diff --git a/README.md b/README.md index 2e01597..7dcab81 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ lifecycle transitions; strict CSR import and proof verification; credential issuance and inspection; revocation inspection and bounded history/snapshot presentation; signed status/CRL generation and inspection; and explicit publication registration, processing, retry, reconciliation, inspection, and -listing. Presentation limits bound terminal output only; backend revocation and +listing. It also exposes immutable X.509 algorithm-binding listing, inspection, +and commitment validation. Presentation limits bound terminal output only; backend revocation and publication cursors remain streaming and do not acquire an aggregate population limit. @@ -70,7 +71,7 @@ only `FAIL_FAST` and `CONTINUE_INDEPENDENT`; they have no scripts, expressions, loops, implicit retries, or rollback across already committed operations. Binary artifacts and secrets are never emitted in terminal JSON. -Version-two session configuration enables capabilities explicitly. A signing +Version-three session configuration enables capabilities explicitly. A signing section selects one signature-workflow provider, one X.509 framework, a durable signing-bus location, an algorithm, a finite signing deadline, and the name of an environment variable supplying the process-local keyring unlock capability. @@ -80,13 +81,26 @@ private keys, and publisher credentials are never operation arguments or output. Read-only version-one configurations remain valid and allocate neither signing nor publisher capabilities. +X.509 algorithm identity is independent of its role-specific OID representation. +Published standard bindings remain sealed. ZeroEcho private assignments are +frozen below the Egothor PEN branch `1.3.6.1.4.1.31424.1.1`; explicitly enabled +deployer providers may contribute immutable bindings only below configuration- +authorized roots, including deployment-local arcs below +`1.3.6.1.4.1.31424.1.2`. Profiles default to standard-only mode and pin both a +binding ID and its semantic commitment when private mode is deliberately chosen. +Private-OID certificates and CRLs require matching binding and cryptographic +support at every relying party; generic PKI software may reject them. + +```text +zeroecho pki algorithm.binding.list --limit 100 --config pki-config.json +zeroecho pki algorithm.binding.inspect --binding-id zeroecho.private.sphincs-plus-default.certificate-signature.v1 --config pki-config.json +zeroecho pki algorithm.binding.validate --config pki-config.json +``` + The CLI and a future server share the same transport-neutral `PkiSession` and typed operations; batch execution opens that service graph once. Backup and import/export commands remain unavailable because the current backend has no -production service implementation for them. Final backend release readiness is -intentionally deferred until the planned extensible X.509 algorithm/OID binding -work and its CLI integration are complete; this CLI does not anticipate or add -placeholders for that future work. +production service implementation for them. ## Development Status diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCli.java b/app/src/main/java/zeroecho/pki/cli/PkiCli.java index 2059985..8846a2e 100644 --- a/app/src/main/java/zeroecho/pki/cli/PkiCli.java +++ b/app/src/main/java/zeroecho/pki/cli/PkiCli.java @@ -67,7 +67,10 @@ public final class PkiCli { Map.entry("--type", "type"), Map.entry("--publication-id", "publicationId"), Map.entry("--source-type", "sourceType"), Map.entry("--source-id", "sourceId"), Map.entry("--target-type", "targetType"), Map.entry("--target-id", "targetId"), - Map.entry("--reason", "reason"), Map.entry("--limit", "limit")); + Map.entry("--reason", "reason"), Map.entry("--limit", "limit"), + Map.entry("--binding-id", "bindingId"), + Map.entry("--binding-commitment", "bindingCommitment"), + Map.entry("--origin", "origin"), Map.entry("--role", "role")); private static final int RUN_TOKEN_COUNT = 2; private PkiCli() { @@ -263,6 +266,9 @@ public final class PkiCli { writer.println("Usage: zeroecho pki --config [--output human|json] [arguments]"); writer.println(" zeroecho pki run --config [--output human|json]"); writer.println("Operations:"); + writer.println(" algorithm.binding.list --limit <1..1000> [--origin ] [--role ]"); + writer.println(" algorithm.binding.inspect --binding-id "); + writer.println(" algorithm.binding.validate [--binding-id --binding-commitment ]"); writer.println(" configuration.validate"); writer.println(" profile.validate --profile-file "); writer.println(" profile.register --profile-file "); diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java index cb40775..d47fe43 100644 --- a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java +++ b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java @@ -48,10 +48,17 @@ import zeroecho.pki.spi.ProviderConfig; final class PkiCliConfiguration { private static final int VERSION_ONE = 1; + private static final int VERSION_TWO = 2; + private static final int VERSION_THREE = 3; private static final Set ROOT_FIELDS_V1 = Set.of("version", "store", "audit"); private static final Set ROOT_FIELDS_V2 = Set.of("version", "store", "audit", "signing", "publishers"); + private static final Set ROOT_FIELDS_V3 = Set.of("version", "store", "audit", "signing", "publishers", + "bindingProviders"); private static final Set PROVIDER_FIELDS = Set.of("provider", "properties"); private static final Set SIGNING_FIELDS = Set.of("workflow", "framework", "busPath", + "signatureAlgorithm", "signingTtlSeconds", "unlockEnvironmentVariable", + "certificateSignatureBinding", "crlSignatureBinding", "subjectPublicKeyBinding"); + private static final Set SIGNING_REQUIRED_FIELDS = Set.of("workflow", "framework", "busPath", "signatureAlgorithm", "signingTtlSeconds", "unlockEnvironmentVariable"); private static final String STDOUT_PROVIDER = "stdout"; @@ -64,8 +71,12 @@ final class PkiCliConfiguration { int version = Math.toIntExact(integer(required(root, "version"))); if (version == VERSION_ONE) { requireExactFields(root.fields(), ROOT_FIELDS_V1); + } else if (version == VERSION_TWO) { + requireFields(root.fields(), ROOT_FIELDS_V2); + } else if (version == VERSION_THREE) { + requireFields(root.fields(), ROOT_FIELDS_V3); } else { - requireVersionTwoFields(root.fields()); + throw new IllegalArgumentException("Unsupported CLI configuration version"); } ProviderConfig store = provider(required(root, "store")); ProviderConfig audit = provider(required(root, "audit")); @@ -80,23 +91,46 @@ final class PkiCliConfiguration { PkiOperationValue publisherValue = root.fields().get("publishers"); List publishers = publisherValue == null ? List.of() : list(publisherValue).values().stream().map(PkiCliConfiguration::provider).toList(); - return new PkiSessionConfiguration(version, store, audit, signing, publishers); + PkiOperationValue bindingProviderValue = root.fields().get("bindingProviders"); + List bindingProviders = bindingProviderValue == null + ? List.of() : list(bindingProviderValue).values().stream() + .map(PkiCliConfiguration::bindingProvider).toList(); + return new PkiSessionConfiguration(version, store, audit, signing, publishers, bindingProviders); } - private static void requireVersionTwoFields(Map actual) { - if (!actual.keySet().containsAll(ROOT_FIELDS_V1) || !ROOT_FIELDS_V2.containsAll(actual.keySet())) { + private static void requireFields(Map actual, Set allowed) { + if (!actual.keySet().containsAll(ROOT_FIELDS_V1) || !allowed.containsAll(actual.keySet())) { throw new IllegalArgumentException("CLI document fields are invalid"); } } + private static PkiSessionConfiguration.BindingProviderConfiguration bindingProvider(PkiOperationValue value) { + PkiOperationValue.ObjectValue object = object(value); + Set fields = object.fields().keySet(); + if (!fields.containsAll(Set.of("providerId", "authorizedOidRoots")) + || !Set.of("providerId", "authorizedOidRoots", "expectedBindingSetVersion").containsAll(fields)) { + throw new IllegalArgumentException("Binding-provider configuration fields are invalid"); + } + List roots = list(required(object, "authorizedOidRoots")).values().stream() + .map(PkiCliConfiguration::text).toList(); + PkiOperationValue expected = object.fields().get("expectedBindingSetVersion"); + return new PkiSessionConfiguration.BindingProviderConfiguration(text(required(object, "providerId")), roots, + expected == null ? java.util.Optional.empty() : java.util.Optional.of(text(expected))); + } + private static PkiSessionConfiguration.SigningConfiguration signing(PkiOperationValue value) { PkiOperationValue.ObjectValue object = object(value); - requireExactFields(object.fields(), SIGNING_FIELDS); + if (!object.fields().keySet().containsAll(SIGNING_REQUIRED_FIELDS) + || !SIGNING_FIELDS.containsAll(object.fields().keySet())) { + throw new IllegalArgumentException("Signing configuration fields are invalid"); + } return new PkiSessionConfiguration.SigningConfiguration(provider(required(object, "workflow")), provider(required(object, "framework")), text(required(object, "busPath")), text(required(object, "signatureAlgorithm")), Duration.ofSeconds(integer(required(object, "signingTtlSeconds"))), - java.util.Optional.of(text(required(object, "unlockEnvironmentVariable")))); + java.util.Optional.of(text(required(object, "unlockEnvironmentVariable"))), + optionalText(object, "certificateSignatureBinding"), optionalText(object, "crlSignatureBinding"), + optionalText(object, "subjectPublicKeyBinding")); } private static ProviderConfig provider(PkiOperationValue value) { @@ -140,6 +174,11 @@ final class PkiCliConfiguration { throw new IllegalArgumentException("CLI field has the wrong type"); } + private static java.util.Optional optionalText(PkiOperationValue.ObjectValue object, String field) { + PkiOperationValue value = object.fields().get(field); + return value == null ? java.util.Optional.empty() : java.util.Optional.of(text(value)); + } + /* default */ static long integer(PkiOperationValue value) { if (value instanceof PkiOperationValue.IntegerValue integer) { return integer.value(); diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java index d60f223..1796892 100644 --- a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java +++ b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java @@ -68,12 +68,16 @@ final class PkiOperationRegistry { private static final String PROFILE_ID = "profileId"; private static final String PROFILE_VERSION = "profileVersion"; private static final String REQUEST_ID = "requestId"; + private static final String BINDING_ID = "bindingId"; private static final int MAXIMUM_REQUEST_BYTES = 1024 * 1024; private final Map bindings; /* default */ PkiOperationRegistry() { Map configured = new LinkedHashMap<>(); + add(configured, PkiOperation.ListAlgorithmBindings.NAME, this::listAlgorithmBindings); + add(configured, PkiOperation.InspectAlgorithmBinding.NAME, this::inspectAlgorithmBinding); + add(configured, PkiOperation.ValidateAlgorithmBindings.NAME, this::validateAlgorithmBindings); add(configured, PkiOperation.ValidateConfiguration.NAME, this::configuration); add(configured, PkiOperation.ValidateProfile.NAME, this::profile); add(configured, PkiOperation.RegisterProfile.NAME, this::registerProfile); @@ -123,6 +127,29 @@ final class PkiOperationRegistry { return new PkiOperation.ValidateConfiguration(); } + private PkiOperation listAlgorithmBindings(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + Set supplied = arguments.fields().keySet(); + if (!supplied.contains(LIMIT) || !Set.of(LIMIT, "origin", "role").containsAll(supplied)) { + throw new IllegalArgumentException("PKI operation arguments are invalid"); + } + return new PkiOperation.ListAlgorithmBindings(optionalText(arguments, "origin"), + optionalText(arguments, "role"), limit(arguments)); + } + + private PkiOperation inspectAlgorithmBinding(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(BINDING_ID)); + return new PkiOperation.InspectAlgorithmBinding(text(arguments, BINDING_ID)); + } + + private PkiOperation validateAlgorithmBindings(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + Set supplied = arguments.fields().keySet(); + if (!(supplied.isEmpty() || supplied.equals(Set.of(BINDING_ID, "bindingCommitment")))) { + throw new IllegalArgumentException("PKI operation arguments are invalid"); + } + return new PkiOperation.ValidateAlgorithmBindings(optionalText(arguments, BINDING_ID), + optionalText(arguments, "bindingCommitment")); + } + private PkiOperation profile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) throws IOException { requireFields(arguments, Set.of(PROFILE_FILE)); Path path = resolve(baseDirectory, text(arguments, PROFILE_FILE)); @@ -296,6 +323,11 @@ final class PkiOperationRegistry { return PkiCliConfiguration.text(PkiCliConfiguration.required(arguments, name)); } + private static java.util.Optional optionalText(PkiOperationValue.ObjectValue arguments, String name) { + PkiOperationValue value = arguments.fields().get(name); + return value == null ? java.util.Optional.empty() : java.util.Optional.of(PkiCliConfiguration.text(value)); + } + private static long integer(PkiOperationValue.ObjectValue arguments, String name) { return PkiCliConfiguration.integer(PkiCliConfiguration.required(arguments, name)); } diff --git a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java index 448681d..ddee736 100644 --- a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java +++ b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java @@ -168,6 +168,33 @@ class PkiCliTest { System.out.println("...ok"); } + @Test + void bindingAdministrationUsesDirectAndBatchTypedExecution() throws IOException { + System.out.println("bindingAdministrationUsesDirectAndBatchTypedExecution"); + Path configuration = configuration("bindings"); + ByteArrayOutputStream listed = new ByteArrayOutputStream(); + int listCode = PkiCli.execute(new String[] { "algorithm.binding.list", "--limit", "64", "--config", + configuration.toString(), "--output", "json" }, listed); + String bindingId = "zeroecho.private.sphincs-plus-default.certificate-signature.v1"; + Path workflow = plan("binding-workflow.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"binding","operation":"algorithm.binding.inspect","arguments":{"bindingId":"%s"}}, + {"id":"validate","operation":"algorithm.binding.validate","arguments":{"bindingId":"${binding.bindingId}","bindingCommitment":"${binding.bindingCommitment}"}} + ]} + """.formatted(bindingId)); + ByteArrayOutputStream batch = new ByteArrayOutputStream(); + int batchCode = PkiCli.execute(new String[] { "run", workflow.toString(), "--config", + configuration.toString(), "--output", "json" }, batch); + String output = listed.toString(StandardCharsets.UTF_8) + batch.toString(StandardCharsets.UTF_8); + System.out.println("...outputBytes=" + output.length()); + assertEquals(PkiExitCodes.SUCCESS, listCode); + assertEquals(PkiExitCodes.SUCCESS, batchCode); + assertTrue(output.contains("1.3.6.1.4.1.31424.1.1.3")); + assertTrue(output.contains("ZEROECHO_PRIVATE")); + assertFalse(output.contains("codecClass")); + System.out.println("...ok"); + } + @Test void versionTwoConfigurationKeepsCapabilitiesExplicit() throws IOException { System.out.println("versionTwoConfigurationKeepsCapabilitiesExplicit"); @@ -393,6 +420,40 @@ class PkiCliTest { throw new UnsupportedOperationException(); } + @Override + public zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry algorithmBindings() { + return new zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry() { + @Override + public java.util.List bindings() { + return java.util.List.of(); + } + + @Override + public java.util.Optional find( + String bindingId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional standard( + zeroecho.core.spec.AlgorithmIdentity identity, + zeroecho.pki.api.algorithm.X509AlgorithmBinding.Role role) { + return java.util.Optional.empty(); + } + + @Override + public String commitment() { + return "test-registry"; + } + + @Override + public zeroecho.pki.api.algorithm.X509AlgorithmBinding require(String bindingId, + String expectedCommitment) { + throw new IllegalArgumentException("inactive test binding"); + } + }; + } + @Override public zeroecho.pki.application.PkiOperationExecutor operations() { return (operation, cancellation) -> outcome(operation); diff --git a/app/src/test/resources/pki-administration-config.json b/app/src/test/resources/pki-administration-config.json index 9787fbc..1afee31 100644 --- a/app/src/test/resources/pki-administration-config.json +++ b/app/src/test/resources/pki-administration-config.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "store": { "provider": "fs", "properties": { diff --git a/app/src/test/resources/pki-administration-plan.json b/app/src/test/resources/pki-administration-plan.json index e1eee8c..6ef8319 100644 --- a/app/src/test/resources/pki-administration-plan.json +++ b/app/src/test/resources/pki-administration-plan.json @@ -7,6 +7,21 @@ "operation": "configuration.validate", "arguments": {} }, + { + "id": "inspect-binding", + "operation": "algorithm.binding.inspect", + "arguments": { + "bindingId": "zeroecho.private.sphincs-plus-default.certificate-signature.v1" + } + }, + { + "id": "validate-binding", + "operation": "algorithm.binding.validate", + "arguments": { + "bindingId": "${inspect-binding.bindingId}", + "bindingCommitment": "${inspect-binding.bindingCommitment}" + } + }, { "id": "list-publications", "operation": "publication.list", diff --git a/app/src/test/resources/pki-private-sphincs-profile.json b/app/src/test/resources/pki-private-sphincs-profile.json new file mode 100644 index 0000000..33feef5 --- /dev/null +++ b/app/src/test/resources/pki-private-sphincs-profile.json @@ -0,0 +1 @@ +{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"server-tls-sphincs","profileVersion":1,"formatId":"x509","displayName":"Server TLS SPHINCS+ private binding","maxValidity":"PT8760H","algorithmBindings":{"mode":"EXPLICIT","subjectPublicKey":{"bindingId":"zeroecho.private.sphincs-plus-default.spki.v1","semanticCommitment":"zeroecho.private.sphincs-plus-default.spki.v1|zealg:2:10:public_key8:zeroecho12:sphincs-plus16:zeroecho.builtin6:bm9uZQ|SUBJECT_PUBLIC_KEY|1.3.6.1.4.1.31424.1.1.1|1|ABSENT|NESTED_SPKI_DER|NOT_APPLICABLE|ZEROECHO_ECOSYSTEM"},"csrSignature":{"bindingId":"zeroecho.private.sphincs-plus-default.csr-signature.v1","semanticCommitment":"zeroecho.private.sphincs-plus-default.csr-signature.v1|zealg:2:9:signature8:zeroecho12:sphincs-plus16:zeroecho.builtin6:bm9uZQ|CSR_SIGNATURE|1.3.6.1.4.1.31424.1.1.2|1|ABSENT|NOT_APPLICABLE|OPAQUE|ZEROECHO_ECOSYSTEM"},"certificateSignature":{"bindingId":"zeroecho.private.sphincs-plus-default.certificate-signature.v1","semanticCommitment":"zeroecho.private.sphincs-plus-default.certificate-signature.v1|zealg:2:9:signature8:zeroecho12:sphincs-plus16:zeroecho.builtin6:bm9uZQ|CERTIFICATE_SIGNATURE|1.3.6.1.4.1.31424.1.1.3|1|ABSENT|NOT_APPLICABLE|OPAQUE|ZEROECHO_ECOSYSTEM"}},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["SPHINCS+"]}} diff --git a/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java b/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java index 2d92151..eb6d2e7 100644 --- a/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java +++ b/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java @@ -63,6 +63,7 @@ public final class BootstrapAlgorithmIdentities { private static final AlgorithmIdentity.Family ECDSA = family("ecdsa"); private static final AlgorithmIdentity.Family ED25519_FAMILY = family("ed25519"); private static final AlgorithmIdentity.Family ED448_FAMILY = family("ed448"); + private static final AlgorithmIdentity.Family SPHINCS_PLUS_FAMILY = family("sphincs-plus"); private static final AlgorithmIdentity.Family RSA_KEY = family("rsa"); private static final AlgorithmIdentity.Family EC_KEY = family("ec"); @@ -99,6 +100,9 @@ public final class BootstrapAlgorithmIdentities { /** Ed448 signature identity. */ public static final AlgorithmIdentity ED448_SIGNATURE = identity(AlgorithmIdentity.Kind.SIGNATURE, ED448_FAMILY, AlgorithmIdentity.NoParameters.INSTANCE); + /** ZeroEcho SPHINCS+ default signature identity. */ + public static final AlgorithmIdentity SPHINCS_PLUS_SIGNATURE = identity(AlgorithmIdentity.Kind.SIGNATURE, + SPHINCS_PLUS_FAMILY, AlgorithmIdentity.NoParameters.INSTANCE); /** RSA public-key identity. */ public static final AlgorithmIdentity RSA_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY, RSA_KEY, @@ -115,6 +119,9 @@ public final class BootstrapAlgorithmIdentities { /** Ed448 public-key identity. */ public static final AlgorithmIdentity ED448_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY, ED448_FAMILY, AlgorithmIdentity.NoParameters.INSTANCE); + /** ZeroEcho SPHINCS+ default public-key identity. */ + public static final AlgorithmIdentity SPHINCS_PLUS_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY, + SPHINCS_PLUS_FAMILY, AlgorithmIdentity.NoParameters.INSTANCE); /** Current ECDSA P-256 signing suite. */ public static final AlgorithmSuite ECDSA_SHA256_P256 = new AlgorithmSuite(ECDSA_SHA256, EC_P256_PUBLIC_KEY); @@ -127,8 +134,9 @@ public final class BootstrapAlgorithmIdentities { private static final List IDENTITIES = List.of(SHA256, SHA384, SHA512, MGF1, RSA_PKCS1_SHA256, RSA_PKCS1_SHA384, RSA_PKCS1_SHA512, RSA_PSS_SHA256, ECDSA_SHA256, ECDSA_SHA384, ECDSA_SHA512, - ED25519_SIGNATURE, ED448_SIGNATURE, RSA_PUBLIC_KEY, EC_P256_PUBLIC_KEY, EC_P384_PUBLIC_KEY, - EC_P521_PUBLIC_KEY, ED25519_PUBLIC_KEY, ED448_PUBLIC_KEY); + ED25519_SIGNATURE, ED448_SIGNATURE, SPHINCS_PLUS_SIGNATURE, RSA_PUBLIC_KEY, EC_P256_PUBLIC_KEY, + EC_P384_PUBLIC_KEY, EC_P521_PUBLIC_KEY, ED25519_PUBLIC_KEY, ED448_PUBLIC_KEY, + SPHINCS_PLUS_PUBLIC_KEY); private static final AlgorithmIdentityCatalog CATALOG = AlgorithmIdentityCatalog.builtIn(IDENTITIES); @@ -141,7 +149,8 @@ public final class BootstrapAlgorithmIdentities { Map.entry("SHA384withECDSA", ECDSA_SHA384), Map.entry("SHA512withECDSA", ECDSA_SHA512), Map.entry("Ed25519", ED25519_SIGNATURE), - Map.entry("Ed448", ED448_SIGNATURE)); + Map.entry("Ed448", ED448_SIGNATURE), + Map.entry("SPHINCS+", SPHINCS_PLUS_SIGNATURE)); private BootstrapAlgorithmIdentities() { } diff --git a/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java b/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java index b6cd0a9..895383d 100644 --- a/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java +++ b/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java @@ -118,7 +118,9 @@ public final class SignatureInteropProfiles { new SignatureInteropProfile("Ed25519", "Ed25519", "Ed25519", VoidSpec.INSTANCE, SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)), Map.entry("Ed448", new SignatureInteropProfile("Ed448", "Ed448", "Ed448", VoidSpec.INSTANCE, - SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0))); + SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)), + Map.entry("SPHINCS+", new SignatureInteropProfile("SPHINCS+", "SPHINCS+", "SPHINCS+", + VoidSpec.INSTANCE, SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0))); private static final Map CANONICAL_PROFILES = Map.ofEntries( canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, "SHA256withRSA"), @@ -129,7 +131,8 @@ public final class SignatureInteropProfiles { canonical(BootstrapAlgorithmIdentities.ECDSA_SHA384, "SHA384withECDSA"), canonical(BootstrapAlgorithmIdentities.ECDSA_SHA512, "SHA512withECDSA"), canonical(BootstrapAlgorithmIdentities.ED25519_SIGNATURE, "Ed25519"), - canonical(BootstrapAlgorithmIdentities.ED448_SIGNATURE, "Ed448")); + canonical(BootstrapAlgorithmIdentities.ED448_SIGNATURE, "Ed448"), + canonical(BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE, "SPHINCS+")); private SignatureInteropProfiles() { } diff --git a/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java new file mode 100644 index 0000000..7151a90 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java @@ -0,0 +1,167 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.api.algorithm; + +import java.util.Objects; +import java.util.Optional; + +import zeroecho.core.spec.AlgorithmIdentity; + +/** + * Immutable safe descriptor of one role-specific X.509 algorithm binding. + * + *

The descriptor separates cryptographic identity, ASN.1 identity, namespace + * ownership, encoding rules, and provider implementation. It contains no codec + * class, key material, provider secret, or caller-supplied DER.

+ * + * @param bindingId stable semantic binding identifier + * @param algorithmIdentity exact provider-independent cryptographic identity + * @param role exact X.509 use role + * @param oid complete dotted-decimal object identifier + * @param origin namespace ownership + * @param encodingVersion positive immutable encoding-contract version + * @param parameterRule canonical AlgorithmIdentifier parameter rule + * @param publicKeyEncoding public-key BIT STRING contract + * @param signatureEncoding signature BIT STRING contract + * @param interoperability finite interoperability classification + * @param providerId contributing provider for deployer bindings + * @param semanticCommitment deterministic commitment to the complete contract + */ +public record X509AlgorithmBinding(String bindingId, AlgorithmIdentity algorithmIdentity, Role role, String oid, + Origin origin, int encodingVersion, ParameterRule parameterRule, PublicKeyEncoding publicKeyEncoding, + SignatureEncoding signatureEncoding, Interoperability interoperability, Optional providerId, + String semanticCommitment) { + + /** X.509 locations whose representations are independently authorized. */ + public enum Role { + /** SubjectPublicKeyInfo algorithm and public-key BIT STRING. */ + SUBJECT_PUBLIC_KEY, + /** PKCS#10 certification-request signature. */ + CSR_SIGNATURE, + /** Certificate signature. */ + CERTIFICATE_SIGNATURE, + /** Certificate-revocation-list signature. */ + CRL_SIGNATURE + } + + /** Authority that owns the OID assignment. */ + public enum Origin { + /** Published standards assignment. */ + STANDARD, + /** Frozen ZeroEcho assignment under the Egothor PEN. */ + ZEROECHO_PRIVATE, + /** Explicitly enabled deployment-owned assignment. */ + DEPLOYER_PRIVATE + } + + /** Canonical AlgorithmIdentifier parameter representation. */ + public enum ParameterRule { + /** Parameters must be absent. */ + ABSENT, + /** Parameters must be canonical DER NULL. */ + DER_NULL, + /** Parameters are a binding-owned canonical DER structure. */ + STRUCTURED_DER + } + + /** Subject-public-key BIT STRING representation. */ + public enum PublicKeyEncoding { + /** This binding is not an SPKI binding. */ + NOT_APPLICABLE, + /** Standard RSA PKCS#1 public-key DER. */ + RSA_PKCS1_DER, + /** SEC1 elliptic-curve point. */ + EC_POINT, + /** Algorithm-defined raw public-key bytes. */ + RAW, + /** Canonical native SPKI DER nested as the BIT STRING payload. */ + NESTED_SPKI_DER + } + + /** Signature BIT STRING representation. */ + public enum SignatureEncoding { + /** This binding is not a signature binding. */ + NOT_APPLICABLE, + /** Algorithm-defined opaque signature bytes. */ + OPAQUE, + /** Canonical DER sequence of positive ECDSA integers. */ + ECDSA_DER + } + + /** Relying-party interoperability expectation. */ + public enum Interoperability { + /** Published standards ecosystems. */ + STANDARD_INTEROPERABLE, + /** Relying parties with matching ZeroEcho bindings. */ + ZEROECHO_ECOSYSTEM, + /** One explicitly configured deployment ecosystem. */ + DEPLOYER_ECOSYSTEM, + /** Experimental representation without general interoperability. */ + EXPERIMENTAL + } + + /** Validates and snapshots one descriptor. */ + public X509AlgorithmBinding { + bindingId = requireText(bindingId, "bindingId"); + Objects.requireNonNull(algorithmIdentity, "algorithmIdentity"); + Objects.requireNonNull(role, "role"); + oid = requireText(oid, "oid"); + Objects.requireNonNull(origin, "origin"); + if (encodingVersion <= 0) { + throw new IllegalArgumentException("encodingVersion must be positive"); + } + Objects.requireNonNull(parameterRule, "parameterRule"); + Objects.requireNonNull(publicKeyEncoding, "publicKeyEncoding"); + Objects.requireNonNull(signatureEncoding, "signatureEncoding"); + Objects.requireNonNull(interoperability, "interoperability"); + providerId = Objects.requireNonNull(providerId, "providerId").map(value -> requireText(value, "providerId")); + semanticCommitment = requireText(semanticCommitment, "semanticCommitment"); + if (origin == Origin.DEPLOYER_PRIVATE != providerId.isPresent()) { + throw new IllegalArgumentException("Only deployer bindings declare a providerId"); + } + if ((role == Role.SUBJECT_PUBLIC_KEY) != (signatureEncoding == SignatureEncoding.NOT_APPLICABLE)) { + throw new IllegalArgumentException("Binding role and signature encoding disagree"); + } + if ((role != Role.SUBJECT_PUBLIC_KEY) != (publicKeyEncoding == PublicKeyEncoding.NOT_APPLICABLE)) { + throw new IllegalArgumentException("Binding role and public-key encoding disagree"); + } + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBindingRegistry.java b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBindingRegistry.java new file mode 100644 index 0000000..0003fed --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBindingRegistry.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.api.algorithm; + +import java.util.List; +import java.util.Optional; + +import zeroecho.core.spec.AlgorithmIdentity; + +/** + * Immutable active X.509 algorithm-binding registry shared by one PKI session. + * + *

Implementations provide deterministic constant-time identifier lookup and + * linear-time ordered listing. Returned collections are immutable snapshots.

+ */ +public interface X509AlgorithmBindingRegistry { + + /** @return deterministic immutable binding order */ + List bindings(); + + /** + * Resolves a stable binding identifier. + * + * @param bindingId stable identifier + * @return exact descriptor, or empty when inactive + */ + Optional find(String bindingId); + + /** + * Resolves the standard default for one identity and role. + * + * @param identity exact cryptographic identity + * @param role X.509 role + * @return official binding, or empty when no official binding exists + */ + Optional standard(AlgorithmIdentity identity, X509AlgorithmBinding.Role role); + + /** @return deterministic SHA-256 commitment to the complete active set */ + String commitment(); + + /** + * Requires that one explicit binding remains active with an exact commitment. + * + * @param bindingId stable binding identifier + * @param expectedCommitment persisted binding-contract commitment + * @return validated binding + * @throws IllegalArgumentException if the binding is absent or changed + */ + X509AlgorithmBinding require(String bindingId, String expectedCommitment); +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java index c0a020a..238a4f0 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java @@ -60,7 +60,8 @@ public record CaCertificatePolicy(boolean basicConstraintsCritical, int pathLeng /** Maximum supported path-length constraint. */ public static final int MAXIMUM_PATH_LENGTH = 32; - private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448"); + private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448", + "SPHINCS+"); /** Validates and constructs the CA policy. */ public CaCertificatePolicy { diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java index a3953be..e4233d4 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java @@ -56,9 +56,18 @@ import zeroecho.pki.api.FormatId; * @param maximumValidity positive maximum validity * @param subjectPolicy complete end-entity subject policy * @param leafPolicy complete end-entity extension policy + * @param algorithmBindingPolicy explicit role-specific X.509 representation policy */ public record CertificateProfile(String profileId, FormatId formatId, String displayName, Duration maximumValidity, - SubjectPolicy subjectPolicy, LeafCertificatePolicy leafPolicy) { + SubjectPolicy subjectPolicy, LeafCertificatePolicy leafPolicy, + X509AlgorithmBindingPolicy algorithmBindingPolicy) { + + /** Creates a standard-only runtime profile. */ + public CertificateProfile(String profileId, FormatId formatId, String displayName, Duration maximumValidity, + SubjectPolicy subjectPolicy, LeafCertificatePolicy leafPolicy) { + this(profileId, formatId, displayName, maximumValidity, subjectPolicy, leafPolicy, + X509AlgorithmBindingPolicy.standardOnly()); + } /** * Creates a certificate profile. @@ -77,7 +86,7 @@ public record CertificateProfile(String profileId, FormatId formatId, String dis throw new IllegalArgumentException("displayName must not be null/blank"); } if (maximumValidity == null || maximumValidity.isZero() || maximumValidity.isNegative() || subjectPolicy == null - || leafPolicy == null) { + || leafPolicy == null || algorithmBindingPolicy == null) { throw new IllegalArgumentException("profile policies and maximum validity must be valid"); } } @@ -96,6 +105,7 @@ public record CertificateProfile(String profileId, FormatId formatId, String dis throw new IllegalArgumentException("Only end-entity definitions have a runtime issuance projection"); } return new CertificateProfile(definition.profileId(), definition.formatId(), definition.displayName(), - definition.maximumValidity(), definition.subjectPolicy(), definition.leafPolicy()); + definition.maximumValidity(), definition.subjectPolicy(), definition.leafPolicy(), + definition.algorithmBindingPolicy()); } } diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java index 0a8ad0c..c899489 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java @@ -41,8 +41,9 @@ import zeroecho.pki.api.FormatId; * Immutable, versioned certificate-profile configuration. * *

- * Schema version 2 replaces the pre-release version 1 shape. The certificate - * kind and its closed policy variant are immutable across the definition. + * Schema version 3 adds explicit immutable X.509 algorithm-binding selection to + * the closed pre-release profile shape. The certificate kind and its closed + * policy variant are immutable across the definition. * Runtime activation state is deliberately excluded. *

* @@ -54,13 +55,23 @@ import zeroecho.pki.api.FormatId; * @param maximumValidity positive maximum validity * @param subjectPolicy complete subject policy * @param certificatePolicy closed certificate-specific policy variant + * @param algorithmBindingPolicy explicit X.509 representation policy */ public record CertificateProfileDefinition(CertificateProfileKind certificateType, String profileId, long profileVersion, FormatId formatId, String displayName, Duration maximumValidity, - SubjectPolicy subjectPolicy, CertificatePolicy certificatePolicy) { + SubjectPolicy subjectPolicy, CertificatePolicy certificatePolicy, + X509AlgorithmBindingPolicy algorithmBindingPolicy) { /** Current certificate-profile document schema version. */ - public static final int SCHEMA_VERSION = 2; + public static final int SCHEMA_VERSION = 3; + + /** Creates a standard-only profile definition. */ + public CertificateProfileDefinition(CertificateProfileKind certificateType, String profileId, + long profileVersion, FormatId formatId, String displayName, Duration maximumValidity, + SubjectPolicy subjectPolicy, CertificatePolicy certificatePolicy) { + this(certificateType, profileId, profileVersion, formatId, displayName, maximumValidity, subjectPolicy, + certificatePolicy, X509AlgorithmBindingPolicy.standardOnly()); + } /** * Creates a certificate-profile definition. @@ -87,7 +98,7 @@ public record CertificateProfileDefinition(CertificateProfileKind certificateTyp if (maximumValidity == null || maximumValidity.isZero() || maximumValidity.isNegative()) { throw new IllegalArgumentException("maximumValidity must be positive"); } - if (subjectPolicy == null || certificatePolicy == null) { + if (subjectPolicy == null || certificatePolicy == null || algorithmBindingPolicy == null) { throw new IllegalArgumentException("profile policies must not be null"); } boolean leaf = certificatePolicy instanceof LeafCertificatePolicy; diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java index f8a4925..cb6ef2b 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java @@ -237,7 +237,7 @@ public final class CertificateProfileDocumentCodec { requireValue(parser, "$." + field); readDocumentField(parser, field, document); } - requireAll(document.seen, 8, "$"); + requireAll(document.seen, 9, "$"); if (document.schemaVersion != CertificateProfileDefinition.SCHEMA_VERSION) { throw failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion"); } @@ -286,6 +286,10 @@ public final class CertificateProfileDocumentCodec { document.seen = mark(document.seen, 7, "$.subject"); document.subject = readSubject(parser, "$.subject"); } + case "algorithmBindings" -> { + document.seen = mark(document.seen, 8, "$.algorithmBindings"); + document.algorithmBindings = readAlgorithmBindings(parser, "$.algorithmBindings"); + } case "subjectAlternativeNames" -> readDocumentSan(parser, document); case "leafCertificate" -> readDocumentLeaf(parser, document); case "caCertificate" -> readDocumentCa(parser, document); @@ -297,7 +301,7 @@ public final class CertificateProfileDocumentCodec { if (document.certificateType != null && document.certificateType != CertificateProfileKind.END_ENTITY) { throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.subjectAlternativeNames"); } - document.seen = mark(document.seen, 8, "$.subjectAlternativeNames"); + document.seen = mark(document.seen, 9, "$.subjectAlternativeNames"); document.san = readSan(parser, "$.subjectAlternativeNames"); } @@ -305,7 +309,7 @@ public final class CertificateProfileDocumentCodec { if (document.certificateType != null && document.certificateType != CertificateProfileKind.END_ENTITY) { throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.leafCertificate"); } - document.seen = mark(document.seen, 9, "$.leafCertificate"); + document.seen = mark(document.seen, 10, "$.leafCertificate"); document.leaf = readLeaf(parser, "$.leafCertificate"); } @@ -313,14 +317,14 @@ public final class CertificateProfileDocumentCodec { if (document.certificateType == CertificateProfileKind.END_ENTITY) { throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.caCertificate"); } - document.seen = mark(document.seen, 10, "$.caCertificate"); + document.seen = mark(document.seen, 11, "$.caCertificate"); document.ca = readCa(parser, "$.caCertificate"); } private static void requirePolicyFields(CertificateProfileKind certificateType, long seen) { - boolean hasSan = isSeen(seen, 8); - boolean hasLeaf = isSeen(seen, 9); - boolean hasCa = isSeen(seen, 10); + boolean hasSan = isSeen(seen, 9); + boolean hasLeaf = isSeen(seen, 10); + boolean hasCa = isSeen(seen, 11); if (certificateType == CertificateProfileKind.END_ENTITY) { if (hasCa) { throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.caCertificate"); @@ -366,7 +370,7 @@ public final class CertificateProfileDocumentCodec { } return new CertificateProfileDefinition(document.certificateType, document.profileId, document.profileVersion, new FormatId(document.formatId), document.displayName, - document.maximumValidity, subjectPolicy, policy); + document.maximumValidity, subjectPolicy, policy, document.algorithmBindings); } catch (IllegalArgumentException | ArithmeticException ex) { throw failure("SEMANTIC_INVALID", "$"); } @@ -397,6 +401,89 @@ public final class CertificateProfileDocumentCodec { return new SubjectSection(allowEmpty, rules); } + private static X509AlgorithmBindingPolicy readAlgorithmBindings(JsonParser parser, String path) + throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT, path); + long seen = 0; + X509AlgorithmBindingPolicy.Mode mode = null; + Optional subject = Optional.empty(); + Optional csr = Optional.empty(); + Optional certificate = Optional.empty(); + Optional crl = Optional.empty(); + while (parser.nextToken() != JsonToken.END_OBJECT) { + requireToken(parser.currentToken(), JsonToken.PROPERTY_NAME, path); + String field = parser.currentName(); + requireValue(parser, path + "." + field); + switch (field) { + case "mode" -> { + seen = mark(seen, 0, path + ".mode"); + mode = readEnum(parser, X509AlgorithmBindingPolicy.Mode.class, path + ".mode"); + } + case "subjectPublicKey" -> { + seen = mark(seen, 1, path + ".subjectPublicKey"); + subject = Optional.of(readBindingReference(parser, path + ".subjectPublicKey")); + } + case "csrSignature" -> { + seen = mark(seen, 2, path + ".csrSignature"); + csr = Optional.of(readBindingReference(parser, path + ".csrSignature")); + } + case "certificateSignature" -> { + seen = mark(seen, 3, path + ".certificateSignature"); + certificate = Optional.of(readBindingReference(parser, path + ".certificateSignature")); + } + case "crlSignature" -> { + seen = mark(seen, 4, path + ".crlSignature"); + crl = Optional.of(readBindingReference(parser, path + ".crlSignature")); + } + default -> throw failure("UNKNOWN_FIELD", path + ".?"); + } + } + if (!isSeen(seen, 0)) { + throw failure("MISSING_FIELD", path + ".mode"); + } + try { + return new X509AlgorithmBindingPolicy(mode, subject, csr, certificate, crl); + } catch (IllegalArgumentException exception) { + throw failure("SEMANTIC_INVALID", path); + } + } + + private static X509AlgorithmBindingPolicy.BindingReference readBindingReference(JsonParser parser, String path) + throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT, path); + long seen = 0; + String bindingId = null; + String commitment = null; + while (parser.nextToken() != JsonToken.END_OBJECT) { + requireToken(parser.currentToken(), JsonToken.PROPERTY_NAME, path); + String field = parser.currentName(); + requireValue(parser, path + "." + field); + switch (field) { + case "bindingId" -> { + seen = mark(seen, 0, path + ".bindingId"); + bindingId = readBoundedString(parser, path + ".bindingId", 512); + } + case "semanticCommitment" -> { + seen = mark(seen, 1, path + ".semanticCommitment"); + commitment = readBoundedString(parser, path + ".semanticCommitment", 1024); + } + default -> throw failure("UNKNOWN_FIELD", path + ".?"); + } + } + requireAll(seen, 2, path); + return new X509AlgorithmBindingPolicy.BindingReference(bindingId, commitment); + } + + private static > E readEnum(JsonParser parser, Class type, String path) + throws JacksonException { + String value = readString(parser, path); + try { + return Enum.valueOf(type, value); + } catch (IllegalArgumentException exception) { + throw failure("TOKEN_INVALID", path); + } + } + private static List readSubjectRules(JsonParser parser, String path) throws JacksonException { requireToken(parser.currentToken(), JsonToken.START_ARRAY, path); List rules = new ArrayList<>(); @@ -785,6 +872,7 @@ public final class CertificateProfileDocumentCodec { generator.writeStringProperty("formatId", definition.formatId().value()); generator.writeStringProperty("displayName", definition.displayName()); generator.writeStringProperty("maxValidity", definition.maximumValidity().toString()); + writeAlgorithmBindings(generator, definition.algorithmBindingPolicy()); writeSubject(generator, definition.subjectPolicy()); if (definition.certificateType() == CertificateProfileKind.END_ENTITY) { LeafCertificatePolicy leaf = definition.leafPolicy(); @@ -816,6 +904,29 @@ public final class CertificateProfileDocumentCodec { generator.writeEndObject(); } + private static void writeAlgorithmBindings(JsonGenerator generator, X509AlgorithmBindingPolicy policy) + throws JacksonException { + generator.writeObjectPropertyStart("algorithmBindings"); + generator.writeStringProperty("mode", policy.mode().name()); + writeBindingReference(generator, "subjectPublicKey", policy.subjectPublicKey()); + writeBindingReference(generator, "csrSignature", policy.csrSignature()); + writeBindingReference(generator, "certificateSignature", policy.certificateSignature()); + writeBindingReference(generator, "crlSignature", policy.crlSignature()); + generator.writeEndObject(); + } + + private static void writeBindingReference(JsonGenerator generator, String field, + Optional reference) throws JacksonException { + if (reference.isEmpty()) { + return; + } + X509AlgorithmBindingPolicy.BindingReference value = reference.orElseThrow(); + generator.writeObjectPropertyStart(field); + generator.writeStringProperty("bindingId", value.bindingId()); + generator.writeStringProperty("semanticCommitment", value.semanticCommitment()); + generator.writeEndObject(); + } + private static void writeSan(JsonGenerator generator, SubjectAlternativeNamePolicy policy) throws JacksonException { generator.writeObjectPropertyStart("subjectAlternativeNames"); generator.writeNumberProperty("minimumTotal", policy.minimumTotal()); @@ -1098,7 +1209,8 @@ public final class CertificateProfileDocumentCodec { } private static boolean isAllowedAlgorithm(String value) { - return "RSA".equals(value) || "ECDSA".equals(value) || "Ed25519".equals(value) || "Ed448".equals(value); + return "RSA".equals(value) || "ECDSA".equals(value) || "Ed25519".equals(value) || "Ed448".equals(value) + || "SPHINCS+".equals(value); } private static boolean hasUtf8Bom(byte[] value) { @@ -1124,6 +1236,7 @@ public final class CertificateProfileDocumentCodec { private String formatId; private String displayName; private Duration maximumValidity; + private X509AlgorithmBindingPolicy algorithmBindings; private SubjectSection subject; private SanSection san; private LeafSection leaf; diff --git a/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java index 207da37..3eca65d 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java @@ -52,7 +52,8 @@ public record LeafCertificatePolicy(SubjectAlternativeNamePolicy subjectAlternat boolean extendedKeyUsageCritical, boolean basicConstraintsCritical, Set allowedSubjectKeyAlgorithmIds) implements CertificatePolicy { - private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448"); + private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448", + "SPHINCS+"); /** * Validates and constructs the policy. diff --git a/pki/src/main/java/zeroecho/pki/api/profile/X509AlgorithmBindingPolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/X509AlgorithmBindingPolicy.java new file mode 100644 index 0000000..d833662 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/X509AlgorithmBindingPolicy.java @@ -0,0 +1,100 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.util.Objects; +import java.util.Optional; + +/** + * Immutable explicit X.509 representation policy persisted with a certificate + * profile. + * + *

Standard mode never falls back to a private OID. Explicit mode pins stable + * binding identifiers and their immutable semantic commitments. Raw OIDs and DER + * parameters are deliberately absent from this model.

+ * + * @param mode standard-only or explicit binding selection + * @param subjectPublicKey optional SPKI binding + * @param csrSignature optional CSR-signature binding + * @param certificateSignature optional certificate-signature binding + * @param crlSignature optional CRL-signature binding + */ +public record X509AlgorithmBindingPolicy(Mode mode, Optional subjectPublicKey, + Optional csrSignature, Optional certificateSignature, + Optional crlSignature) { + + /** Selection mode. */ + public enum Mode { + /** Only sealed official bindings may be selected. */ + STANDARD_ONLY, + /** Every custom representation is selected by stable binding identity. */ + EXPLICIT + } + + /** + * Stable reference to one immutable binding contract. + * + * @param bindingId stable binding identity + * @param semanticCommitment exact contract commitment + */ + public record BindingReference(String bindingId, String semanticCommitment) { + /** Validates one binding reference. */ + public BindingReference { + if (bindingId == null || bindingId.isBlank() || semanticCommitment == null + || semanticCommitment.isBlank() || semanticCommitment.length() > 1024) { + throw new IllegalArgumentException("X.509 binding reference is invalid"); + } + } + } + + /** Validates and snapshots the policy. */ + public X509AlgorithmBindingPolicy { + Objects.requireNonNull(mode, "mode"); + subjectPublicKey = Objects.requireNonNull(subjectPublicKey, "subjectPublicKey"); + csrSignature = Objects.requireNonNull(csrSignature, "csrSignature"); + certificateSignature = Objects.requireNonNull(certificateSignature, "certificateSignature"); + crlSignature = Objects.requireNonNull(crlSignature, "crlSignature"); + boolean selected = subjectPublicKey.isPresent() || csrSignature.isPresent() + || certificateSignature.isPresent() || crlSignature.isPresent(); + if (mode == Mode.STANDARD_ONLY == selected) { + throw new IllegalArgumentException("X.509 binding mode and selections disagree"); + } + } + + /** @return standard-only policy with no private fallback */ + public static X509AlgorithmBindingPolicy standardOnly() { + return new X509AlgorithmBindingPolicy(Mode.STANDARD_ONLY, Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java index d92f6b2..9bc7229 100644 --- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java @@ -53,6 +53,8 @@ import zeroecho.pki.api.PublicationService; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.StatusObjectService; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; +import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.Credential; @@ -62,6 +64,7 @@ import zeroecho.pki.api.profile.CertificateProfileDefinition; import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; import zeroecho.pki.api.profile.CertificateProfileRef; import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.api.profile.X509AlgorithmBindingPolicy; import zeroecho.pki.api.issuance.IssueEndEntityCommand; import zeroecho.pki.api.issuance.VerificationPolicy; import zeroecho.pki.api.publication.PublicationRequest; @@ -101,12 +104,13 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { private final Optional issuance; private final Optional statusObjects; private final Optional publications; + private final X509AlgorithmBindingRegistry algorithmBindings; private final Runnable openCheck; /* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store, ProfileService profiles, RevocationService revocations, Runnable openCheck) { this(configuration, store, profiles, revocations, Optional.empty(), Optional.empty(), Optional.empty(), - Optional.empty(), Optional.empty(), openCheck); + Optional.empty(), Optional.empty(), defaultBindings(), openCheck); } /* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store, @@ -114,6 +118,16 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { Optional requests, Optional issuance, Optional statusObjects, Optional publications, Runnable openCheck) { + this(configuration, store, profiles, revocations, authorities, requests, issuance, statusObjects, + publications, defaultBindings(), openCheck); + } + + /* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store, + ProfileService profiles, RevocationService revocations, Optional authorities, + Optional requests, Optional issuance, + Optional statusObjects, Optional publications, + X509AlgorithmBindingRegistry algorithmBindings, + Runnable openCheck) { this.configuration = Objects.requireNonNull(configuration, "configuration"); this.store = Objects.requireNonNull(store, "store"); this.profiles = Objects.requireNonNull(profiles, "profiles"); @@ -123,6 +137,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { this.issuance = Objects.requireNonNull(issuance, "issuance"); this.statusObjects = Objects.requireNonNull(statusObjects, "statusObjects"); this.publications = Objects.requireNonNull(publications, "publications"); + this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings"); this.openCheck = Objects.requireNonNull(openCheck, "openCheck"); } @@ -134,6 +149,9 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { try { signal.throwIfCancelled(); PkiOperationResult result = switch (exact) { + case PkiOperation.ListAlgorithmBindings request -> listAlgorithmBindings(request); + case PkiOperation.InspectAlgorithmBinding request -> inspectAlgorithmBinding(request); + case PkiOperation.ValidateAlgorithmBindings request -> validateAlgorithmBindings(request); case PkiOperation.ValidateConfiguration ignored -> validateConfiguration(); case PkiOperation.ValidateProfile request -> validateProfile(request); case PkiOperation.RegisterProfile request -> registerProfile(request); @@ -162,7 +180,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { case PkiOperation.ProcessPublication request -> processPublication(request); case PkiOperation.RetryPublication request -> retryPublication(request); case PkiOperation.ReconcilePublication request -> reconcilePublication(request); - }; + }; return new PkiOperationOutcome.Success(result); } catch (InterruptedIOException failure) { return failure(exact.name(), PkiOperationFailure.CANCELLED, "OPERATION_CANCELLED"); @@ -173,6 +191,61 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { } } + private PkiOperationResult listAlgorithmBindings(PkiOperation.ListAlgorithmBindings request) { + List values = algorithmBindings.bindings().stream() + .filter(binding -> request.origin().isEmpty() || binding.origin().name().equals(request.origin().get())) + .filter(binding -> request.role().isEmpty() || binding.role().name().equals(request.role().get())) + .limit(request.limit()).map(binding -> (PkiOperationValue) bindingValue(binding)).toList(); + Map fields = fields(); + fields.put(COUNT, integer(values.size())); + fields.put("registryCommitment", text(algorithmBindings.commitment())); + fields.put("bindings", new PkiOperationValue.ListValue(values)); + return result(request.name(), fields); + } + + private PkiOperationResult inspectAlgorithmBinding(PkiOperation.InspectAlgorithmBinding request) { + X509AlgorithmBinding binding = algorithmBindings.find(request.bindingId()) + .orElseThrow(MissingObjectException::new); + Map fields = fields(); + fields.putAll(bindingValue(binding).fields()); + fields.put("registryCommitment", text(algorithmBindings.commitment())); + return result(request.name(), fields); + } + + private PkiOperationResult validateAlgorithmBindings(PkiOperation.ValidateAlgorithmBindings request) { + request.bindingId().ifPresent(bindingId -> algorithmBindings.require(bindingId, + request.expectedCommitment().orElseThrow())); + Map fields = fields(); + fields.put("valid", bool(true)); + fields.put(COUNT, integer(algorithmBindings.bindings().size())); + fields.put("registryCommitment", text(algorithmBindings.commitment())); + return result(request.name(), fields); + } + + private static PkiOperationValue.ObjectValue bindingValue(X509AlgorithmBinding binding) { + Map fields = fields(); + fields.put("bindingId", text(binding.bindingId())); + fields.put("algorithmId", text(binding.algorithmIdentity().canonicalForm())); + fields.put("role", text(binding.role().name())); + fields.put("oid", text(binding.oid())); + fields.put("origin", text(binding.origin().name())); + fields.put("encodingVersion", integer(binding.encodingVersion())); + fields.put("parameterRule", text(binding.parameterRule().name())); + fields.put("publicKeyEncoding", text(binding.publicKeyEncoding().name())); + fields.put("signatureEncoding", text(binding.signatureEncoding().name())); + fields.put("interoperability", text(binding.interoperability().name())); + fields.put("bindingCommitment", text(binding.semanticCommitment())); + binding.providerId().ifPresent(value -> fields.put("providerId", text(value))); + return new PkiOperationValue.ObjectValue(fields); + } + + private static X509AlgorithmBindingRegistry defaultBindings() { + List descriptors = new ArrayList<>( + zeroecho.pki.impl.framework.x509.StandardX509Bindings.descriptors()); + descriptors.addAll(zeroecho.pki.impl.framework.x509.ZeroEchoPrivateX509Bindings.descriptors()); + return zeroecho.pki.impl.framework.x509.ImmutableX509AlgorithmBindingRegistry.create(descriptors); + } + private PkiOperationResult validateConfiguration() { Map fields = fields(); fields.put("version", integer(configuration.version())); @@ -181,8 +254,9 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { return result(PkiOperation.ValidateConfiguration.NAME, fields); } - private static PkiOperationResult validateProfile(PkiOperation.ValidateProfile request) { + private PkiOperationResult validateProfile(PkiOperation.ValidateProfile request) { CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(request.document()); + validateProfileBindings(definition.algorithmBindingPolicy()); Map fields = fields(); fields.put("profileId", text(definition.profileId())); fields.put("profileVersion", integer(definition.profileVersion())); @@ -191,6 +265,26 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { return result(request.name(), fields); } + private void validateProfileBindings(X509AlgorithmBindingPolicy policy) { + if (policy.mode() == X509AlgorithmBindingPolicy.Mode.STANDARD_ONLY) { + return; + } + validateProfileBinding(policy.subjectPublicKey(), X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY); + validateProfileBinding(policy.csrSignature(), X509AlgorithmBinding.Role.CSR_SIGNATURE); + validateProfileBinding(policy.certificateSignature(), X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE); + validateProfileBinding(policy.crlSignature(), X509AlgorithmBinding.Role.CRL_SIGNATURE); + } + + private void validateProfileBinding(Optional reference, + X509AlgorithmBinding.Role role) { + reference.ifPresent(value -> { + X509AlgorithmBinding binding = algorithmBindings.require(value.bindingId(), value.semanticCommitment()); + if (binding.role() != role || binding.origin() == X509AlgorithmBinding.Origin.STANDARD) { + throw new IllegalArgumentException("Explicit profile binding is invalid for its role"); + } + }); + } + private PkiOperationResult registerProfile(PkiOperation.RegisterProfile request) { CertificateProfileRef reference = profiles.importProfile(request.document()); return profileReferenceResult(request.name(), reference); diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java index ff28ce4..d231362 100644 --- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java @@ -36,9 +36,13 @@ package zeroecho.pki.application; import java.nio.file.Path; import java.time.Clock; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.ServiceLoader; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; @@ -51,11 +55,16 @@ import zeroecho.core.spi.KeyringUnlockProvider; import zeroecho.core.storage.KeyringPassword; import zeroecho.pki.api.CaService; import zeroecho.pki.api.CertificationRequestService; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.PkiException; import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.PublicationService; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.StatusObjectService; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; +import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.impl.core.DefaultProfileService; import zeroecho.pki.impl.core.DefaultRevocationService; @@ -65,15 +74,22 @@ import zeroecho.pki.impl.core.DefaultIssuanceService; import zeroecho.pki.impl.core.DefaultPublicationService; import zeroecho.pki.impl.core.DefaultStatusObjectService; import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver; +import zeroecho.pki.impl.core.PublicKeyInfoResolver; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver; import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; +import zeroecho.pki.impl.framework.x509.ImmutableX509AlgorithmBindingRegistry; +import zeroecho.pki.impl.framework.x509.StandardX509Bindings; +import zeroecho.pki.impl.framework.x509.ZeroEchoPrivateX509Bindings; +import zeroecho.pki.impl.framework.x509.X509BindingRuleProvider; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend; import zeroecho.pki.impl.framework.x509.bc.BcX509ProofOfPossessionVerifier; +import zeroecho.pki.impl.framework.x509.bc.BcX509PublicKeyAdapter; import zeroecho.pki.impl.framework.x509.bc.BcX509StatusObjectGenerator; import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor; import zeroecho.pki.spi.ProviderConfig; +import zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.bootstrap.PkiBootstrap; import zeroecho.pki.spi.crypto.PublicKeyInfoSource; @@ -83,6 +99,7 @@ import zeroecho.pki.spi.publish.Publisher; import zeroecho.pki.spi.store.PkiStore; /** Default synchronous session composition. */ +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity" }) final class DefaultPkiSession implements PkiSession { private final PkiStore store; @@ -96,11 +113,13 @@ final class DefaultPkiSession implements PkiSession { private final Optional publications; private final Optional signingBus; private final Optional signatureWorkflow; + private final X509AlgorithmBindingRegistry algorithmBindings; private final PkiOperationExecutor operations; private final AtomicBoolean closed = new AtomicBoolean(); private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit, - ProfileService profiles, RevocationService revocations, ServiceGraph graph) { + ProfileService profiles, RevocationService revocations, X509AlgorithmBindingRegistry algorithmBindings, + ServiceGraph graph) { this.store = store; this.audit = audit; this.profiles = Objects.requireNonNull(profiles, "profiles"); @@ -112,8 +131,9 @@ final class DefaultPkiSession implements PkiSession { this.publications = graph.publications(); this.signingBus = graph.signingBus(); this.signatureWorkflow = graph.signatureWorkflow(); + this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings"); this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities, - requests, issuance, statusObjects, publications, this::requireOpen); + requests, issuance, statusObjects, publications, algorithmBindings, this::requireOpen); } /* default */ static PkiSession open(PkiSessionConfiguration configuration) { @@ -146,6 +166,9 @@ final class DefaultPkiSession implements PkiSession { } }); exact.publishers().forEach(bootstrap::validatePublisher); + BindingComposition bindingComposition = composeBindings(exact); + X509AlgorithmBindingRegistry algorithmBindings = bindingComposition.registry(); + exact.signing().ifPresent(signing -> validateConfiguredBindings(signing, algorithmBindings)); PkiStore store = null; AuditSink audit = null; @@ -153,10 +176,15 @@ final class DefaultPkiSession implements PkiSession { try { store = Objects.requireNonNull(bootstrap.openStore(exact.store()), "opened store"); audit = Objects.requireNonNull(bootstrap.openAudit(exact.audit()), "opened audit sink"); - ProfileService profiles = new DefaultProfileService(store, clock, audit); + ProfileService profiles = new DefaultProfileService(store, clock, audit, algorithmBindings, + exact.signing().isPresent(), + exact.signing().flatMap(PkiSessionConfiguration.SigningConfiguration::certificateSignatureBinding), + exact.signing().flatMap(PkiSessionConfiguration.SigningConfiguration::crlSignatureBinding), + exact.signing().flatMap(PkiSessionConfiguration.SigningConfiguration::subjectPublicKeyBinding)); RevocationService revocations = new DefaultRevocationService(store, clock, audit); - graph = composeGraph(exact, dependencies, store, audit, profiles, clock, bootstrap); - return new DefaultPkiSession(exact, store, audit, profiles, revocations, graph); + graph = composeGraph(exact, dependencies, store, audit, profiles, clock, bootstrap, + bindingComposition.providers()); + return new DefaultPkiSession(exact, store, audit, profiles, revocations, algorithmBindings, graph); } catch (RuntimeException | Error primary) { closeGraphAfterConstructionFailure(graph, primary); closeAfterConstructionFailure(audit, store, primary); @@ -206,6 +234,12 @@ final class DefaultPkiSession implements PkiSession { return publications; } + @Override + public X509AlgorithmBindingRegistry algorithmBindings() { + requireOpen(); + return algorithmBindings; + } + @Override public PkiOperationExecutor operations() { requireOpen(); @@ -247,7 +281,7 @@ final class DefaultPkiSession implements PkiSession { @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.CloseResource", "PMD.ExceptionAsFlowControl" }) private static ServiceGraph composeGraph(PkiSessionConfiguration configuration, PkiSessionRuntimeDependencies dependencies, PkiStore store, AuditSink audit, ProfileService profiles, - Clock clock, Bootstrap bootstrap) { + Clock clock, Bootstrap bootstrap, List bindingProviders) { Optional workflow = Optional.empty(); Optional bus = Optional.empty(); Optional authorities = Optional.empty(); @@ -267,14 +301,15 @@ final class DefaultPkiSession implements PkiSession { throw new IllegalStateException("Signature workflow lacks managed public-key capability"); } BcX509VerificationExecutor verification = new BcX509VerificationExecutor(); - X509AuthoritySnapshot authority = authority(openedWorkflow, verification); + X509AuthoritySnapshot authority = authority(openedWorkflow, verification, bindingProviders); PkiSigningBus openedBus = new PkiSigningBus(store, openedWorkflow, Path.of(signing.busPath()), authority); bus = Optional.of(openedBus); + AlgorithmIdentity signatureIdentity = authority.resolveIdentity(signing.signatureAlgorithm()); BcX509CredentialIssuerBackend issuerBackend = new BcX509CredentialIssuerBackend(openedBus, - signing.signatureAlgorithm(), signing.signingTtl()); + signatureIdentity, signing.certificateSignatureBinding(), signing.signingTtl()); BcX509StatusObjectGenerator statusGenerator = new BcX509StatusObjectGenerator(openedBus, - signing.signatureAlgorithm(), signing.signingTtl()); + signatureIdentity, signing.crlSignatureBinding(), signing.signingTtl()); BcX509CredentialFramework framework = new BcX509CredentialFramework(authority, verification) .wired(statusGenerator, new BcX509ProofOfPossessionVerifier(authority, verification)); EffectiveCredentialStatusResolver statusResolver = new StoreBackedEffectiveCredentialStatusResolver( @@ -285,8 +320,8 @@ final class DefaultPkiSession implements PkiSession { statuses = Optional.of(new DefaultStatusObjectService(store, framework, audit, statusResolver, authority)); authorities = Optional.of(new DefaultCaService(store, framework, issuerBackend, - publicKeys::resolvePublicKeyInfo, openedBus, audit, statusResolver, profiles, clock, - signing.signatureAlgorithm(), signing.signingTtl())); + publicKeySource(publicKeys, authority, signing), openedBus, audit, statusResolver, profiles, clock, + signing.signatureAlgorithm(), signing.certificateSignatureBinding(), signing.signingTtl())); } List configuredPublishers = new ArrayList<>(); for (ProviderConfig publisher : configuration.publishers()) { @@ -304,7 +339,7 @@ final class DefaultPkiSession implements PkiSession { } private static X509AuthoritySnapshot authority(SignatureWorkflow workflow, - BcX509VerificationExecutor verification) { + BcX509VerificationExecutor verification, List bindingProviders) { Set supported = Set.copyOf(workflow.supportedAlgorithms()); Set identities = BootstrapAlgorithmIdentities.catalog().identities().stream() .filter(identity -> identity.kind() == AlgorithmIdentity.Kind.SIGNATURE) @@ -342,13 +377,153 @@ final class DefaultPkiSession implements PkiSession { return "pki-session-bootstrap-policy-v1:configured-capabilities-only"; } }; - return X509AuthoritySnapshot.compose(List.of(), List.of(signingProvider, verification), + return X509AuthoritySnapshot.compose(bindingProviders, List.of(signingProvider, verification), List.of(X509AuthoritySnapshot.bindExecutor(implementationId, AlgorithmExecutionCapability.Direction.SIGN, workflow), X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID, AlgorithmExecutionCapability.Direction.VERIFY, verification)), policy); } + private static BindingComposition composeBindings(PkiSessionConfiguration configuration) { + List discovered = ServiceLoader.load(X509AlgorithmBindingProvider.class) + .stream().map(ServiceLoader.Provider::get) + .sorted(Comparator.comparing(X509AlgorithmBindingProvider::providerId)).toList(); + Map byId = new LinkedHashMap<>(); + for (X509AlgorithmBindingProvider provider : discovered) { + if (provider.providerId() == null || provider.providerId().isBlank() + || byId.putIfAbsent(provider.providerId(), provider) != null) { + throw new IllegalArgumentException("Duplicate or invalid X.509 binding-provider identity"); + } + } + List descriptors = new ArrayList<>(StandardX509Bindings.descriptors()); + descriptors.addAll(ZeroEchoPrivateX509Bindings.descriptors()); + List ruleProviders = new ArrayList<>(); + for (PkiSessionConfiguration.BindingProviderConfiguration activation : configuration.bindingProviders()) { + X509AlgorithmBindingProvider provider = byId.get(activation.providerId()); + if (provider == null) { + throw new IllegalArgumentException("Configured X.509 binding provider is unavailable"); + } + activation.expectedBindingSetVersion().ifPresent(expected -> { + if (!expected.equals(provider.bindingSetVersion())) { + throw new IllegalArgumentException("X.509 binding-provider version changed"); + } + }); + List additions = List.copyOf(provider.bindings()); + for (X509AlgorithmBinding binding : additions) { + if (binding.origin() != X509AlgorithmBinding.Origin.DEPLOYER_PRIVATE + || !binding.providerId().orElse("").equals(provider.providerId()) + || BootstrapAlgorithmIdentities.catalog() + .resolve(binding.algorithmIdentity().canonicalForm()).isEmpty() + || activation.authorizedOidRoots().stream() + .noneMatch(root -> authorizedRoot(root, binding.oid()))) { + throw new IllegalArgumentException("Unauthorized deployer X.509 binding"); + } + } + descriptors.addAll(additions); + List rules = List.copyOf(provider.rules()); + Set descriptorIds = additions.stream().map(X509AlgorithmBinding::bindingId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + Set ruleIds = rules.stream().flatMap(rule -> rule.bindings().stream()) + .map(X509AlgorithmBinding::bindingId).collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (rules.isEmpty() || !ruleIds.equals(descriptorIds)) { + throw new IllegalArgumentException("Deployer binding provider has inconsistent codecs"); + } + ruleProviders.add(new X509BindingRuleProvider() { + @Override + public List rules() { + return rules; + } + }); + } + return new BindingComposition(ImmutableX509AlgorithmBindingRegistry.create(descriptors), + List.copyOf(ruleProviders)); + } + + private static void validateConfiguredBindings(PkiSessionConfiguration.SigningConfiguration signing, + X509AlgorithmBindingRegistry registry) { + if (signing.certificateSignatureBinding().isEmpty() && signing.crlSignatureBinding().isEmpty() + && signing.subjectPublicKeyBinding().isEmpty()) { + return; + } + AlgorithmIdentity identity = BootstrapAlgorithmIdentities.resolve(signing.signatureAlgorithm()) + .orElseThrow(() -> new IllegalArgumentException("Configured signature identity is unknown")); + validateConfiguredBinding(registry, signing.certificateSignatureBinding(), identity, + X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE); + validateConfiguredBinding(registry, signing.crlSignatureBinding(), identity, + X509AlgorithmBinding.Role.CRL_SIGNATURE); + validateConfiguredBinding(registry, signing.subjectPublicKeyBinding(), + BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE.equals(identity) + ? BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY + : bootstrapPublicKey(identity), X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY); + boolean privateAlgorithm = identity.equals(BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE); + if (privateAlgorithm && (signing.certificateSignatureBinding().isEmpty() + || signing.crlSignatureBinding().isEmpty() || signing.subjectPublicKeyBinding().isEmpty())) { + throw new IllegalArgumentException("Private X.509 signature identity requires explicit bindings"); + } + } + + private static AlgorithmIdentity bootstrapPublicKey(AlgorithmIdentity signature) { + if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA256)) { + return BootstrapAlgorithmIdentities.EC_P256_PUBLIC_KEY; + } + if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA384)) { + return BootstrapAlgorithmIdentities.EC_P384_PUBLIC_KEY; + } + if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA512)) { + return BootstrapAlgorithmIdentities.EC_P521_PUBLIC_KEY; + } + if (signature.equals(BootstrapAlgorithmIdentities.ED25519_SIGNATURE)) { + return BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY; + } + if (signature.equals(BootstrapAlgorithmIdentities.ED448_SIGNATURE)) { + return BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY; + } + return BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY; + } + + @SuppressWarnings("PMD.PreserveStackTrace") + private static PublicKeyInfoResolver publicKeySource(PublicKeyInfoSource source, X509AuthoritySnapshot authority, + PkiSessionConfiguration.SigningConfiguration signing) { + if (signing.subjectPublicKeyBinding().isEmpty()) { + return source::resolvePublicKeyInfo; + } + return keyRef -> { + EncodedObject nativeKey = source.resolvePublicKeyInfo(keyRef); + if (nativeKey.encoding() != Encoding.DER) { + throw new PkiException("Managed public key is not canonical DER: code=PUBLIC_KEY_ENCODING_INVALID"); + } + try { + byte[] wrapped = new BcX509PublicKeyAdapter(authority.bindings()) + .wrap(nativeKey.bytes(), BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY, + signing.subjectPublicKeyBinding().orElseThrow()) + .getEncoded(org.bouncycastle.asn1.ASN1Encoding.DER); + return new EncodedObject(Encoding.DER, wrapped); + } catch (java.io.IOException | IllegalArgumentException failure) { + throw new PkiException("Managed public key binding failed: code=PUBLIC_KEY_BINDING_FAILED"); + } + }; + } + + private static void validateConfiguredBinding(X509AlgorithmBindingRegistry registry, + Optional bindingId, AlgorithmIdentity identity, X509AlgorithmBinding.Role role) { + bindingId.ifPresent(value -> { + X509AlgorithmBinding binding = registry.find(value) + .orElseThrow(() -> new IllegalArgumentException("Configured X.509 binding is inactive")); + if (binding.role() != role || !binding.algorithmIdentity().equals(identity)) { + throw new IllegalArgumentException("Configured X.509 binding does not match the signing role"); + } + }); + } + + private static boolean authorizedRoot(String root, String oid) { + boolean egothorDeployment = ImmutableX509AlgorithmBindingRegistry.isWithin(root, + ImmutableX509AlgorithmBindingRegistry.ZEROECHO_DEPLOYMENT_ROOT) + && !ImmutableX509AlgorithmBindingRegistry.ZEROECHO_DEPLOYMENT_ROOT.equals(root); + boolean externalPen = root.matches("1\\.3\\.6\\.1\\.4\\.1\\.(?!31424(?:\\.|$))[0-9]+(?:\\.[0-9]+)*"); + return (egothorDeployment || externalPen) + && ImmutableX509AlgorithmBindingRegistry.isWithin(oid, root); + } + private static PkiSessionRuntimeDependencies runtimeDependencies(PkiSessionConfiguration configuration) { Optional environment = configuration.signing() .flatMap(PkiSessionConfiguration.SigningConfiguration::unlockEnvironmentVariable); @@ -474,6 +649,14 @@ final class DefaultPkiSession implements PkiSession { } } + private record BindingComposition(X509AlgorithmBindingRegistry registry, + List providers) { + private BindingComposition { + Objects.requireNonNull(registry, "registry"); + providers = List.copyOf(providers); + } + } + /** Production provider bootstrap implementation. */ private enum ProductionBootstrap implements Bootstrap { INSTANCE; diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java index b00c932..d6858d4 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java @@ -53,6 +53,8 @@ import zeroecho.pki.api.status.StatusObjectType; * explicit type and never by reflection or Java class name.

*/ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, PkiOperation.ValidateProfile, + PkiOperation.ListAlgorithmBindings, PkiOperation.InspectAlgorithmBinding, + PkiOperation.ValidateAlgorithmBindings, PkiOperation.RegisterProfile, PkiOperation.InspectProfile, PkiOperation.ListProfileVersions, PkiOperation.ActivateProfile, PkiOperation.InspectAuthority, PkiOperation.ListAuthorities, PkiOperation.CreateAuthority, PkiOperation.TransitionAuthority, PkiOperation.InspectRequest, @@ -69,6 +71,81 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, /** @return stable semantic operation name */ String name(); + /** Lists active X.509 algorithm bindings with optional finite filters. */ + record ListAlgorithmBindings(java.util.Optional origin, java.util.Optional role, int limit) + implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "algorithm.binding.list"; + + /** Validates immutable filters and the presentation limit. */ + public ListAlgorithmBindings { + origin = Objects.requireNonNull(origin, "origin").map(ListAlgorithmBindings::requireText); + role = Objects.requireNonNull(role, "role").map(ListAlgorithmBindings::requireText); + requireLimit(limit); + } + + @Override + public String name() { + return NAME; + } + + private static String requireText(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Binding filter must not be blank"); + } + return value; + } + } + + /** Inspects one active X.509 algorithm binding by stable identifier. */ + record InspectAlgorithmBinding(String bindingId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "algorithm.binding.inspect"; + + /** Validates the stable identifier. */ + public InspectAlgorithmBinding { + if (bindingId == null || bindingId.isBlank()) { + throw new IllegalArgumentException("bindingId must not be blank"); + } + } + + @Override + public String name() { + return NAME; + } + } + + /** Validates the immutable active registry and optionally one exact binding. */ + record ValidateAlgorithmBindings(java.util.Optional bindingId, + java.util.Optional expectedCommitment) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "algorithm.binding.validate"; + + /** Validates paired optional fields. */ + public ValidateAlgorithmBindings { + bindingId = Objects.requireNonNull(bindingId, "bindingId"); + expectedCommitment = Objects.requireNonNull(expectedCommitment, "expectedCommitment"); + if (bindingId.isPresent() != expectedCommitment.isPresent()) { + throw new IllegalArgumentException("Binding validation fields must be supplied together"); + } + bindingId.ifPresent(value -> { + if (value.isBlank()) { + throw new IllegalArgumentException("bindingId must not be blank"); + } + }); + expectedCommitment.ifPresent(value -> { + if (!value.matches("[0-9a-f]{64}|[^\\s]{1,512}")) { + throw new IllegalArgumentException("Binding commitment is invalid"); + } + }); + } + + @Override + public String name() { + return NAME; + } + } + /** Validates the already-open session configuration. */ record ValidateConfiguration() implements PkiOperation { /** Stable operation name. */ diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSession.java b/pki/src/main/java/zeroecho/pki/application/PkiSession.java index 786e9fe..371de80 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiSession.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiSession.java @@ -42,6 +42,7 @@ import zeroecho.pki.api.PublicationService; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.StatusObjectService; +import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; /** * Lifecycle-owned synchronous PKI backend session. @@ -107,6 +108,13 @@ public interface PkiSession extends AutoCloseable { return Optional.empty(); } + /** + * Returns the immutable active X.509 algorithm-binding registry. + * + * @return session-owned immutable registry, available to read-only sessions + */ + X509AlgorithmBindingRegistry algorithmBindings(); + /** @return shared typed operation executor owned by this session */ PkiOperationExecutor operations(); diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java index 87cc55b..d694d6b 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java @@ -54,14 +54,21 @@ import zeroecho.pki.spi.ProviderConfig; * @param publishers explicitly enabled publication destinations */ public record PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit, - Optional signing, List publishers) { + Optional signing, List publishers, + List bindingProviders) { /** Current configuration schema version. */ - public static final int CURRENT_VERSION = 2; + public static final int CURRENT_VERSION = 3; /** Creates a read-only-compatible version-one configuration. */ public PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit) { - this(version, store, audit, Optional.empty(), List.of()); + this(version, store, audit, Optional.empty(), List.of(), List.of()); + } + + /** Creates a version-one or version-two configuration without custom bindings. */ + public PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit, + Optional signing, List publishers) { + this(version, store, audit, signing, publishers, List.of()); } /** Validates and snapshots the configuration. */ @@ -74,9 +81,17 @@ public record PkiSessionConfiguration(int version, ProviderConfig store, Provide signing = Objects.requireNonNull(signing, "signing").map(SigningConfiguration::snapshot); publishers = Objects.requireNonNull(publishers, "publishers").stream() .map(PkiSessionConfiguration::snapshot).toList(); + bindingProviders = List.copyOf(Objects.requireNonNull(bindingProviders, "bindingProviders")); if (version == 1 && (signing.isPresent() || !publishers.isEmpty())) { throw new IllegalArgumentException("Version-one configuration cannot enable optional capabilities"); } + if (version < 3 && !bindingProviders.isEmpty()) { + throw new IllegalArgumentException("Custom X.509 bindings require configuration version three"); + } + if (bindingProviders.stream().map(BindingProviderConfiguration::providerId).distinct().count() + != bindingProviders.size()) { + throw new IllegalArgumentException("Duplicate X.509 binding-provider identity"); + } } private static ProviderConfig snapshot(ProviderConfig config) { @@ -93,9 +108,21 @@ public record PkiSessionConfiguration(int version, ProviderConfig store, Provide * @param signingTtl positive synchronous signing deadline * @param unlockEnvironmentVariable optional environment-variable reference used * only by the default process composition + * @param certificateSignatureBinding optional explicit certificate signature binding + * @param crlSignatureBinding optional explicit CRL signature binding + * @param subjectPublicKeyBinding optional explicit SPKI binding */ public record SigningConfiguration(ProviderConfig workflow, ProviderConfig framework, String busPath, - String signatureAlgorithm, Duration signingTtl, Optional unlockEnvironmentVariable) { + String signatureAlgorithm, Duration signingTtl, Optional unlockEnvironmentVariable, + Optional certificateSignatureBinding, Optional crlSignatureBinding, + Optional subjectPublicKeyBinding) { + + /** Creates standard-mode signing configuration. */ + public SigningConfiguration(ProviderConfig workflow, ProviderConfig framework, String busPath, + String signatureAlgorithm, Duration signingTtl, Optional unlockEnvironmentVariable) { + this(workflow, framework, busPath, signatureAlgorithm, signingTtl, unlockEnvironmentVariable, + Optional.empty(), Optional.empty(), Optional.empty()); + } /** Validates and snapshots the signing configuration. */ public SigningConfiguration { @@ -109,6 +136,9 @@ public record PkiSessionConfiguration(int version, ProviderConfig store, Provide } unlockEnvironmentVariable = Objects.requireNonNull(unlockEnvironmentVariable, "unlockEnvironmentVariable"); + certificateSignatureBinding = binding(certificateSignatureBinding, "certificateSignatureBinding"); + crlSignatureBinding = binding(crlSignatureBinding, "crlSignatureBinding"); + subjectPublicKeyBinding = binding(subjectPublicKeyBinding, "subjectPublicKeyBinding"); unlockEnvironmentVariable.ifPresent(value -> { if (!value.matches("[A-Z][A-Z0-9_]{0,127}")) { throw new IllegalArgumentException("Unlock environment-variable reference is invalid"); @@ -118,7 +148,51 @@ public record PkiSessionConfiguration(int version, ProviderConfig store, Provide private static SigningConfiguration snapshot(SigningConfiguration source) { return new SigningConfiguration(source.workflow, source.framework, source.busPath, - source.signatureAlgorithm, source.signingTtl, source.unlockEnvironmentVariable); + source.signatureAlgorithm, source.signingTtl, source.unlockEnvironmentVariable, + source.certificateSignatureBinding, source.crlSignatureBinding, source.subjectPublicKeyBinding); + } + + private static Optional binding(Optional source, String name) { + return Objects.requireNonNull(source, name).map(value -> { + if (value.isBlank() || value.length() > 512) { + throw new IllegalArgumentException(name + " is invalid"); + } + return value; + }); + } + } + + /** + * Explicit activation and namespace authorization for one installed binding + * provider. + * + * @param providerId stable installed provider identity + * @param authorizedOidRoots finite roots owned by the deployment + * @param expectedBindingSetVersion optional pinned provider binding-set version + */ + public record BindingProviderConfiguration(String providerId, List authorizedOidRoots, + Optional expectedBindingSetVersion) { + + /** Validates and snapshots the activation. */ + public BindingProviderConfiguration { + if (providerId == null || providerId.isBlank()) { + throw new IllegalArgumentException("binding providerId must not be blank"); + } + authorizedOidRoots = List.copyOf(Objects.requireNonNull(authorizedOidRoots, "authorizedOidRoots")); + if (authorizedOidRoots.isEmpty() || authorizedOidRoots.stream().anyMatch(root -> root == null + || !root.matches("[0-2](?:\\.[0-9]+)+"))) { + throw new IllegalArgumentException("Authorized OID roots are invalid"); + } + if (authorizedOidRoots.stream().distinct().count() != authorizedOidRoots.size()) { + throw new IllegalArgumentException("Authorized OID roots are duplicated"); + } + expectedBindingSetVersion = Objects.requireNonNull(expectedBindingSetVersion, + "expectedBindingSetVersion").map(value -> { + if (value.isBlank() || value.length() > 512) { + throw new IllegalArgumentException("Expected binding-set version is invalid"); + } + return value; + }); } } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java b/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java index 1b2bbd3..db7afd5 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java @@ -109,7 +109,7 @@ final class CaCertificateProfileValidator { SubjectRef canonicalSubject = new SubjectRef(BcX509ProfileSupport.subject(subjectSnapshot).toString()); return new ValidatedCaCertificateRequest(operation, formatId, issuerCaId, subjectCaId, activeProfile.reference(), expectedKind, canonicalSubject, subjectSnapshot, exactPublicKey, validity, - serial, definition.caPolicy()); + serial, definition.caPolicy(), definition.algorithmBindingPolicy()); } /* package */ static void requireProfileShape(ActiveCertificateProfile activeProfile, diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java index e625e1b..9be8184 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java @@ -69,6 +69,7 @@ import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.impl.framework.x509.StreamingDerReader; import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor; import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter; +import zeroecho.pki.impl.framework.x509.bc.PkiBusContentSigner; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncStatus; @@ -93,26 +94,46 @@ final class CaProofGate { private final PkiSigningBus signingBus; private final AuditSink auditSink; private final AlgorithmIdentity signatureIdentity; + private final Optional signatureBindingId; private final Duration signingTtl; /* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, String signatureAlgorithmId, Duration signingTtl) { this(publicKeyResolver, signingBus, auditSink, signingBus.authority().resolveIdentity(Objects.requireNonNull(signatureAlgorithmId, - "signatureAlgorithmId")), signingTtl); + "signatureAlgorithmId")), Optional.empty(), signingTtl); } /* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, AlgorithmIdentity signatureIdentity, Duration signingTtl) { + this(publicKeyResolver, signingBus, auditSink, signatureIdentity, Optional.empty(), signingTtl); + } + + /* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, + AlgorithmIdentity signatureIdentity, Optional signatureBindingId, Duration signingTtl) { this.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); this.signingBus = Objects.requireNonNull(signingBus, "signingBus"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.signatureIdentity = Objects.requireNonNull(signatureIdentity, "signatureIdentity"); + this.signatureBindingId = Objects.requireNonNull(signatureBindingId, "signatureBindingId"); this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl"); } /* default */ ContentSigner signer(ManagedKeyProof proof) { - return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl); + return signatureBindingId.isPresent() + ? new PkiBusContentSigner(signingBus, proof.keyRef(), signatureIdentity, + signatureBindingId.orElseThrow(), signingTtl) + : new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl); + } + + /* default */ ContentSigner signer(ManagedKeyProof proof, + zeroecho.pki.api.profile.X509AlgorithmBindingPolicy bindingPolicy) { + Optional bindingId = bindingPolicy.certificateSignature() + .map(zeroecho.pki.api.profile.X509AlgorithmBindingPolicy.BindingReference::bindingId); + return bindingId.isPresent() + ? new PkiBusContentSigner(signingBus, proof.keyRef(), signatureIdentity, bindingId.orElseThrow(), + signingTtl) + : new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl); } /* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) { diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java index 9b992ff..6e8decb 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java @@ -108,7 +108,7 @@ final class CertificateProfileValidator { return new ValidatedCertificateRequest(candidate.issuerCaId(), profileReference, approvedSubjectRef, approvedSubject, approvedSans, sanCritical, candidate.exactPublicKey(), validity, policy.keyUsages(), policy.extendedKeyUsages(), policy.keyUsageCritical(), policy.extendedKeyUsageCritical(), - policy.basicConstraintsCritical()); + policy.basicConstraintsCritical(), profile.algorithmBindingPolicy()); } private static void requireCanonicalRequestAttributes(ParsedCertificationRequest request) { @@ -252,14 +252,21 @@ final class CertificateProfileValidator { if (!allowedAlgorithms.contains(algorithm.profileId())) { throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN"); } - PublicKey reconstructed = KeyFactory.getInstance(algorithm.jcaName()) - .generatePublic(new X509EncodedKeySpec(encoded)); + SubjectPublicKeyInfo nativeSpki = new zeroecho.pki.impl.framework.x509.bc.BcX509PublicKeyAdapter( + authority.bindings()).unwrap(spki); + byte[] nativeEncoded = nativeSpki.getEncoded(); + KeyFactory keyFactory = BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY.equals(identity) + ? KeyFactory.getInstance(algorithm.jcaName(), + new org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider()) + : KeyFactory.getInstance(algorithm.jcaName()); + PublicKey reconstructed = keyFactory.generatePublic(new X509EncodedKeySpec(nativeEncoded)); byte[] canonical = reconstructed.getEncoded(); try { - if (canonical == null || !MessageDigest.isEqual(encoded, canonical)) { + if (canonical == null || !MessageDigest.isEqual(nativeEncoded, canonical)) { throw reject("SUBJECT_KEY_NOT_CANONICAL"); } } finally { + java.util.Arrays.fill(nativeEncoded, (byte) 0); if (canonical != null) { java.util.Arrays.fill(canonical, (byte) 0); } @@ -286,6 +293,9 @@ final class CertificateProfileValidator { if (identity.equals(BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY)) { return new SubjectKeyAlgorithm("Ed448", "Ed448"); } + if (identity.equals(BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY)) { + return new SubjectKeyAlgorithm("SPHINCS+", "SPHINCS+"); + } throw reject("SUBJECT_KEY_ALGORITHM_UNKNOWN"); } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java index fee1734..46bffd4 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java @@ -232,6 +232,30 @@ public final class DefaultCaService implements CaService { PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, EffectiveCredentialStatusResolver statusResolver, ProfileService profileService, Clock clock, String signatureAlgorithmId, Duration signingTtl) { + this(store, framework, issuerBackend, publicKeyResolver, signingBus, auditSink, statusResolver, profileService, + clock, signatureAlgorithmId, Optional.empty(), signingTtl); + } + + /** + * Creates a CA service with an optional explicit X.509 signature binding. + * + * @param store persistent PKI store + * @param framework credential framework + * @param issuerBackend issuer backend + * @param publicKeyResolver managed public-key resolver + * @param signingBus signing bus + * @param auditSink audit sink + * @param statusResolver effective status resolver + * @param profileService profile service + * @param clock injected clock + * @param signatureAlgorithmId cryptographic signature identity + * @param signatureBindingId explicit binding identity, when private mode is selected + * @param signingTtl signing operation time-to-live + */ + public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend, + PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, + EffectiveCredentialStatusResolver statusResolver, ProfileService profileService, Clock clock, + String signatureAlgorithmId, Optional signatureBindingId, Duration signingTtl) { this.store = Objects.requireNonNull(store, "store"); this.framework = Objects.requireNonNull(framework, "framework"); @@ -250,11 +274,14 @@ public final class DefaultCaService implements CaService { throw new IllegalArgumentException("signingTtl must be positive"); } AlgorithmIdentity signatureIdentity = signingBus.authority().resolveIdentity(signatureAlgorithmId); - X509ExecutionPlan plan = signingBus.authority() - .planSigning(signatureIdentity.canonicalForm(), SignatureWorkflow.class); + X509ExecutionPlan plan = signatureBindingId.isPresent() + ? signingBus.authority().planSigningWithBinding(signatureIdentity.canonicalForm(), + signatureBindingId.orElseThrow(), SignatureWorkflow.class) + : signingBus.authority().planSigning(signatureIdentity.canonicalForm(), SignatureWorkflow.class); signingBus.authority().authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN); this.signatureIdentity = signatureIdentity; - this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureIdentity, signingTtl); + this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureIdentity, + signatureBindingId, signingTtl); } /** @@ -326,7 +353,7 @@ public final class DefaultCaService implements CaService { throw new PkiException("Root extension construction failed: code=ROOT_EXTENSION_BUILD_FAILED"); } - ContentSigner signer = proofGate.signer(proof); + ContentSigner signer = proofGate.signer(proof, request.algorithmBindingPolicy()); X509CertificateHolder cert; try { cert = b.build(signer); diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java index d9933a8..bc4d3f0 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java @@ -55,6 +55,9 @@ import zeroecho.pki.api.profile.CertificateProfileDefinition; import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; import zeroecho.pki.api.profile.CertificateProfileRef; import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.api.profile.X509AlgorithmBindingPolicy; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; +import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; import zeroecho.pki.impl.ProfileLifecycleFailure; import zeroecho.pki.impl.ProfileLifecycleFailure.Code; import zeroecho.pki.spi.audit.AuditSink; @@ -70,12 +73,42 @@ public final class DefaultProfileService implements ProfileService { private final PkiStore store; private final Clock clock; private final AuditSink auditSink; + private final X509AlgorithmBindingRegistry algorithmBindings; + private final boolean signingConfigured; + private final Optional certificateBinding; + private final Optional crlBinding; + private final Optional publicKeyBinding; /** Creates a profile service using one authoritative clock. */ public DefaultProfileService(PkiStore store, Clock clock, AuditSink auditSink) { + this(store, clock, auditSink, defaultBindings()); + } + + /** + * Creates a profile service bound to one immutable algorithm registry. + * + * @param store authoritative PKI store + * @param clock authoritative clock + * @param auditSink audit sink + * @param algorithmBindings active immutable binding registry + */ + public DefaultProfileService(PkiStore store, Clock clock, AuditSink auditSink, + X509AlgorithmBindingRegistry algorithmBindings) { + this(store, clock, auditSink, algorithmBindings, false, Optional.empty(), Optional.empty(), Optional.empty()); + } + + /** Creates a profile service with the session's explicit signing bindings. */ + public DefaultProfileService(PkiStore store, Clock clock, AuditSink auditSink, + X509AlgorithmBindingRegistry algorithmBindings, boolean signingConfigured, + Optional certificateBinding, Optional crlBinding, Optional publicKeyBinding) { this.store = Objects.requireNonNull(store, "store"); this.clock = Objects.requireNonNull(clock, "clock"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); + this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings"); + this.signingConfigured = signingConfigured; + this.certificateBinding = Objects.requireNonNull(certificateBinding, "certificateBinding"); + this.crlBinding = Objects.requireNonNull(crlBinding, "crlBinding"); + this.publicKeyBinding = Objects.requireNonNull(publicKeyBinding, "publicKeyBinding"); } @Override @@ -83,6 +116,7 @@ public final class DefaultProfileService implements ProfileService { Objects.requireNonNull(jsonDocument, "jsonDocument"); return executeSanitized(() -> { CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(jsonDocument); + validateBindings(definition); byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition); return importCanonical(definition, canonical, clock.instant()); }, Code.PROFILE_IMPORT_VALIDATION_FAILED); @@ -93,6 +127,7 @@ public final class DefaultProfileService implements ProfileService { Objects.requireNonNull(jsonDocument, "jsonDocument"); return executeSanitized(() -> { CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(jsonDocument); + validateBindings(definition); byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition); return importCanonical(definition, canonical, clock.instant()); }, Code.PROFILE_IMPORT_VALIDATION_FAILED); @@ -122,6 +157,10 @@ public final class DefaultProfileService implements ProfileService { throw new IllegalArgumentException("profileVersion must be positive"); } return executeAudited("PROFILE_ACTIVATE", profileId, profileVersion, Code.PROFILE_ACTIVATION_FAILED, () -> { + ImportedCertificateProfileVersion version = store.getProfileVersion(profileId, profileVersion) + .orElseThrow(() -> new ProfileLifecycleFailure(Code.PROFILE_VERSION_NOT_FOUND)); + validateBindings(version.definition()); + validateRuntimeBindings(version.definition()); CertificateProfileRef result = store.activateProfile(profileId, profileVersion); audit("PROFILE_ACTIVATE", result, "SUCCESS"); return result; @@ -131,8 +170,12 @@ public final class DefaultProfileService implements ProfileService { @Override public ActiveCertificateProfile requireActiveProfile(String profileId) { requireProfileId(profileId); - return executeAudited("PROFILE_RESOLVE", profileId, 0L, Code.PROFILE_STORE_FAILURE, - () -> store.requireActiveProfile(profileId)); + return executeAudited("PROFILE_RESOLVE", profileId, 0L, Code.PROFILE_STORE_FAILURE, () -> { + ActiveCertificateProfile active = store.requireActiveProfile(profileId); + validateBindings(active.definition()); + validateRuntimeBindings(active.definition()); + return active; + }); } @Override @@ -217,6 +260,60 @@ public final class DefaultProfileService implements ProfileService { return new ProfileLifecycleFailure(fallback); } + private void validateBindings(CertificateProfileDefinition definition) { + X509AlgorithmBindingPolicy policy = definition.algorithmBindingPolicy(); + if (policy.mode() == X509AlgorithmBindingPolicy.Mode.STANDARD_ONLY) { + return; + } + validateBinding(policy.subjectPublicKey(), X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY); + validateBinding(policy.csrSignature(), X509AlgorithmBinding.Role.CSR_SIGNATURE); + validateBinding(policy.certificateSignature(), X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE); + validateBinding(policy.crlSignature(), X509AlgorithmBinding.Role.CRL_SIGNATURE); + } + + private void validateBinding(Optional reference, + X509AlgorithmBinding.Role role) { + reference.ifPresent(value -> { + X509AlgorithmBinding binding = algorithmBindings.require(value.bindingId(), value.semanticCommitment()); + if (binding.role() != role || binding.origin() == X509AlgorithmBinding.Origin.STANDARD) { + throw new IllegalArgumentException("Explicit profile binding is invalid for its role"); + } + }); + } + + private void validateRuntimeBindings(CertificateProfileDefinition definition) { + if (!signingConfigured) { + return; + } + X509AlgorithmBindingPolicy policy = definition.algorithmBindingPolicy(); + if (policy.mode() == X509AlgorithmBindingPolicy.Mode.STANDARD_ONLY) { + if (certificateBinding.isPresent() || crlBinding.isPresent() || publicKeyBinding.isPresent()) { + throw new IllegalArgumentException("Standard profile cannot use private runtime bindings"); + } + return; + } + requireRuntimeBinding(policy.certificateSignature(), certificateBinding); + requireRuntimeBinding(policy.subjectPublicKey(), publicKeyBinding); + if (policy.crlSignature().isPresent()) { + requireRuntimeBinding(policy.crlSignature(), crlBinding); + } + } + + private static void requireRuntimeBinding(Optional selected, + Optional configured) { + if (selected.isEmpty() || configured.isEmpty() + || !selected.orElseThrow().bindingId().equals(configured.orElseThrow())) { + throw new IllegalArgumentException("Profile binding does not match the signing runtime"); + } + } + + private static X509AlgorithmBindingRegistry defaultBindings() { + List descriptors = new java.util.ArrayList<>( + zeroecho.pki.impl.framework.x509.StandardX509Bindings.descriptors()); + descriptors.addAll(zeroecho.pki.impl.framework.x509.ZeroEchoPrivateX509Bindings.descriptors()); + return zeroecho.pki.impl.framework.x509.ImmutableX509AlgorithmBindingRegistry.create(descriptors); + } + /* * Attacker-controlled parser and store causes are intentionally replaced by * stable, cause-free lifecycle failures. diff --git a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java index 7dbd222..3b11de0 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java @@ -45,6 +45,7 @@ import zeroecho.pki.api.Validity; import zeroecho.pki.api.profile.CaCertificatePolicy; import zeroecho.pki.api.profile.CertificateProfileKind; import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.X509AlgorithmBindingPolicy; import zeroecho.pki.api.request.SubjectRdn; /** @@ -83,6 +84,7 @@ public final class ValidatedCaCertificateRequest { private final Validity validity; private final BigInteger serial; private final CaCertificatePolicy policy; + private final X509AlgorithmBindingPolicy algorithmBindingPolicy; // The constructor is the single cohesive gate output boundary. @SuppressWarnings("PMD.ExcessiveParameterList") @@ -90,6 +92,15 @@ public final class ValidatedCaCertificateRequest { PkiId subjectCaId, CertificateProfileRef profileReference, CertificateProfileKind certificateType, SubjectRef subjectRef, List subjectRdns, EncodedObject exactPublicKey, Validity validity, BigInteger serial, CaCertificatePolicy policy) { + this(operation, formatId, issuerCaId, subjectCaId, profileReference, certificateType, subjectRef, subjectRdns, + exactPublicKey, validity, serial, policy, X509AlgorithmBindingPolicy.standardOnly()); + } + + @SuppressWarnings("PMD.ExcessiveParameterList") + /* package */ ValidatedCaCertificateRequest(Operation operation, FormatId formatId, PkiId issuerCaId, + PkiId subjectCaId, CertificateProfileRef profileReference, CertificateProfileKind certificateType, + SubjectRef subjectRef, List subjectRdns, EncodedObject exactPublicKey, Validity validity, + BigInteger serial, CaCertificatePolicy policy, X509AlgorithmBindingPolicy algorithmBindingPolicy) { this.operation = Objects.requireNonNull(operation, "operation"); this.formatId = Objects.requireNonNull(formatId, "formatId"); this.issuerCaId = Objects.requireNonNull(issuerCaId, "issuerCaId"); @@ -103,6 +114,7 @@ public final class ValidatedCaCertificateRequest { this.validity = Objects.requireNonNull(validity, "validity"); this.serial = Objects.requireNonNull(serial, "serial"); this.policy = Objects.requireNonNull(policy, "policy"); + this.algorithmBindingPolicy = Objects.requireNonNull(algorithmBindingPolicy, "algorithmBindingPolicy"); if (serial.signum() <= 0 || serial.toByteArray().length > 20) { throw new IllegalArgumentException("serial must be positive and at most 20 bytes"); } @@ -167,4 +179,9 @@ public final class ValidatedCaCertificateRequest { public CaCertificatePolicy policy() { return policy; } + + /** @return exact immutable X.509 representation selection */ + public X509AlgorithmBindingPolicy algorithmBindingPolicy() { + return algorithmBindingPolicy; + } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java index 2680bf0..f5e85f6 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java @@ -44,6 +44,7 @@ import zeroecho.pki.api.Validity; import zeroecho.pki.api.profile.CertificateProfileRef; import zeroecho.pki.api.profile.ExtendedKeyUsageId; import zeroecho.pki.api.profile.LeafKeyUsage; +import zeroecho.pki.api.profile.X509AlgorithmBindingPolicy; import zeroecho.pki.api.request.SubjectAlternativeName; import zeroecho.pki.api.request.SubjectRdn; @@ -71,12 +72,25 @@ public final class ValidatedCertificateRequest { private final boolean keyUsageCritical; private final boolean extendedKeyUsageCritical; private final boolean basicConstraintsCritical; + private final X509AlgorithmBindingPolicy algorithmBindingPolicy; /* default */ ValidatedCertificateRequest(PkiId issuerCaId, CertificateProfileRef profileReference, SubjectRef subjectRef, List subjectRdns, List subjectAlternativeNames, boolean subjectAlternativeNameCritical, EncodedObject exactPublicKey, Validity validity, Set keyUsages, Set extendedKeyUsages, boolean keyUsageCritical, boolean extendedKeyUsageCritical, boolean basicConstraintsCritical) { + this(issuerCaId, profileReference, subjectRef, subjectRdns, subjectAlternativeNames, + subjectAlternativeNameCritical, exactPublicKey, validity, keyUsages, extendedKeyUsages, + keyUsageCritical, extendedKeyUsageCritical, basicConstraintsCritical, + X509AlgorithmBindingPolicy.standardOnly()); + } + + /* default */ ValidatedCertificateRequest(PkiId issuerCaId, CertificateProfileRef profileReference, + SubjectRef subjectRef, List subjectRdns, List subjectAlternativeNames, + boolean subjectAlternativeNameCritical, EncodedObject exactPublicKey, Validity validity, + Set keyUsages, Set extendedKeyUsages, boolean keyUsageCritical, + boolean extendedKeyUsageCritical, boolean basicConstraintsCritical, + X509AlgorithmBindingPolicy algorithmBindingPolicy) { this.issuerCaId = Objects.requireNonNull(issuerCaId, "issuerCaId"); this.profileReference = Objects.requireNonNull(profileReference, "profileReference"); this.subjectRef = Objects.requireNonNull(subjectRef, "subjectRef"); @@ -90,6 +104,7 @@ public final class ValidatedCertificateRequest { this.keyUsageCritical = keyUsageCritical; this.extendedKeyUsageCritical = extendedKeyUsageCritical; this.basicConstraintsCritical = basicConstraintsCritical; + this.algorithmBindingPolicy = Objects.requireNonNull(algorithmBindingPolicy, "algorithmBindingPolicy"); } /** @return authoritative issuer CA identifier */ @@ -161,4 +176,9 @@ public final class ValidatedCertificateRequest { public boolean basicConstraintsCritical() { return basicConstraintsCritical; } + + /** @return exact immutable X.509 representation selection */ + public X509AlgorithmBindingPolicy algorithmBindingPolicy() { + return algorithmBindingPolicy; + } } diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java index 7760325..2ff92ff 100644 --- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java +++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java @@ -150,7 +150,8 @@ public final class ZeroEchoLibSignatureWorkflowProvider BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, BootstrapAlgorithmIdentities.RSA_PSS_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA384, BootstrapAlgorithmIdentities.ECDSA_SHA512, - BootstrapAlgorithmIdentities.ED25519_SIGNATURE, BootstrapAlgorithmIdentities.ED448_SIGNATURE); + BootstrapAlgorithmIdentities.ED25519_SIGNATURE, BootstrapAlgorithmIdentities.ED448_SIGNATURE, + BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE); return java.util.List.of(new AlgorithmExecutionCapability() { @Override public String implementationId() { diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/ImmutableX509AlgorithmBindingRegistry.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/ImmutableX509AlgorithmBindingRegistry.java new file mode 100644 index 0000000..4232d78 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/ImmutableX509AlgorithmBindingRegistry.java @@ -0,0 +1,176 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.framework.x509; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; +import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; + +/** Package-owned immutable registry implementation used by session bootstrap. */ +public final class ImmutableX509AlgorithmBindingRegistry implements X509AlgorithmBindingRegistry { + + /** Egothor Private Enterprise Number root. */ + public static final String EGOTHOR_PEN = "1.3.6.1.4.1.31424"; + /** Frozen ZeroEcho private assignment branch. */ + public static final String ZEROECHO_ASSIGNMENT_ROOT = EGOTHOR_PEN + ".1.1"; + /** Deployment-local experimental branch. */ + public static final String ZEROECHO_DEPLOYMENT_ROOT = EGOTHOR_PEN + ".1.2"; + + private final List bindings; + private final Map byId; + private final Map standards; + private final String commitment; + + private ImmutableX509AlgorithmBindingRegistry(List source) { + this.bindings = source; + Map identifiers = new LinkedHashMap<>(); + Map official = new LinkedHashMap<>(); + Map reverse = new LinkedHashMap<>(); + for (X509AlgorithmBinding binding : source) { + if (identifiers.putIfAbsent(binding.bindingId(), binding) != null) { + throw new IllegalArgumentException("Duplicate X.509 binding identifier"); + } + String reverseKey = binding.role() + "|" + binding.oid() + "|" + + (binding.parameterRule() == X509AlgorithmBinding.ParameterRule.STRUCTURED_DER + ? binding.algorithmIdentity().canonicalForm() : "fixed"); + if (reverse.putIfAbsent(reverseKey, binding) != null) { + throw new IllegalArgumentException("Ambiguous X.509 role and OID"); + } + if (binding.origin() == X509AlgorithmBinding.Origin.STANDARD + && official.putIfAbsent(key(binding.algorithmIdentity(), binding.role()), binding) != null) { + throw new IllegalArgumentException("Ambiguous standard X.509 binding"); + } + } + this.byId = Map.copyOf(identifiers); + this.standards = Map.copyOf(official); + this.commitment = digest(source); + } + + /** + * Constructs and validates one deterministic registry snapshot. + * + * @param bindings complete explicitly selected binding set + * @return immutable registry + */ + public static ImmutableX509AlgorithmBindingRegistry create(List bindings) { + Objects.requireNonNull(bindings, "bindings"); + List ordered = new ArrayList<>(bindings); + ordered.sort(Comparator.comparing(X509AlgorithmBinding::bindingId)); + for (X509AlgorithmBinding binding : ordered) { + Objects.requireNonNull(binding, "binding"); + if (binding.origin() == X509AlgorithmBinding.Origin.ZEROECHO_PRIVATE + && !isWithin(binding.oid(), ZEROECHO_ASSIGNMENT_ROOT)) { + throw new IllegalArgumentException("ZeroEcho binding is outside the frozen assignment branch"); + } + if (binding.origin() == X509AlgorithmBinding.Origin.STANDARD + && isWithin(binding.oid(), EGOTHOR_PEN)) { + throw new IllegalArgumentException("Standard binding cannot use the Egothor private namespace"); + } + } + return new ImmutableX509AlgorithmBindingRegistry(List.copyOf(ordered)); + } + + /** Tests whether one OID is strictly below or exactly at an authorized root. */ + public static boolean isWithin(String oid, String root) { + Objects.requireNonNull(oid, "oid"); + Objects.requireNonNull(root, "root"); + return oid.equals(root) || oid.startsWith(root + "."); + } + + @Override + public List bindings() { + return bindings; + } + + @Override + public Optional find(String bindingId) { + return Optional.ofNullable(byId.get(Objects.requireNonNull(bindingId, "bindingId"))); + } + + @Override + public Optional standard(AlgorithmIdentity identity, X509AlgorithmBinding.Role role) { + return Optional.ofNullable(standards.get(key(Objects.requireNonNull(identity, "identity"), + Objects.requireNonNull(role, "role")))); + } + + @Override + public String commitment() { + return commitment; + } + + @Override + public X509AlgorithmBinding require(String bindingId, String expectedCommitment) { + X509AlgorithmBinding binding = find(bindingId) + .orElseThrow(() -> new IllegalArgumentException("X.509 binding is not active")); + if (!binding.semanticCommitment().equals(Objects.requireNonNull(expectedCommitment, "expectedCommitment"))) { + throw new IllegalArgumentException("X.509 binding contract has changed"); + } + return binding; + } + + private static String key(AlgorithmIdentity identity, X509AlgorithmBinding.Role role) { + return identity.canonicalForm() + "|" + role; + } + + private static String digest(List bindings) { + StringBuilder semantic = new StringBuilder(); + for (X509AlgorithmBinding binding : bindings) { + semantic.append(binding.bindingId()).append('|').append(binding.algorithmIdentity().canonicalForm()) + .append('|').append(binding.role()).append('|').append(binding.oid()).append('|') + .append(binding.origin()).append('|').append(binding.encodingVersion()).append('|') + .append(binding.parameterRule()).append('|').append(binding.publicKeyEncoding()).append('|') + .append(binding.signatureEncoding()).append('|').append(binding.interoperability()).append('|') + .append(binding.providerId().orElse("")).append('|').append(binding.semanticCommitment()) + .append('\n'); + } + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(semantic.toString().getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java index eaf0d9f..4bf776e 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java @@ -42,6 +42,7 @@ import java.util.Optional; import zeroecho.core.alg.BootstrapAlgorithmIdentities; import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; /** * Immutable authoritative standard X.509 bootstrap bindings. @@ -150,6 +151,16 @@ public final class StandardX509Bindings { return createCatalog(components); } + /** + * Returns deterministic safe descriptors for every sealed standard binding. + * + * @return immutable standard binding descriptors + */ + public static List descriptors() { + return CATALOG.rules().stream().flatMap(rule -> rule.bindings().stream()) + .sorted(java.util.Comparator.comparing(X509AlgorithmBinding::bindingId)).toList(); + } + private static X509BindingRule fixed(String id, X509AlgorithmRole role, AlgorithmIdentity identity, X509AlgorithmIdentifier identifier, X509BindingRule.SignatureEncoding signatureEncoding, X509BindingRule.PublicKeyEncoding publicKeyEncoding) { @@ -180,6 +191,12 @@ public final class StandardX509Bindings { + signatureEncoding + "|" + publicKeyEncoding; } + @Override + public List bindings() { + return descriptors(id, role, identity, identifier, signatureEncoding, publicKeyEncoding, + semanticFingerprint()); + } + @Override public Optional encode(AlgorithmIdentity candidate) { return identity.equals(candidate) ? Optional.of(identifier) : Optional.empty(); @@ -236,6 +253,13 @@ public final class StandardX509Bindings { return PublicKeyEncoding.NOT_APPLICABLE; } + @Override + public List bindings() { + X509AlgorithmIdentifier identifier = encode(BootstrapAlgorithmIdentities.RSA_PSS_SHA256).orElseThrow(); + return descriptors(id(), role(), BootstrapAlgorithmIdentities.RSA_PSS_SHA256, identifier, + signatureEncoding(), publicKeyEncoding(), semanticFingerprint()); + } + @Override public Optional encode(AlgorithmIdentity identity) { if (identity.kind() != AlgorithmIdentity.Kind.SIGNATURE @@ -302,6 +326,16 @@ public final class StandardX509Bindings { return PublicKeyEncoding.EC_POINT; } + @Override + public List bindings() { + return List.of(BootstrapAlgorithmIdentities.EC_P256_PUBLIC_KEY, + BootstrapAlgorithmIdentities.EC_P384_PUBLIC_KEY, + BootstrapAlgorithmIdentities.EC_P521_PUBLIC_KEY).stream() + .flatMap(identity -> descriptors(id() + "." + identity.parameters().canonicalForm(), role(), + identity, encode(identity).orElseThrow(), signatureEncoding(), publicKeyEncoding(), + semanticFingerprint()).stream()).toList(); + } + @Override public Optional encode(AlgorithmIdentity identity) { if (identity.kind() != AlgorithmIdentity.Kind.PUBLIC_KEY @@ -341,6 +375,42 @@ public final class StandardX509Bindings { StrictDer.explicit(2, StrictDer.integer(parameters.saltLength()))); } + private static List descriptors(String id, X509AlgorithmRole role, + AlgorithmIdentity identity, X509AlgorithmIdentifier identifier, X509BindingRule.SignatureEncoding signature, + X509BindingRule.PublicKeyEncoding publicKey, String commitment) { + X509AlgorithmBinding.ParameterRule parameters = switch (identifier.parameterForm()) { + case ABSENT -> X509AlgorithmBinding.ParameterRule.ABSENT; + case DER_NULL -> X509AlgorithmBinding.ParameterRule.DER_NULL; + case EXACT_DER -> X509AlgorithmBinding.ParameterRule.STRUCTURED_DER; + }; + X509AlgorithmBinding.PublicKeyEncoding keyEncoding = switch (publicKey) { + case RSA_PKCS1_DER -> X509AlgorithmBinding.PublicKeyEncoding.RSA_PKCS1_DER; + case EC_POINT -> X509AlgorithmBinding.PublicKeyEncoding.EC_POINT; + case RAW -> X509AlgorithmBinding.PublicKeyEncoding.RAW; + case NESTED_SPKI_DER -> X509AlgorithmBinding.PublicKeyEncoding.NESTED_SPKI_DER; + case NOT_APPLICABLE -> X509AlgorithmBinding.PublicKeyEncoding.NOT_APPLICABLE; + }; + X509AlgorithmBinding.SignatureEncoding signatureEncoding = switch (signature) { + case OPAQUE -> X509AlgorithmBinding.SignatureEncoding.OPAQUE; + case ECDSA_DER -> X509AlgorithmBinding.SignatureEncoding.ECDSA_DER; + case NOT_APPLICABLE -> X509AlgorithmBinding.SignatureEncoding.NOT_APPLICABLE; + }; + if (role == X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM) { + return List.of(new X509AlgorithmBinding(id, identity, X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY, + identifier.oid(), X509AlgorithmBinding.Origin.STANDARD, 1, parameters, keyEncoding, + signatureEncoding, X509AlgorithmBinding.Interoperability.STANDARD_INTEROPERABLE, + Optional.empty(), commitment)); + } + return List.of(X509AlgorithmBinding.Role.CSR_SIGNATURE, X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE, + X509AlgorithmBinding.Role.CRL_SIGNATURE).stream() + .map(bindingRole -> new X509AlgorithmBinding(id + "." + + bindingRole.name().toLowerCase(java.util.Locale.ROOT), identity, + bindingRole, identifier.oid(), X509AlgorithmBinding.Origin.STANDARD, 1, parameters, keyEncoding, + signatureEncoding, X509AlgorithmBinding.Interoperability.STANDARD_INTEROPERABLE, + Optional.empty(), commitment)) + .toList(); + } + private static AlgorithmIdentity.RsaPssParameters decodePss(byte[] encoded, X509ComponentCatalog components) { StrictDer.Reader sequence = StrictDer.reader(encoded).readConstructed(0x30); byte[] hashAlgorithm = sequence.readConstructed(0xa0).readOnlyValue(0x30); diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java index 12d87f1..ec84ae4 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java @@ -194,6 +194,14 @@ public final class X509AlgorithmResolver { public Selection resolve(AlgorithmIdentity requested, AlgorithmIdentity key, AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance, String authorityFingerprint) { + return resolve(requested, key, direction, implementation, provenance, authorityFingerprint, + Optional.empty()); + } + + /** Resolves an operation with an optional explicit X.509 binding identity. */ + public Selection resolve(AlgorithmIdentity requested, AlgorithmIdentity key, + AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance, + String authorityFingerprint, Optional bindingId) { Objects.requireNonNull(requested, "requested"); Objects.requireNonNull(key, "key"); Objects.requireNonNull(direction, "direction"); @@ -213,7 +221,9 @@ public final class X509AlgorithmResolver { } X509AlgorithmIdentifier binding; try { - binding = bindings.resolve(requested, X509AlgorithmRole.SIGNATURE_ALGORITHM); + binding = bindingId.isPresent() + ? bindings.resolve(bindingId.orElseThrow(), requested, X509AlgorithmRole.SIGNATURE_ALGORITHM) + : bindings.resolve(requested, X509AlgorithmRole.SIGNATURE_ALGORITHM); } catch (IllegalArgumentException missing) { throw new ResolutionException(Failure.NO_BINDING, missing); } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java index 4186d1f..6e3cdcf 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java @@ -188,7 +188,8 @@ public final class X509AuthoritySnapshot { mergeDefaults(defaults, provider.defaults()); } X509ComponentCatalog components = X509ComponentCatalog.builtIn().merge(componentExtensions); - X509BindingCatalog builtInBindings = StandardX509Bindings.catalog(components); + X509BindingCatalog builtInBindings = StandardX509Bindings.catalog(components) + .merge(List.of(ZeroEchoPrivateX509Bindings.catalog())); for (X509BindingRuleProvider provider : ordered) { List rules = List.copyOf(provider.rules()); if (!rules.isEmpty()) { @@ -248,6 +249,14 @@ public final class X509AuthoritySnapshot { return resolver.resolve(signature, key, direction, implementation, provenance, semanticFingerprint); } + /** Resolves one operation through an explicitly selected role-specific binding. */ + public X509AlgorithmResolver.Selection resolve(AlgorithmIdentity signature, AlgorithmIdentity key, + AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance, + String bindingId) { + return resolver.resolve(signature, key, direction, implementation, provenance, semanticFingerprint, + Optional.of(Objects.requireNonNull(bindingId, "bindingId"))); + } + /** * Resolves and authorizes one process-local execution plan. * @@ -272,6 +281,20 @@ public final class X509AuthoritySnapshot { return new X509ExecutionPlan<>(selection, executorType.cast(executor), provenanceToken); } + /** Resolves and authorizes one plan through an explicit binding identity. */ + public X509ExecutionPlan plan(AlgorithmIdentity signature, AlgorithmIdentity key, + AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance, + String bindingId, Class executorType) { + X509AlgorithmResolver.Selection selection = resolve(signature, key, direction, implementation, provenance, + bindingId); + ExecutorKey executorKey = new ExecutorKey(selection.implementation().implementationId(), direction); + Object executor = executors.get(executorKey); + if (executor == null || !executorType.isInstance(executor)) { + throw new X509AlgorithmResolver.ResolutionException(X509AlgorithmResolver.Failure.NO_EXECUTOR); + } + return new X509ExecutionPlan<>(selection, executorType.cast(executor), provenanceToken); + } + /** * Resolves a signing plan for a legacy upper API that carries only the * signature identity. @@ -304,6 +327,14 @@ public final class X509AuthoritySnapshot { executorType); } + /** Resolves a signing plan through one explicit binding. */ + public X509ExecutionPlan planSigningWithBinding(String value, String bindingId, Class executorType) { + AlgorithmIdentity signature = resolveIdentity(value); + AlgorithmIdentity key = bootstrapKeyFor(signature); + return plan(signature, key, AlgorithmExecutionCapability.Direction.SIGN, Optional.empty(), "explicit", + bindingId, executorType); + } + /** * Validates an execution plan immediately before invoking its executor. * @@ -562,6 +593,9 @@ public final class X509AuthoritySnapshot { if (signature.equals(BootstrapAlgorithmIdentities.ED448_SIGNATURE)) { return BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY; } + if (signature.equals(BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE)) { + return BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY; + } if ("rsa-pkcs1-v1_5".equals(signature.family().name()) || "rsa-pss".equals(signature.family().name())) { return BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY; diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java index fd3a3dd..23bc969 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java @@ -130,6 +130,11 @@ public final class X509BindingCatalog { if (rule.role() != role) { continue; } + if (!rule.bindings().isEmpty() && rule.bindings().stream() + .noneMatch(binding -> binding.origin() + == zeroecho.pki.api.algorithm.X509AlgorithmBinding.Origin.STANDARD)) { + continue; + } Optional candidate = rule.encode(identity); if (candidate.isPresent()) { if (resolved != null) { @@ -144,6 +149,30 @@ public final class X509BindingCatalog { return resolved; } + /** + * Resolves one exact identity through an explicitly selected binding. + * + * @param bindingId stable binding identifier + * @param identity exact cryptographic identity + * @param role internal structural role + * @return canonical identifier + * @throws IllegalArgumentException if the binding is absent, has the wrong + * role, or cannot encode the identity + */ + public X509AlgorithmIdentifier resolve(String bindingId, AlgorithmIdentity identity, X509AlgorithmRole role) { + Objects.requireNonNull(bindingId, "bindingId"); + Objects.requireNonNull(identity, "identity"); + Objects.requireNonNull(role, "role"); + X509BindingRule rule = rules.stream() + .filter(candidate -> candidate.id().equals(bindingId) + || candidate.bindings().stream().anyMatch(binding -> binding.bindingId().equals(bindingId))) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown X.509 binding identifier")); + if (rule.role() != role) { + throw new IllegalArgumentException("X.509 binding role mismatch"); + } + return rule.encode(identity).orElseThrow(() -> new IllegalArgumentException("X.509 binding identity mismatch")); + } + /** * Reverse-resolves an exact role-specific representation. * @@ -167,6 +196,23 @@ public final class X509BindingCatalog { return identity; } + /** + * Returns the stable binding identifier owning an exact representation. + * + * @param identifier canonical representation + * @param role structural role + * @return stable binding identifier + */ + public String bindingId(X509AlgorithmIdentifier identifier, X509AlgorithmRole role) { + Objects.requireNonNull(identifier, "identifier"); + Objects.requireNonNull(role, "role"); + X509BindingRule rule = byRoleAndOid.get(key(role, identifier.oid())); + if (rule == null || rule.decode(identifier).isEmpty()) { + throw new IllegalArgumentException("Unknown X.509 algorithm identifier"); + } + return rule.id(); + } + /** * Returns deterministic immutable binding rules. * diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java index 70803b6..17002d7 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java @@ -34,8 +34,10 @@ package zeroecho.pki.impl.framework.x509; import java.util.Optional; +import java.util.List; import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; /** * Immutable trusted-code rule between exact ZeroEcho identities and X.509 @@ -49,6 +51,19 @@ import zeroecho.core.spec.AlgorithmIdentity; */ public interface X509BindingRule { + /** + * Returns the finite safe descriptors owned by this encoding rule. + * + *

A parameterized rule may expose one descriptor for every currently + * registered exact identity. Descriptors do not expose implementation + * classes or mutable codec state.

+ * + * @return immutable descriptors + */ + default List bindings() { + return List.of(); + } + /** * Signature byte representation owned by a signature rule. */ @@ -71,6 +86,8 @@ public interface X509BindingRule { EC_POINT, /** Algorithm-defined raw public-key bytes. */ RAW, + /** Canonical native SubjectPublicKeyInfo DER nested in the BIT STRING. */ + NESTED_SPKI_DER, /** Not applicable to a signature-only rule. */ NOT_APPLICABLE } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java index 861866f..653b948 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java @@ -71,6 +71,7 @@ public final class X509SuiteCompatibility { case "ecdsa" -> "ec".equals(keyFamily); case "ed25519" -> "ed25519".equals(keyFamily); case "ed448" -> "ed448".equals(keyFamily); + case "sphincs-plus" -> "sphincs-plus".equals(keyFamily); default -> false; }; if (!compatible) { diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/ZeroEchoPrivateX509Bindings.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/ZeroEchoPrivateX509Bindings.java new file mode 100644 index 0000000..9812be4 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/ZeroEchoPrivateX509Bindings.java @@ -0,0 +1,277 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.framework.x509; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.ObjectReadContext; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonFactoryBuilder; +import zeroecho.core.alg.BootstrapAlgorithmIdentities; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; + +/** Loads and locks the version-one ZeroEcho-owned private X.509 assignments. */ +public final class ZeroEchoPrivateX509Bindings { + + /** Versioned assignment resource. */ + public static final String RESOURCE = "/zeroecho/pki/x509/zeroecho-private-bindings-v1.json"; + private static final int MAXIMUM_RESOURCE_BYTES = 64 * 1024; + private static final Set ROOT_FIELDS = Set.of("schemaVersion", "bindings"); + private static final Set BINDING_FIELDS = Set.of("bindingId", "algorithmIdentity", "role", "oid", + "encodingVersion", "parameterRule", "publicKeyEncoding", "signatureEncoding", "interoperability"); + private static final JsonFactory JSON = new JsonFactoryBuilder() + .streamReadConstraints(StreamReadConstraints.builder().maxDocumentLength(MAXIMUM_RESOURCE_BYTES) + .maxNestingDepth(8).maxStringLength(512).build()) + .build(); + private static final AssignmentSet ASSIGNMENTS = loadResource(); + + private ZeroEchoPrivateX509Bindings() { + } + + /** @return frozen safe descriptors in resource order */ + public static List descriptors() { + return ASSIGNMENTS.descriptors(); + } + + /** @return trusted immutable encoding-rule catalog */ + public static X509BindingCatalog catalog() { + return ASSIGNMENTS.catalog(); + } + + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.ExceptionAsFlowControl", + "PMD.AvoidLiteralsInIfCondition" }) + private static AssignmentSet loadResource() { + try (InputStream input = ZeroEchoPrivateX509Bindings.class.getResourceAsStream(RESOURCE)) { + if (input == null) { + throw new IllegalStateException("ZeroEcho X.509 assignment resource is missing"); + } + try (JsonParser parser = JSON.createParser(ObjectReadContext.empty(), input)) { + require(parser.nextToken(), JsonToken.START_OBJECT); + Set seen = new HashSet<>(); + int schema = 0; + List bindings = null; + while (parser.nextToken() != JsonToken.END_OBJECT) { + String field = parser.currentName(); + if (!ROOT_FIELDS.contains(field) || !seen.add(field)) { + throw invalid(); + } + parser.nextToken(); + if ("schemaVersion".equals(field)) { + schema = parser.getIntValue(); + } else { + bindings = readBindings(parser); + } + } + if (parser.nextToken() != null || schema != 1 || bindings == null || seen.size() != ROOT_FIELDS.size()) { + throw invalid(); + } + return validate(bindings); + } + } catch (IOException | RuntimeException failure) { + throw new IllegalStateException("ZeroEcho X.509 assignment resource is invalid", failure); + } + } + + @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops", + "PMD.AvoidLiteralsInIfCondition" }) + private static List readBindings(JsonParser parser) throws IOException { + require(parser.currentToken(), JsonToken.START_ARRAY); + List bindings = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + require(parser.currentToken(), JsonToken.START_OBJECT); + Set seen = new HashSet<>(); + String id = null; + String identity = null; + String role = null; + String oid = null; + int version = 0; + String parameters = null; + String publicKey = null; + String signature = null; + String interoperability = null; + while (parser.nextToken() != JsonToken.END_OBJECT) { + String field = parser.currentName(); + if (!BINDING_FIELDS.contains(field) || !seen.add(field)) { + throw invalid(); + } + parser.nextToken(); + if ("encodingVersion".equals(field)) { + version = parser.getIntValue(); + } else { + String value = parser.getValueAsString(); + switch (field) { + case "bindingId" -> id = value; + case "algorithmIdentity" -> identity = value; + case "role" -> role = value; + case "oid" -> oid = value; + case "parameterRule" -> parameters = value; + case "publicKeyEncoding" -> publicKey = value; + case "signatureEncoding" -> signature = value; + case "interoperability" -> interoperability = value; + default -> throw invalid(); + } + } + } + if (seen.size() != BINDING_FIELDS.size()) { + throw invalid(); + } + AlgorithmIdentity exactIdentity = identity(identity); + String commitment = String.join("|", id, exactIdentity.canonicalForm(), role, oid, + Integer.toString(version), parameters, publicKey, signature, interoperability); + bindings.add(new X509AlgorithmBinding(id, exactIdentity, X509AlgorithmBinding.Role.valueOf(role), oid, + X509AlgorithmBinding.Origin.ZEROECHO_PRIVATE, version, + X509AlgorithmBinding.ParameterRule.valueOf(parameters), + X509AlgorithmBinding.PublicKeyEncoding.valueOf(publicKey), + X509AlgorithmBinding.SignatureEncoding.valueOf(signature), + X509AlgorithmBinding.Interoperability.valueOf(interoperability), Optional.empty(), commitment)); + } + return List.copyOf(bindings); + } + + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + private static AssignmentSet validate(List descriptors) { + if (descriptors.size() != 4) { + throw invalid(); + } + ImmutableX509AlgorithmBindingRegistry.create(descriptors); + List rules = descriptors.stream().map(PrivateRule::new).map(X509BindingRule.class::cast) + .toList(); + return new AssignmentSet(descriptors, X509BindingCatalog.builtIn(rules)); + } + + private static AlgorithmIdentity identity(String value) { + return switch (Objects.requireNonNull(value, "algorithmIdentity")) { + case "SPHINCS_PLUS_SIGNATURE" -> BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE; + case "SPHINCS_PLUS_PUBLIC_KEY" -> BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY; + default -> throw invalid(); + }; + } + + private static void require(JsonToken actual, JsonToken expected) { + if (actual != expected) { + throw invalid(); + } + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("Invalid ZeroEcho X.509 assignment schema"); + } + + private record AssignmentSet(List descriptors, X509BindingCatalog catalog) { + private AssignmentSet { + descriptors = List.copyOf(descriptors); + Objects.requireNonNull(catalog, "catalog"); + } + } + + private record PrivateRule(X509AlgorithmBinding binding) implements X509BindingRule { + private PrivateRule { + Objects.requireNonNull(binding, "binding"); + } + + @Override + public String id() { + return binding.bindingId(); + } + + @Override + public X509AlgorithmRole role() { + return binding.role() == X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY + ? X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM : X509AlgorithmRole.SIGNATURE_ALGORITHM; + } + + @Override + public String oid() { + return binding.oid(); + } + + @Override + public String semanticFingerprint() { + return binding.semanticCommitment(); + } + + @Override + public SignatureEncoding signatureEncoding() { + return switch (binding.signatureEncoding()) { + case OPAQUE -> SignatureEncoding.OPAQUE; + case ECDSA_DER -> SignatureEncoding.ECDSA_DER; + case NOT_APPLICABLE -> SignatureEncoding.NOT_APPLICABLE; + }; + } + + @Override + public PublicKeyEncoding publicKeyEncoding() { + return switch (binding.publicKeyEncoding()) { + case NESTED_SPKI_DER -> PublicKeyEncoding.NESTED_SPKI_DER; + case RAW -> PublicKeyEncoding.RAW; + case RSA_PKCS1_DER -> PublicKeyEncoding.RSA_PKCS1_DER; + case EC_POINT -> PublicKeyEncoding.EC_POINT; + case NOT_APPLICABLE -> PublicKeyEncoding.NOT_APPLICABLE; + }; + } + + @Override + public List bindings() { + return List.of(binding); + } + + @Override + public Optional encode(AlgorithmIdentity identity) { + return binding.algorithmIdentity().equals(identity) + ? Optional.of(X509AlgorithmIdentifier.absent(binding.oid())) : Optional.empty(); + } + + @Override + public Optional decode(X509AlgorithmIdentifier identifier) { + if (!binding.oid().equals(identifier.oid())) { + return Optional.empty(); + } + if (identifier.parameterForm() != X509AlgorithmIdentifier.ParameterForm.ABSENT) { + throw new IllegalArgumentException("Private X.509 binding parameters must be absent"); + } + return Optional.of(binding.algorithmIdentity()); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java index 92be442..79a0e5c 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java @@ -165,6 +165,12 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack */ public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity, Duration signingTtl) { + this(signingBus, signatureIdentity, Optional.empty(), signingTtl); + } + + /** Creates an issuance backend with an optional explicit certificate binding. */ + public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity, + Optional signatureBindingId, Duration signingTtl) { if (signingBus == null) { throw new IllegalArgumentException("signingBus must not be null"); } @@ -176,6 +182,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack } this.signingBus = signingBus; this.signatureIdentity = signatureIdentity; + java.util.Objects.requireNonNull(signatureBindingId, "signatureBindingId"); this.signingTtl = signingTtl; } @@ -222,7 +229,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki); addLeafExtensions(builder, request); - PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl); + PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy()); X509CertificateHolder leaf; try { leaf = builder.build(signer); @@ -279,6 +286,16 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack } } + private PkiBusContentSigner signer(KeyRef keyRef, + zeroecho.pki.api.profile.X509AlgorithmBindingPolicy bindingPolicy) { + Optional bindingId = bindingPolicy.certificateSignature() + .map(zeroecho.pki.api.profile.X509AlgorithmBindingPolicy.BindingReference::bindingId); + return bindingId.isPresent() + ? new PkiBusContentSigner(signingBus, keyRef, signatureIdentity, + bindingId.orElseThrow(), signingTtl) + : new PkiBusContentSigner(signingBus, keyRef, signatureIdentity, signingTtl); + } + private static int toKeyUsageBits(java.util.Set usages) { int bits = 0; for (LeafKeyUsage usage : usages) { @@ -372,7 +389,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED"); } - PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl); + PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy()); X509CertificateHolder certificate; try { certificate = builder.build(signer); diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java index 3ddacd2..022b0aa 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java @@ -215,6 +215,8 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV X509ExecutionPlan plan = authority.plan(signatureIdentity, keyIdentity, AlgorithmExecutionCapability.Direction.VERIFY, Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "csr-proof", + authority.bindings().bindingId(BcX509AlgorithmAdapter.fromBc(csr.getSignatureAlgorithm()), + X509AlgorithmRole.SIGNATURE_ALGORITHM), BcX509VerificationExecutor.class); byte[] signedBytes = csr.toASN1Structure().getCertificationRequestInfo().getEncoded(); boolean valid = executor.verify(authority, plan, csr.getSubjectPublicKeyInfo(), diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509PublicKeyAdapter.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509PublicKeyAdapter.java new file mode 100644 index 0000000..81c22a9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509PublicKeyAdapter.java @@ -0,0 +1,107 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.framework.x509.bc; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; + +import org.bouncycastle.asn1.ASN1Encoding; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; + +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.pki.impl.framework.x509.StreamingDerReader; +import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; +import zeroecho.pki.impl.framework.x509.X509BindingCatalog; +import zeroecho.pki.impl.framework.x509.X509BindingRule; + +/** Bouncy Castle adapter for binding-owned SubjectPublicKeyInfo encodings. */ +public final class BcX509PublicKeyAdapter { + + private static final int MAXIMUM_SPKI_BYTES = 1024 * 1024; + private final X509BindingCatalog catalog; + + /** Creates an adapter for one immutable binding catalog. */ + public BcX509PublicKeyAdapter(X509BindingCatalog catalog) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + } + + /** + * Wraps canonical native SPKI DER under one explicit binding. + * + * @param nativeSpki canonical provider-native SPKI + * @param identity exact public-key identity + * @param bindingId explicit SPKI binding + * @return canonical bound SPKI + * @throws IOException if either SPKI cannot be represented canonically + */ + public SubjectPublicKeyInfo wrap(byte[] nativeSpki, AlgorithmIdentity identity, String bindingId) + throws IOException { + requireCanonical(nativeSpki); + org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm = BcX509AlgorithmAdapter.toBc( + catalog.resolve(bindingId, identity, X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM)); + SubjectPublicKeyInfo wrapped = new SubjectPublicKeyInfo(algorithm, nativeSpki); + requireCanonical(wrapped.getEncoded(ASN1Encoding.DER)); + return wrapped; + } + + /** + * Returns provider-native SPKI for verification or key import. + * + * @param input exact active SPKI + * @return canonical native SPKI; the input itself for standard encodings + * @throws IOException if nested SPKI bytes are malformed or non-canonical + */ + public SubjectPublicKeyInfo unwrap(SubjectPublicKeyInfo input) throws IOException { + Objects.requireNonNull(input, "input"); + String bindingId = catalog.bindingId(BcX509AlgorithmAdapter.fromBc(input.getAlgorithm()), + X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); + X509BindingRule rule = catalog.rules().stream().filter(candidate -> candidate.id().equals(bindingId)) + .findFirst().orElseThrow(() -> new IllegalArgumentException("SPKI binding rule is unavailable")); + if (rule.publicKeyEncoding() != X509BindingRule.PublicKeyEncoding.NESTED_SPKI_DER) { + return input; + } + byte[] nested = input.getPublicKeyData().getBytes(); + requireCanonical(nested); + SubjectPublicKeyInfo nativeSpki = SubjectPublicKeyInfo.getInstance(nested); + if (!Arrays.equals(nested, nativeSpki.getEncoded(ASN1Encoding.DER))) { + throw new IOException("Nested SubjectPublicKeyInfo is not canonical DER"); + } + return nativeSpki; + } + + private static void requireCanonical(byte[] encoded) throws IOException { + new StreamingDerReader().validateSubjectPublicKeyInfo(encoded, MAXIMUM_SPKI_BYTES); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java index c5daf28..b8a6588 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java @@ -209,6 +209,8 @@ public final class BcX509SignedObjectValidator { X509ExecutionPlan verification = authority.plan(signatureIdentity, issuerKey, AlgorithmExecutionCapability.Direction.VERIFY, Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "status-postcondition", + authority.bindings().bindingId(BcX509AlgorithmAdapter.fromBc(identifier), + X509AlgorithmRole.SIGNATURE_ALGORITHM), BcX509VerificationExecutor.class); try (RepeatableContent tbs = new ContentSlice(content, layout.tbsOffset(), layout.tbsLength())) { if (!verification.executor().verify(authority, verification, issuerPublicKey, identifier, tbs, diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java index c7d7a53..f82b592 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java @@ -169,6 +169,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator private final PkiSigningBus signingBus; private final AlgorithmIdentity signatureIdentity; private final Duration signingTtl; + private final Optional signatureBindingId; /** * Creates the X.509 status object generator. @@ -196,6 +197,12 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator */ public BcX509StatusObjectGenerator(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity, Duration signingTtl) { + this(signingBus, signatureIdentity, Optional.empty(), signingTtl); + } + + /** Creates a generator with an optional explicit CRL-signature binding. */ + public BcX509StatusObjectGenerator(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity, + Optional signatureBindingId, Duration signingTtl) { if (signingBus == null) { throw new IllegalArgumentException("signingBus must not be null"); } @@ -207,6 +214,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator } this.signingBus = signingBus; this.signatureIdentity = signatureIdentity; + this.signatureBindingId = java.util.Objects.requireNonNull(signatureBindingId, "signatureBindingId"); this.signingTtl = signingTtl; } @@ -262,8 +270,10 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator boolean accepted = false; try { entries = encodeEntries(crlEntries); - PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), - signatureIdentity, signingTtl); + PkiBusContentSigner signer = signatureBindingId.isPresent() + ? new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), signatureIdentity, + signatureBindingId.orElseThrow(), signingTtl) + : new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), signatureIdentity, signingTtl); byte[] algorithm = signer.getAlgorithmIdentifier().getEncoded(); tbs = encodeTbs(issuerMaterial.issuerHolder(), thisUpdate, nextUpdate, entries, algorithm); copyToSigner(tbs, signer); @@ -321,6 +331,8 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator X509ExecutionPlan plan = signingBus.authority().plan(actualSignature, issuerKey, AlgorithmExecutionCapability.Direction.VERIFY, Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "crl-postcondition", + signingBus.authority().bindings().bindingId(BcX509AlgorithmAdapter.fromBc(algorithm), + X509AlgorithmRole.SIGNATURE_ALGORITHM), BcX509VerificationExecutor.class); try (RepeatableContent signedContent = new ContentSlice(complete, layout.tbsOffset(), layout.tbsLength())) { diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java index 6487bf6..bec4b17 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java @@ -37,12 +37,17 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; import java.util.List; import java.util.Set; import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider; import org.bouncycastle.operator.ContentVerifier; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; @@ -78,7 +83,8 @@ public final class BcX509VerificationExecutor implements AlgorithmExecutionCapab BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, BootstrapAlgorithmIdentities.RSA_PSS_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA384, BootstrapAlgorithmIdentities.ECDSA_SHA512, BootstrapAlgorithmIdentities.ED25519_SIGNATURE, - BootstrapAlgorithmIdentities.ED448_SIGNATURE); + BootstrapAlgorithmIdentities.ED448_SIGNATURE, BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE); + private static final BouncyCastlePQCProvider BC_PQC_PROVIDER = new BouncyCastlePQCProvider(); @Override public List capabilities() { @@ -134,8 +140,16 @@ public final class BcX509VerificationExecutor implements AlgorithmExecutionCapab if (!plan.selection().suite().publicKey().equals(publicKeyIdentity)) { throw new IllegalArgumentException("SubjectPublicKeyInfo does not match execution plan"); } + SubjectPublicKeyInfo nativePublicKey = new BcX509PublicKeyAdapter(authority.bindings()).unwrap(publicKeyInfo); + if (BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE.equals(signatureIdentity)) { + return verifySphincs(nativePublicKey, signedContent, signature); + } + if (!authority.bindings().resolve(signatureIdentity, X509AlgorithmRole.SIGNATURE_ALGORITHM) + .equals(BcX509AlgorithmAdapter.fromBc(algorithmIdentifier))) { + return verifyCustomClassic(signatureIdentity, nativePublicKey, signedContent, signature); + } ContentVerifier verifier = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER) - .build(publicKeyInfo).get(algorithmIdentifier); + .build(nativePublicKey).get(algorithmIdentifier); try (InputStream input = signedContent.openStream(); OutputStream output = verifier.getOutputStream()) { byte[] buffer = new byte[16 * 1024]; int read; @@ -148,4 +162,50 @@ public final class BcX509VerificationExecutor implements AlgorithmExecutionCapab } return verifier.verify(signature); } + + private static boolean verifySphincs(SubjectPublicKeyInfo publicKeyInfo, RepeatableContent signedContent, + byte[] signatureBytes) throws GeneralSecurityException, IOException { + PublicKey publicKey = KeyFactory.getInstance("SPHINCS+", BC_PQC_PROVIDER) + .generatePublic(new X509EncodedKeySpec(publicKeyInfo.getEncoded())); + Signature verifier = Signature.getInstance("SPHINCS+", BC_PQC_PROVIDER); + verifier.initVerify(publicKey); + try (InputStream input = signedContent.openStream()) { + byte[] buffer = new byte[16 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) { + CancellationSignal.NONE.throwIfCancelled(); + if (read != 0) { + verifier.update(buffer, 0, read); + } + } + } + return verifier.verify(signatureBytes); + } + + private static boolean verifyCustomClassic(AlgorithmIdentity identity, SubjectPublicKeyInfo publicKeyInfo, + RepeatableContent signedContent, byte[] signatureBytes) throws GeneralSecurityException, IOException { + String algorithm; + if (BootstrapAlgorithmIdentities.ED25519_SIGNATURE.equals(identity)) { + algorithm = "Ed25519"; + } else if (BootstrapAlgorithmIdentities.ED448_SIGNATURE.equals(identity)) { + algorithm = "Ed448"; + } else { + throw new GeneralSecurityException("Custom classic binding lacks an exact JCA verification contract"); + } + PublicKey publicKey = KeyFactory.getInstance(algorithm, BC_PROVIDER) + .generatePublic(new X509EncodedKeySpec(publicKeyInfo.getEncoded())); + Signature verifier = Signature.getInstance(algorithm, BC_PROVIDER); + verifier.initVerify(publicKey); + try (InputStream input = signedContent.openStream()) { + byte[] buffer = new byte[16 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) { + CancellationSignal.NONE.throwIfCancelled(); + if (read != 0) { + verifier.update(buffer, 0, read); + } + } + } + return verifier.verify(signatureBytes); + } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java index f69d953..b2e0e1a 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java @@ -54,7 +54,6 @@ import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus.SignContinuation; -import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.store.ContentSink; @@ -156,6 +155,25 @@ public final class PkiBusContentSigner implements ContentSigner { * authoritative signature binding exists */ public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity, Duration ttl) { + this(bus, keyRef, algorithmIdentity, Optional.empty(), ttl); + } + + /** + * Creates a signer using one explicitly selected X.509 binding. + * + * @param bus signing bus + * @param keyRef managed signing-key reference + * @param algorithmIdentity exact cryptographic identity + * @param bindingId stable active binding identity + * @param ttl positive workflow time-to-live + */ + public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity, + String bindingId, Duration ttl) { + this(bus, keyRef, algorithmIdentity, Optional.of(bindingId), ttl); + } + + private PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity, + Optional bindingId, Duration ttl) { if (bus == null) { throw new IllegalArgumentException("bus must not be null"); } @@ -170,8 +188,10 @@ public final class PkiBusContentSigner implements ContentSigner { } this.bus = bus; this.keyRef = keyRef; - X509ExecutionPlan plan = bus.authority() - .planSigning(algorithmIdentity.canonicalForm(), SignatureWorkflow.class); + X509ExecutionPlan plan = bindingId.isPresent() + ? bus.authority().planSigningWithBinding(algorithmIdentity.canonicalForm(), bindingId.orElseThrow(), + SignatureWorkflow.class) + : bus.authority().planSigning(algorithmIdentity.canonicalForm(), SignatureWorkflow.class); bus.authority().authorize(plan, plan.executor(), zeroecho.core.spi.AlgorithmExecutionCapability.Direction.SIGN); this.executionPlan = plan; @@ -205,8 +225,7 @@ public final class PkiBusContentSigner implements ContentSigner { */ @Override public AlgorithmIdentifier getAlgorithmIdentifier() { - return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity, - X509AlgorithmRole.SIGNATURE_ALGORITHM); + return BcX509AlgorithmAdapter.toBc(executionPlan.selection().binding()); } /** diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java index 35f3023..9316ec4 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java @@ -225,7 +225,10 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); executionPlan = authority.plan(signatureIdentity, keyIdentity, zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY, - Optional.empty(), "csr-proof", SignatureWorkflow.class); + Optional.empty(), "csr-proof", + authority.bindings().bindingId(BcX509AlgorithmAdapter.fromBc( + csrAsn1.getSignatureAlgorithm()), X509AlgorithmRole.SIGNATURE_ALGORITHM), + SignatureWorkflow.class); } catch (IllegalArgumentException unsupported) { return new ProofOfPossessionResult(ProofOfPossessionStatus.NOT_SUPPORTED, Optional.of("Unsupported CSR algorithm")); diff --git a/pki/src/main/java/zeroecho/pki/spi/algorithm/X509AlgorithmBindingProvider.java b/pki/src/main/java/zeroecho/pki/spi/algorithm/X509AlgorithmBindingProvider.java new file mode 100644 index 0000000..c7b563e --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/algorithm/X509AlgorithmBindingProvider.java @@ -0,0 +1,61 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.spi.algorithm; + +import java.util.List; + +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; +import zeroecho.pki.impl.framework.x509.X509BindingRule; + +/** + * Installed-code provider of deployer-owned immutable X.509 bindings. + * + *

Discovery does not activate a provider. A session must explicitly enable + * {@link #providerId()} and authorize every contributed OID root. Provider + * implementations must be stateless and must not contain secrets.

+ */ +public interface X509AlgorithmBindingProvider { + + /** @return stable provider identity used by explicit configuration */ + String providerId(); + + /** @return stable version or commitment of the complete contributed set */ + String bindingSetVersion(); + + /** @return finite immutable safe descriptors */ + List bindings(); + + /** @return finite immutable role-specific encoding rules */ + List rules(); +} diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json index d14a2e8..2723726 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json @@ -1 +1 @@ -{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"email-signing","profileVersion":1,"formatId":"x509","displayName":"Email Signing","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":true,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":1,"maximumOccurrences":16}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.4"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"email-signing","profileVersion":1,"formatId":"x509","displayName":"Email Signing","maxValidity":"PT8760H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":true,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":1,"maximumOccurrences":16}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.4"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json index 18110e6..48cb31a 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json @@ -1 +1 @@ -{"schemaVersion":2,"certificateType":"INTERMEDIATE_CA","profileId":"intermediate-ca","profileVersion":1,"formatId":"x509","displayName":"Intermediate CA","maxValidity":"PT43800H","subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":0,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":3,"certificateType":"INTERMEDIATE_CA","profileId":"intermediate-ca","profileVersion":1,"formatId":"x509","displayName":"Intermediate CA","maxValidity":"PT43800H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":0,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json index 388a908..096b067 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json @@ -1 +1 @@ -{"schemaVersion":2,"certificateType":"ROOT_CA","profileId":"root-ca","profileVersion":1,"formatId":"x509","displayName":"Root CA","maxValidity":"PT87600H","subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":1,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":3,"certificateType":"ROOT_CA","profileId":"root-ca","profileVersion":1,"formatId":"x509","displayName":"Root CA","maxValidity":"PT87600H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":1,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json index 0bfb195..172cb05 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json @@ -1 +1 @@ -{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"server-tls","profileVersion":1,"formatId":"x509","displayName":"Server TLS","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"server-tls","profileVersion":1,"formatId":"x509","displayName":"Server TLS","maxValidity":"PT8760H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json index aded624..e471284 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json @@ -1 +1 @@ -{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"vpn-client","profileVersion":1,"formatId":"x509","displayName":"VPN Client","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":0,"maximumOccurrences":16},{"type":"URI","minimumOccurrences":0,"maximumOccurrences":16,"allowedSchemes":["spiffe"]}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.2"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"vpn-client","profileVersion":1,"formatId":"x509","displayName":"VPN Client","maxValidity":"PT8760H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":0,"maximumOccurrences":16},{"type":"URI","minimumOccurrences":0,"maximumOccurrences":16,"allowedSchemes":["spiffe"]}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.2"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json index 1c9a714..7636e7c 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json @@ -1 +1 @@ -{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"vpn-server","profileVersion":1,"formatId":"x509","displayName":"VPN Server","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"vpn-server","profileVersion":1,"formatId":"x509","displayName":"VPN Server","maxValidity":"PT8760H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/x509/zeroecho-private-bindings-v1.json b/pki/src/main/resources/zeroecho/pki/x509/zeroecho-private-bindings-v1.json new file mode 100644 index 0000000..1bc6441 --- /dev/null +++ b/pki/src/main/resources/zeroecho/pki/x509/zeroecho-private-bindings-v1.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "bindings": [ + { + "bindingId": "zeroecho.private.sphincs-plus-default.spki.v1", + "algorithmIdentity": "SPHINCS_PLUS_PUBLIC_KEY", + "role": "SUBJECT_PUBLIC_KEY", + "oid": "1.3.6.1.4.1.31424.1.1.1", + "encodingVersion": 1, + "parameterRule": "ABSENT", + "publicKeyEncoding": "NESTED_SPKI_DER", + "signatureEncoding": "NOT_APPLICABLE", + "interoperability": "ZEROECHO_ECOSYSTEM" + }, + { + "bindingId": "zeroecho.private.sphincs-plus-default.csr-signature.v1", + "algorithmIdentity": "SPHINCS_PLUS_SIGNATURE", + "role": "CSR_SIGNATURE", + "oid": "1.3.6.1.4.1.31424.1.1.2", + "encodingVersion": 1, + "parameterRule": "ABSENT", + "publicKeyEncoding": "NOT_APPLICABLE", + "signatureEncoding": "OPAQUE", + "interoperability": "ZEROECHO_ECOSYSTEM" + }, + { + "bindingId": "zeroecho.private.sphincs-plus-default.certificate-signature.v1", + "algorithmIdentity": "SPHINCS_PLUS_SIGNATURE", + "role": "CERTIFICATE_SIGNATURE", + "oid": "1.3.6.1.4.1.31424.1.1.3", + "encodingVersion": 1, + "parameterRule": "ABSENT", + "publicKeyEncoding": "NOT_APPLICABLE", + "signatureEncoding": "OPAQUE", + "interoperability": "ZEROECHO_ECOSYSTEM" + }, + { + "bindingId": "zeroecho.private.sphincs-plus-default.crl-signature.v1", + "algorithmIdentity": "SPHINCS_PLUS_SIGNATURE", + "role": "CRL_SIGNATURE", + "oid": "1.3.6.1.4.1.31424.1.1.4", + "encodingVersion": 1, + "parameterRule": "ABSENT", + "publicKeyEncoding": "NOT_APPLICABLE", + "signatureEncoding": "OPAQUE", + "interoperability": "ZEROECHO_ECOSYSTEM" + } + ] +} diff --git a/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java index 9294bd4..52aea3d 100644 --- a/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java +++ b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java @@ -245,10 +245,10 @@ final class BuiltInCertificateProfileCatalogTest { @Test void profileDocumentsFailClosedForInvalidSchemaFieldsEncodingAndClassMetadata() { List invalidDocuments = List.of( - replace(mainResource(SERVER), "\"schemaVersion\":2", "\"schemaVersion\":1"), - replace(mainResource(SERVER), "\"schemaVersion\":2,", "\"schemaVersion\":2,\"active\":true,"), - replace(mainResource(SERVER), "\"schemaVersion\":2,", - "\"schemaVersion\":2,\"@class\":\"" + InitializationSentinel.CLASS_NAME + "\","), + replace(mainResource(SERVER), "\"schemaVersion\":3", "\"schemaVersion\":1"), + replace(mainResource(SERVER), "\"schemaVersion\":3,", "\"schemaVersion\":3,\"active\":true,"), + replace(mainResource(SERVER), "\"schemaVersion\":3,", + "\"schemaVersion\":3,\"@class\":\"" + InitializationSentinel.CLASS_NAME + "\","), malformedUtf8(mainResource(SERVER))); for (byte[] invalid : invalidDocuments) { Map> resources = baseResources(); diff --git a/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java b/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java index eb37e45..c60718a 100644 --- a/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java +++ b/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java @@ -63,13 +63,14 @@ final class CertificateProfileDocumentCodecTest { private static final String VALID_DOCUMENT = """ { - "schemaVersion": 2, + "schemaVersion": 3, "certificateType": "END_ENTITY", "profileId": "tls-service", "profileVersion": 7, "formatId": "x509", "displayName": "TLS service", "maxValidity": "PT24H", + "algorithmBindings": {"mode":"STANDARD_ONLY"}, "subject": { "allowEmpty": false, "rules": [ @@ -138,7 +139,7 @@ final class CertificateProfileDocumentCodecTest { void parsesEverySupportedRuleShapeIntoAuthoritativeTypedPolicies() { CertificateProfileDefinition definition = parse(VALID_DOCUMENT); - assertEquals(2, CertificateProfileDefinition.SCHEMA_VERSION); + assertEquals(3, CertificateProfileDefinition.SCHEMA_VERSION); assertEquals(CertificateProfileKind.END_ENTITY, definition.certificateType()); assertEquals("tls-service", definition.profileId()); assertEquals(7, definition.profileVersion()); @@ -311,10 +312,10 @@ final class CertificateProfileDocumentCodecTest { @Test void rejectsDuplicateUnknownMissingNullWrongAndNonintegralFields() { - assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 2,", "\"schemaVersion\": 2,\"schemaVersion\": 2,"), + assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 3,", "\"schemaVersion\": 3,\"schemaVersion\": 3,"), "DUPLICATE_FIELD"); for (String document : List.of( - VALID_DOCUMENT.replace("\"schemaVersion\": 2,", "\"secret-field\": true,\"schemaVersion\": 2,"), + VALID_DOCUMENT.replace("\"schemaVersion\": 3,", "\"secret-field\": true,\"schemaVersion\": 3,"), VALID_DOCUMENT.replace("\"allowEmpty\": false,", "\"unknown\": true,\"allowEmpty\": false,"), VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\",", "\"unknown\": true,\"oid\": \"2.5.4.3\","), VALID_DOCUMENT.replace("\"minimumTotal\": 1,", "\"unknown\": true,\"minimumTotal\": 1,"), @@ -330,7 +331,7 @@ final class CertificateProfileDocumentCodecTest { "\"keyUsageCritical\": true,\"keyUsageCritical\": true,"))) { assertCode(document, "DUPLICATE_FIELD"); } - for (String document : List.of(VALID_DOCUMENT.replace("\"schemaVersion\": 2,\n", ""), + for (String document : List.of(VALID_DOCUMENT.replace("\"schemaVersion\": 3,\n", ""), VALID_DOCUMENT.replace("\"allowEmpty\": false,\n", ""), VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\",\n", ""), VALID_DOCUMENT.replace("\"minimumTotal\": 1,\n", ""), @@ -355,7 +356,7 @@ final class CertificateProfileDocumentCodecTest { @Test void rejectsUnsupportedVersionsTokensCaseWhitespaceAndNoncanonicalDuration() { - assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 2", "\"schemaVersion\": 1"), + assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 3", "\"schemaVersion\": 1"), "SCHEMA_VERSION_UNSUPPORTED"); assertCode(VALID_DOCUMENT.replace("\"certificateType\": \"END_ENTITY\"", "\"certificateType\": \"end_entity\""), "CERTIFICATE_TYPE_UNSUPPORTED"); @@ -473,8 +474,8 @@ final class CertificateProfileDocumentCodecTest { @Test void redactsHostileInputAndParserDetailsFromFailures() { String secret = "do-not-disclose-credential"; - String hostile = VALID_DOCUMENT.replace("\"schemaVersion\": 2,", - "\"" + secret + "\": true,\"schemaVersion\": 2,"); + String hostile = VALID_DOCUMENT.replace("\"schemaVersion\": 3,", + "\"" + secret + "\": true,\"schemaVersion\": 3,"); PkiException exception = assertThrows(PkiException.class, () -> parse(hostile)); @@ -508,8 +509,8 @@ final class CertificateProfileDocumentCodecTest { logger.addHandler(handler); try { for (String field : hostileFields) { - String document = VALID_DOCUMENT.replace("\"schemaVersion\": 2,", - "\"" + field + "\":\"" + probeName + "\",\"schemaVersion\": 2,"); + String document = VALID_DOCUMENT.replace("\"schemaVersion\": 3,", + "\"" + field + "\":\"" + probeName + "\",\"schemaVersion\": 3,"); PkiException exception = assertThrows(PkiException.class, () -> parse(document)); assertTrue(exception.getMessage().contains("code=UNKNOWN_FIELD ")); assertFalse(exception.getMessage().contains(field)); diff --git a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java index 3b0b748..52a594c 100644 --- a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java +++ b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java @@ -40,6 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; @@ -48,6 +49,7 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.util.Map; +import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; @@ -68,6 +70,7 @@ import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.testkit.InMemorySignatureWorkflow; +import zeroecho.pki.testkit.TestX509AlgorithmBindingProvider; class PkiSessionLifecycleTest { @@ -96,6 +99,60 @@ class PkiSessionLifecycleTest { System.out.println("...ok"); } + @Test + void explicitlyActivatesAuthorizedDeployerBindingProvider() throws Exception { + System.out.println("explicitlyActivatesAuthorizedDeployerBindingProvider"); + PkiSessionConfiguration.BindingProviderConfiguration bindings = + new PkiSessionConfiguration.BindingProviderConfiguration(TestX509AlgorithmBindingProvider.ID, + List.of(TestX509AlgorithmBindingProvider.PEN_ROOT, + TestX509AlgorithmBindingProvider.EGOTHOR_ROOT), + Optional.of("test-bindings-v1")); + PkiSessionConfiguration configuration = new PkiSessionConfiguration(3, + new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("binding-store").toString())), + new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), List.of(), List.of(bindings)); + try (PkiSession session = PkiSession.open(configuration)) { + assertTrue(session.algorithmBindings().find("test.ed25519.certificate.v1").isPresent()); + assertTrue(session.algorithmBindings().find("test.ed25519.crl.v1").isPresent()); + System.out.println("...bindings=" + session.algorithmBindings().bindings().size()); + } + System.out.println("...ok"); + } + + @Test + void profileValidationRejectsBindingOutsideActiveRegistry() throws Exception { + System.out.println("profileValidationRejectsBindingOutsideActiveRegistry"); + PkiSessionConfiguration configuration = configuration(temporaryDirectory.resolve("profile-binding-store")); + BuiltInCertificateProfileTemplate template = BuiltInCertificateProfileCatalog + .load(getClass().getClassLoader()).stream().findFirst().orElseThrow(); + String document = new String(template.canonicalJson(), StandardCharsets.UTF_8).replace( + "\"algorithmBindings\":{\"mode\":\"STANDARD_ONLY\"}", + "\"algorithmBindings\":{\"mode\":\"EXPLICIT\",\"certificateSignature\":" + + "{\"bindingId\":\"deployer.inactive.certificate.v1\"," + + "\"commitment\":\"inactive-binding-commitment\"}}"); + try (PkiSession session = PkiSession.open(configuration)) { + PkiOperationOutcome outcome = session.operations().execute( + new PkiOperation.ValidateProfile(document.getBytes(StandardCharsets.UTF_8)), + CancellationSignal.NONE); + PkiOperationOutcome.Failure failure = assertInstanceOf(PkiOperationOutcome.Failure.class, outcome); + System.out.println("...classification=" + failure.classification()); + assertEquals(PkiOperationFailure.POLICY_REJECTION, failure.classification()); + } + System.out.println("...ok"); + } + + @Test + void rejectsUnauthorizedDeployerBindingRootBeforeStoreAllocation() { + System.out.println("rejectsUnauthorizedDeployerBindingRootBeforeStoreAllocation"); + PkiSessionConfiguration.BindingProviderConfiguration bindings = + new PkiSessionConfiguration.BindingProviderConfiguration(TestX509AlgorithmBindingProvider.ID, + List.of(TestX509AlgorithmBindingProvider.PEN_ROOT), Optional.of("test-bindings-v1")); + PkiSessionConfiguration configuration = new PkiSessionConfiguration(3, + new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("unused-binding").toString())), + new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), List.of(), List.of(bindings)); + assertThrows(IllegalArgumentException.class, () -> PkiSession.open(configuration)); + System.out.println("...ok"); + } + @Test void validatesBeforeAllocationAndCleansPartialConstruction() { System.out.println("validatesBeforeAllocationAndCleansPartialConstruction"); @@ -142,7 +199,7 @@ class PkiSessionLifecycleTest { placeholder, new ProviderConfig("x509-bc", Map.of()), temporaryDirectory.resolve("signing-bus").toString(), "RSA-2048-PSS-SHA256", Duration.ofSeconds(5), Optional.empty()); - PkiSessionConfiguration configuration = new PkiSessionConfiguration(2, + PkiSessionConfiguration configuration = new PkiSessionConfiguration(3, new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("store-signing").toString())), new ProviderConfig("memory", Map.of("size", "16")), Optional.of(signing), java.util.List.of()); IllegalStateException failure = assertThrows(IllegalStateException.class, @@ -159,7 +216,7 @@ class PkiSessionLifecycleTest { System.out.println("opensExplicitPublisherCapabilityWithoutSigning"); ProviderConfig publisher = new ProviderConfig("filesystem", Map.of("root", temporaryDirectory.resolve("published").toString(), "targetId", "local-crls")); - PkiSessionConfiguration configuration = new PkiSessionConfiguration(2, + PkiSessionConfiguration configuration = new PkiSessionConfiguration(3, new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("store-publisher").toString())), new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), java.util.List.of(publisher)); try (PkiSession session = PkiSession.open(configuration)) { @@ -174,7 +231,7 @@ class PkiSessionLifecycleTest { void rejectsUnknownPublisherBeforeStoreAllocation() { System.out.println("rejectsUnknownPublisherBeforeStoreAllocation"); Path storeRoot = temporaryDirectory.resolve("must-not-open"); - PkiSessionConfiguration configuration = new PkiSessionConfiguration(2, + PkiSessionConfiguration configuration = new PkiSessionConfiguration(3, new ProviderConfig("fs", Map.of("root", storeRoot.toString())), new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), java.util.List.of(new ProviderConfig("unknown-publisher", Map.of()))); @@ -197,7 +254,7 @@ class PkiSessionLifecycleTest { placeholder, new ProviderConfig("x509-bc", Map.of()), temporaryDirectory.resolve("composed-signing-bus").toString(), "SHA256withRSA", Duration.ofSeconds(5), Optional.empty()); - PkiSessionConfiguration configuration = new PkiSessionConfiguration(2, + PkiSessionConfiguration configuration = new PkiSessionConfiguration(3, new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("composed-store").toString())), new ProviderConfig("memory", Map.of("size", "16")), Optional.of(signing), java.util.List.of()); DefaultPkiSession.Bootstrap bootstrap = signingBootstrap(workflow); diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java index 5233fc9..b8376ec 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java @@ -46,6 +46,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.List; +import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutionException; @@ -60,13 +61,22 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; +import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.CertificateProfileDefinition; +import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.profile.X509AlgorithmBindingPolicy; +import zeroecho.pki.impl.framework.x509.ZeroEchoPrivateX509Bindings; +import zeroecho.pki.impl.framework.x509.ImmutableX509AlgorithmBindingRegistry; +import zeroecho.pki.impl.framework.x509.StandardX509Bindings; import zeroecho.pki.impl.audit.InMemoryAuditSink; import zeroecho.pki.impl.fs.FilesystemPkiStore; import zeroecho.pki.impl.fs.FsPkiStoreOptions; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.testkit.TestX509AlgorithmBindingProvider; /** * Focused persisted profile lifecycle tests. @@ -217,6 +227,68 @@ final class DefaultProfileServiceTest { } } + @Test + void explicitPrivateBindingIsPinnedAndChangedCommitmentFailsClosed(@TempDir Path directory) throws Exception { + System.out.println("explicitPrivateBindingIsPinnedAndChangedCommitmentFailsClosed"); + CertificateProfileDefinition base = builtIn("server-tls").definition(); + zeroecho.pki.api.algorithm.X509AlgorithmBinding binding = ZeroEchoPrivateX509Bindings.descriptors().stream() + .filter(candidate -> candidate.role() + == zeroecho.pki.api.algorithm.X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE) + .findFirst().orElseThrow(); + X509AlgorithmBindingPolicy.BindingReference reference = new X509AlgorithmBindingPolicy.BindingReference( + binding.bindingId(), binding.semanticCommitment()); + X509AlgorithmBindingPolicy policy = new X509AlgorithmBindingPolicy(X509AlgorithmBindingPolicy.Mode.EXPLICIT, + Optional.empty(), Optional.empty(), Optional.of(reference), Optional.empty()); + CertificateProfileDefinition explicit = new CertificateProfileDefinition(base.certificateType(), + "server-tls-private", base.profileVersion(), base.formatId(), "Private server TLS", + base.maximumValidity(), base.subjectPolicy(), base.certificatePolicy(), policy); + try (FilesystemPkiStore store = new FilesystemPkiStore(directory.resolve("store"), FsPkiStoreOptions.defaults(), + CLOCK)) { + DefaultProfileService service = service(store); + service.importProfile(CertificateProfileDocumentCodec.writeCanonical(explicit)); + X509AlgorithmBindingPolicy changed = new X509AlgorithmBindingPolicy(X509AlgorithmBindingPolicy.Mode.EXPLICIT, + Optional.empty(), Optional.empty(), Optional.of(new X509AlgorithmBindingPolicy.BindingReference( + binding.bindingId(), binding.semanticCommitment() + "-changed")), Optional.empty()); + CertificateProfileDefinition invalid = new CertificateProfileDefinition(base.certificateType(), + "server-tls-changed", base.profileVersion(), base.formatId(), "Changed server TLS", + base.maximumValidity(), base.subjectPolicy(), base.certificatePolicy(), changed); + assertThrows(PkiException.class, + () -> service.importProfile(CertificateProfileDocumentCodec.writeCanonical(invalid))); + } + System.out.println("...binding=" + binding.bindingId()); + System.out.println("...ok"); + } + + @Test + void persistedDeployerBindingRequiresIdenticalRegistryAfterReopen(@TempDir Path directory) throws Exception { + System.out.println("persistedDeployerBindingRequiresIdenticalRegistryAfterReopen"); + List descriptors = new java.util.ArrayList<>(StandardX509Bindings.descriptors()); + descriptors.addAll(ZeroEchoPrivateX509Bindings.descriptors()); + descriptors.addAll(new TestX509AlgorithmBindingProvider().bindings()); + X509AlgorithmBindingRegistry deployerRegistry = ImmutableX509AlgorithmBindingRegistry.create(descriptors); + X509AlgorithmBinding binding = deployerRegistry.find("test.ed25519.certificate.v1").orElseThrow(); + CertificateProfileDefinition base = builtIn("server-tls").definition(); + X509AlgorithmBindingPolicy policy = new X509AlgorithmBindingPolicy(X509AlgorithmBindingPolicy.Mode.EXPLICIT, + Optional.empty(), Optional.empty(), Optional.of(new X509AlgorithmBindingPolicy.BindingReference( + binding.bindingId(), binding.semanticCommitment())), Optional.empty()); + CertificateProfileDefinition explicit = new CertificateProfileDefinition(base.certificateType(), + "server-tls-deployer", base.profileVersion(), base.formatId(), "Deployer server TLS", + base.maximumValidity(), base.subjectPolicy(), base.certificatePolicy(), policy); + Path root = directory.resolve("store"); + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), CLOCK)) { + DefaultProfileService service = service(store, deployerRegistry); + service.importProfile(CertificateProfileDocumentCodec.writeCanonical(explicit)); + service.activateProfile(explicit.profileId(), explicit.profileVersion()); + } + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), CLOCK)) { + assertEquals(explicit.profileId(), service(store, deployerRegistry) + .requireActiveProfile(explicit.profileId()).reference().profileId()); + assertThrows(PkiException.class, () -> service(store).requireActiveProfile(explicit.profileId())); + } + System.out.println("...binding=" + binding.bindingId()); + System.out.println("...ok"); + } + @Test void lifecycleOperationsSanitizeStoreDiagnosticsAndAuditCodes() { String sentinel = "unsafe-profile-store-sentinel"; @@ -248,6 +320,11 @@ final class DefaultProfileServiceTest { return new DefaultProfileService(store, CLOCK, new InMemoryAuditSink()); } + private static DefaultProfileService service(FilesystemPkiStore store, + X509AlgorithmBindingRegistry algorithmBindings) { + return new DefaultProfileService(store, CLOCK, new InMemoryAuditSink(), algorithmBindings); + } + private static BuiltInCertificateProfileTemplate builtIn(String profileId) { return BuiltInCertificateProfileCatalog.load(DefaultProfileServiceTest.class.getClassLoader()).stream() .filter(template -> profileId.equals(template.definition().profileId())).findFirst().orElseThrow(); diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java index 4e3dbf7..ccb2e81 100644 --- a/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java @@ -38,22 +38,59 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.Signature; +import java.security.Security; +import java.security.PrivateKey; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigInteger; +import java.time.Instant; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.DERNull; import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509CRLHolder; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v2CRLBuilder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider; import org.junit.jupiter.api.Test; import zeroecho.core.alg.BootstrapAlgorithmIdentities; import zeroecho.core.spec.AlgorithmIdentity; import zeroecho.core.io.ImmutableByteContent; +import zeroecho.core.io.RepeatableContent; +import zeroecho.core.alg.sphincsplus.SphincsPlusKeyGenSpec; +import zeroecho.sdk.ZeroEchoSession; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.issuance.VerificationPolicy; +import zeroecho.pki.api.request.CertificationRequest; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.ProofOfPossessionStatus; +import zeroecho.pki.impl.framework.x509.bc.BcX509CertificationRequestParser; +import zeroecho.pki.impl.framework.x509.bc.BcX509ProofOfPossessionVerifier; +import zeroecho.pki.impl.framework.x509.bc.BcX509SignedObjectValidator; +import zeroecho.pki.impl.framework.x509.bc.BcX509PublicKeyAdapter; +import zeroecho.pki.testkit.TestX509AlgorithmBindingProvider; import zeroecho.core.spec.AlgorithmSuite; import zeroecho.core.spi.AlgorithmExecutionCapability; import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider; @@ -65,6 +102,136 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor; */ public final class X509BindingPhaseATest { + @Test + void zeroEchoPrivateAssignmentsAreExactAndLocked() { + System.out.println("zeroEchoPrivateAssignmentsAreExactAndLocked"); + List bindings = ZeroEchoPrivateX509Bindings.descriptors(); + assertEquals(List.of("1.3.6.1.4.1.31424.1.1.1", "1.3.6.1.4.1.31424.1.1.2", + "1.3.6.1.4.1.31424.1.1.3", "1.3.6.1.4.1.31424.1.1.4"), + bindings.stream().map(X509AlgorithmBinding::oid).toList()); + assertEquals(4, bindings.stream().map(X509AlgorithmBinding::bindingId).distinct().count()); + assertThrows(UnsupportedOperationException.class, () -> bindings.add(bindings.get(0))); + assertThrows(IllegalArgumentException.class, + () -> ImmutableX509AlgorithmBindingRegistry.create(List.of(bindings.get(0), bindings.get(0)))); + System.out.println("...assignment-count=" + bindings.size()); + System.out.println("...ok"); + } + + @Test + void productionSphincsPrivateBindingSignsAndVerifies() throws Exception { + System.out.println("productionSphincsPrivateBindingSignsAndVerifies"); + Security.addProvider(new BouncyCastlePQCProvider()); + KeyPair keyPair = new ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("SPHINCS+", + SphincsPlusKeyGenSpec.defaultSpec()); + BcX509VerificationExecutor verifier = new BcX509VerificationExecutor(); + X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(), List.of(verifier), + List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID, + AlgorithmExecutionCapability.Direction.VERIFY, verifier)), policy(true, "private-test-v1")); + String signatureBinding = "zeroecho.private.sphincs-plus-default.certificate-signature.v1"; + String publicKeyBinding = "zeroecho.private.sphincs-plus-default.spki.v1"; + X509ExecutionPlan plan = authority.plan( + BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE, + BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.VERIFY, Optional.empty(), "explicit", signatureBinding, + BcX509VerificationExecutor.class); + SubjectPublicKeyInfo wrapped = new BcX509PublicKeyAdapter(authority.bindings()).wrap( + keyPair.getPublic().getEncoded(), BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY, + publicKeyBinding); + AlgorithmIdentifier identifier = BcX509AlgorithmAdapter.toBc(authority.bindings().resolve(signatureBinding, + BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE, X509AlgorithmRole.SIGNATURE_ALGORITHM)); + byte[] payload = "ZeroEcho private X.509 binding".getBytes(java.nio.charset.StandardCharsets.UTF_8); + Signature signer = Signature.getInstance("SPHINCS+", new BouncyCastlePQCProvider()); + signer.initSign(keyPair.getPrivate()); + signer.update(payload); + byte[] signature = signer.sign(); + try (RepeatableContent content = content(payload)) { + assertTrue(verifier.verify(authority, plan, wrapped, identifier, content, signature)); + } + + AlgorithmIdentifier csrIdentifier = privateIdentifier(authority, + "zeroecho.private.sphincs-plus-default.csr-signature.v1"); + PKCS10CertificationRequest csr = new PKCS10CertificationRequestBuilder(new X500Name("CN=Private Request"), + wrapped).build(contentSigner(keyPair.getPrivate(), csrIdentifier)); + ParsedCertificationRequest parsed = new BcX509CertificationRequestParser().parse(new CertificationRequest( + new FormatId("x509"), new EncodedObject(Encoding.DER, csr.getEncoded()))); + assertEquals(ProofOfPossessionStatus.VERIFIED, + new BcX509ProofOfPossessionVerifier(authority, verifier) + .verify(parsed, new VerificationPolicy(true, Optional.empty())).status()); + + Instant notBefore = Instant.parse("2026-08-04T00:00:00Z"); + X500Name issuer = new X500Name("CN=Private Root"); + X509CertificateHolder certificate = new X509v3CertificateBuilder(issuer, BigInteger.ONE, + Date.from(notBefore), Date.from(notBefore.plusSeconds(3600)), issuer, wrapped) + .build(contentSigner(keyPair.getPrivate(), identifier)); + try (RepeatableContent certificateContent = content(certificate.getEncoded())) { + BcX509SignedObjectValidator.CertificateBindings validated = new BcX509SignedObjectValidator(authority) + .validateCertificate(certificateContent, + Optional.of(BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE), + zeroecho.core.io.CancellationSignal.NONE); + assertEquals(BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY, validated.subjectPublicKey()); + } + try (RepeatableContent tbs = content(certificate.toASN1Structure().getTBSCertificate().getEncoded())) { + assertTrue(verifier.verify(authority, plan, wrapped, identifier, tbs, certificate.getSignature())); + } + + AlgorithmIdentifier crlIdentifier = privateIdentifier(authority, + "zeroecho.private.sphincs-plus-default.crl-signature.v1"); + X509CRLHolder crl = new X509v2CRLBuilder(issuer, Date.from(notBefore)) + .setNextUpdate(Date.from(notBefore.plusSeconds(1800))) + .build(contentSigner(keyPair.getPrivate(), crlIdentifier)); + X509ExecutionPlan crlPlan = authority.plan( + BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE, + BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.VERIFY, Optional.empty(), "explicit", + "zeroecho.private.sphincs-plus-default.crl-signature.v1", BcX509VerificationExecutor.class); + try (RepeatableContent tbs = content(crl.toASN1Structure().getTBSCertList().getEncoded())) { + assertTrue(verifier.verify(authority, crlPlan, wrapped, crlIdentifier, tbs, + crl.toASN1Structure().getSignature().getOctets())); + } + assertThrows(IllegalArgumentException.class, + () -> new BcX509AlgorithmAdapter(authority.bindings()).decode( + new AlgorithmIdentifier(identifier.getAlgorithm(), DERNull.INSTANCE), + X509AlgorithmRole.SIGNATURE_ALGORITHM)); + System.out.println("...oid=" + identifier.getAlgorithm().getId() + ", signature-bytes=" + signature.length); + System.out.println("...ok"); + } + + @Test + void deployerProviderBindingSignsAndVerifiesEndToEnd() throws Exception { + System.out.println("deployerProviderBindingSignsAndVerifiesEndToEnd"); + TestX509AlgorithmBindingProvider deployer = new TestX509AlgorithmBindingProvider(); + X509BindingRuleProvider provider = new X509BindingRuleProvider() { + @Override + public List rules() { + return deployer.rules(); + } + }; + BcX509VerificationExecutor verifier = new BcX509VerificationExecutor(); + X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(provider), List.of(verifier), + List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID, + AlgorithmExecutionCapability.Direction.VERIFY, verifier)), policy(true, "deployer-test-v1")); + KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519"); + KeyPair pair = generator.generateKeyPair(); + SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(pair.getPublic().getEncoded()); + String bindingId = "test.ed25519.certificate.v1"; + AlgorithmIdentifier identifier = BcX509AlgorithmAdapter.toBc(authority.bindings().resolve(bindingId, + BootstrapAlgorithmIdentities.ED25519_SIGNATURE, X509AlgorithmRole.SIGNATURE_ALGORITHM)); + X500Name name = new X500Name("CN=Deployer Binding"); + Instant start = Instant.parse("2026-08-04T00:00:00Z"); + X509CertificateHolder certificate = new X509v3CertificateBuilder(name, BigInteger.TWO, Date.from(start), + Date.from(start.plusSeconds(3600)), name, spki) + .build(contentSigner(pair.getPrivate(), identifier, "Ed25519")); + X509ExecutionPlan plan = authority.plan( + BootstrapAlgorithmIdentities.ED25519_SIGNATURE, BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.VERIFY, Optional.empty(), "explicit", bindingId, + BcX509VerificationExecutor.class); + try (RepeatableContent tbs = content(certificate.toASN1Structure().getTBSCertificate().getEncoded())) { + assertTrue(verifier.verify(authority, plan, spki, identifier, tbs, certificate.getSignature())); + } + System.out.println("...oid=" + identifier.getAlgorithm().getId()); + System.out.println("...ok"); + } + @Test void bootstrapMatrixIsExactAndSymmetric() { System.out.println("bootstrapMatrixIsExactAndSymmetric"); @@ -374,6 +541,71 @@ public final class X509BindingPhaseATest { }; } + private static RepeatableContent content(byte[] bytes) { + byte[] snapshot = bytes.clone(); + return new RepeatableContent() { + @Override + public ByteArrayInputStream openStream() { + return new ByteArrayInputStream(snapshot); + } + + @Override + public OptionalLong length() { + return OptionalLong.of(snapshot.length); + } + + @Override + public String contentId() { + return "private-binding-test-content"; + } + + @Override + public void close() throws IOException { + // immutable in-memory test content owns no external resource + } + }; + } + + private static AlgorithmIdentifier privateIdentifier(X509AuthoritySnapshot authority, String bindingId) { + return BcX509AlgorithmAdapter.toBc(authority.bindings().resolve(bindingId, + BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE, X509AlgorithmRole.SIGNATURE_ALGORITHM)); + } + + private static ContentSigner contentSigner(PrivateKey privateKey, AlgorithmIdentifier identifier) { + return contentSigner(privateKey, identifier, "SPHINCS+"); + } + + private static ContentSigner contentSigner(PrivateKey privateKey, AlgorithmIdentifier identifier, + String jcaAlgorithm) { + return new ContentSigner() { + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + + @Override + public AlgorithmIdentifier getAlgorithmIdentifier() { + return identifier; + } + + @Override + public OutputStream getOutputStream() { + return output; + } + + @Override + public byte[] getSignature() { + try { + Signature signature = "SPHINCS+".equals(jcaAlgorithm) + ? Signature.getInstance(jcaAlgorithm, new BouncyCastlePQCProvider()) + : Signature.getInstance(jcaAlgorithm); + signature.initSign(privateKey); + signature.update(output.toByteArray()); + return signature.sign(); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException("Test signing failed", failure); + } + } + }; + } + /** * Test-only conflicting extension rule. */ diff --git a/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java b/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java index 8112cde..e3c41a1 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java +++ b/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java @@ -229,8 +229,9 @@ public final class H7ProfileDocuments { List sanRules, boolean noKeyEncipherment, List eku) { String keyUsage = noKeyEncipherment ? "[\"DIGITAL_SIGNATURE\"]" : "[\"DIGITAL_SIGNATURE\",\"KEY_ENCIPHERMENT\"]"; - String json = "{\"schemaVersion\":2,\"certificateType\":\"END_ENTITY\",\"profileId\":\"" + id + String json = "{\"schemaVersion\":3,\"certificateType\":\"END_ENTITY\",\"profileId\":\"" + id + "\",\"profileVersion\":1,\"formatId\":\"" + formatId + "\",\"displayName\":\"H7 Test Profile\"," + + "\"algorithmBindings\":{\"mode\":\"STANDARD_ONLY\"}," + "\"maxValidity\":\"PT8760H\",\"subject\":{\"allowEmpty\":" + allowEmpty + ",\"rules\":[" + String.join(",", subjectRules) + "]}," + "\"subjectAlternativeNames\":{\"minimumTotal\":" + minimumTotal + ",\"maximumTotal\":" + maximumTotal + ",\"serviceIdentityRequired\":" + serviceIdentity diff --git a/pki/src/test/java/zeroecho/pki/testkit/TestX509AlgorithmBindingProvider.java b/pki/src/test/java/zeroecho/pki/testkit/TestX509AlgorithmBindingProvider.java new file mode 100644 index 0000000..f80b0f5 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/testkit/TestX509AlgorithmBindingProvider.java @@ -0,0 +1,142 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.testkit; + +import java.util.List; +import java.util.Optional; + +import zeroecho.core.alg.BootstrapAlgorithmIdentities; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.pki.api.algorithm.X509AlgorithmBinding; +import zeroecho.pki.impl.framework.x509.X509AlgorithmIdentifier; +import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; +import zeroecho.pki.impl.framework.x509.X509BindingRule; +import zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider; + +/** Deterministic deployer binding provider used only by service-loading tests. */ +public final class TestX509AlgorithmBindingProvider implements X509AlgorithmBindingProvider { + /** Stable provider identity. */ + public static final String ID = "test.deployer.bindings"; + /** Deployer-owned PEN test root. */ + public static final String PEN_ROOT = "1.3.6.1.4.1.55555.42"; + /** Egothor deployment-local test root. */ + public static final String EGOTHOR_ROOT = "1.3.6.1.4.1.31424.1.2.4242"; + + private static final X509AlgorithmBinding CERTIFICATE = binding("test.ed25519.certificate.v1", + X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE, PEN_ROOT + ".1"); + private static final X509AlgorithmBinding CRL = binding("test.ed25519.crl.v1", + X509AlgorithmBinding.Role.CRL_SIGNATURE, EGOTHOR_ROOT + ".1"); + + @Override + public String providerId() { + return ID; + } + + @Override + public String bindingSetVersion() { + return "test-bindings-v1"; + } + + @Override + public List bindings() { + return List.of(CERTIFICATE, CRL); + } + + @Override + public List rules() { + return List.of(new Rule(CERTIFICATE), new Rule(CRL)); + } + + private static X509AlgorithmBinding binding(String id, X509AlgorithmBinding.Role role, String oid) { + return new X509AlgorithmBinding(id, BootstrapAlgorithmIdentities.ED25519_SIGNATURE, role, oid, + X509AlgorithmBinding.Origin.DEPLOYER_PRIVATE, 1, X509AlgorithmBinding.ParameterRule.ABSENT, + X509AlgorithmBinding.PublicKeyEncoding.NOT_APPLICABLE, + X509AlgorithmBinding.SignatureEncoding.OPAQUE, + X509AlgorithmBinding.Interoperability.DEPLOYER_ECOSYSTEM, Optional.of(ID), id + "|v1"); + } + + private record Rule(X509AlgorithmBinding binding) implements X509BindingRule { + @Override + public String id() { + return binding.bindingId(); + } + + @Override + public X509AlgorithmRole role() { + return X509AlgorithmRole.SIGNATURE_ALGORITHM; + } + + @Override + public String oid() { + return binding.oid(); + } + + @Override + public String semanticFingerprint() { + return binding.semanticCommitment(); + } + + @Override + public SignatureEncoding signatureEncoding() { + return SignatureEncoding.OPAQUE; + } + + @Override + public PublicKeyEncoding publicKeyEncoding() { + return PublicKeyEncoding.NOT_APPLICABLE; + } + + @Override + public List bindings() { + return List.of(binding); + } + + @Override + public Optional encode(AlgorithmIdentity identity) { + return binding.algorithmIdentity().equals(identity) + ? Optional.of(X509AlgorithmIdentifier.absent(binding.oid())) : Optional.empty(); + } + + @Override + public Optional decode(X509AlgorithmIdentifier identifier) { + if (!binding.oid().equals(identifier.oid())) { + return Optional.empty(); + } + if (identifier.parameterForm() != X509AlgorithmIdentifier.ParameterForm.ABSENT) { + throw new IllegalArgumentException("Test binding parameters must be absent"); + } + return Optional.of(binding.algorithmIdentity()); + } + } +} diff --git a/pki/src/test/resources/META-INF/services/zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider b/pki/src/test/resources/META-INF/services/zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider new file mode 100644 index 0000000..ecc38bc --- /dev/null +++ b/pki/src/test/resources/META-INF/services/zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider @@ -0,0 +1 @@ +zeroecho.pki.testkit.TestX509AlgorithmBindingProvider