diff --git a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java index ddee736..935432b 100644 --- a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java +++ b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java @@ -459,6 +459,11 @@ class PkiCliTest { return (operation, cancellation) -> outcome(operation); } + @Override + public zeroecho.pki.application.PkiRepository repository() { + throw new UnsupportedOperationException(); + } + @Override public void close() throws Exception { closes.incrementAndGet(); diff --git a/docs/pki-server-loopback-example.json b/docs/pki-server-loopback-example.json index 5232d97..ffdb177 100644 --- a/docs/pki-server-loopback-example.json +++ b/docs/pki-server-loopback-example.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "serverName": "zeroecho-admin", "realm": { "realmId": "production", @@ -74,5 +74,45 @@ "gracefulShutdownMillis": 30000, "forcedShutdownMillis": 10000 }, + "publicListener": { + "enabled": true, + "address": "127.0.0.1", + "port": 8444, + "tlsProvider": { + "id": "jsse-pkcs12", + "properties": { + "keyStore": "tls/public-server-identity.p12", + "keyStorePasswordEnvironment": "ZEROECHO_PUBLIC_TLS_KEYSTORE_PASSWORD", + "trustStore": "tls/repository-clients-trust.p12", + "trustStorePasswordEnvironment": "ZEROECHO_PUBLIC_TLS_TRUSTSTORE_PASSWORD" + } + }, + "allowPlaintextLoopback": false, + "authentication": { + "mode": "DIRECT_MTLS", + "directClientMappings": [{ + "mappingId": "repository-client", + "principalId": "repository-client", + "subjectPublicKeyInfoSha256": "3333333333333333333333333333333333333333333333333333333333333333" + }] + }, + "maximumHeaderBytes": 32768, + "maximumBodyBytes": 1024, + "execution": { + "transportWorkers": 4, + "transportQueueCapacity": 32, + "operationWorkers": 4, + "operationQueueCapacity": 32, + "maximumAdmittedRequests": 32, + "defaultDeadlineMillis": 30000, + "maximumDeadlineMillis": 120000, + "gracefulShutdownMillis": 30000, + "forcedShutdownMillis": 10000 + }, + "maximumStreamDurationMillis": 120000, + "publicImmutableCacheMillis": 86400000, + "publicAliasCacheMillis": 300000, + "authorityListExposed": true + }, "runtime": {} } diff --git a/docs/pki-server-production-example.json b/docs/pki-server-production-example.json index fce14cc..f8b8aac 100644 --- a/docs/pki-server-production-example.json +++ b/docs/pki-server-production-example.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "serverName": "zeroecho-admin", "realm": { "realmId": "production", @@ -74,5 +74,6 @@ "gracefulShutdownMillis": 30000, "forcedShutdownMillis": 10000 }, + "publicListener": {"enabled": false}, "runtime": {} } diff --git a/docs/pki-server-public-repository.md b/docs/pki-server-public-repository.md new file mode 100644 index 0000000..465dd58 --- /dev/null +++ b/docs/pki-server-public-repository.md @@ -0,0 +1,93 @@ +# ZeroEcho public PKI repository + +The public repository is an optional listener that is configured independently +from the administrative HTTPS listener. Both listeners use the same realm and +long-lived PKI session, but they have separate ports, TLS policy, worker pools, +queues and admission limits. Administrative routes are never registered on the +public listener, and public routes are never registered on the administrative +listener. + +Non-loopback public service requires TLS. Explicit loopback plaintext is intended +only for deterministic development tests. A direct TLS listener requests an +optional client certificate: an absent certificate is anonymous, while a supplied +invalid or unmapped certificate fails authentication. A trusted-reverse-proxy +listener requires mutual TLS on the proxy-to-ZeroEcho hop. Its transport principal +must have only `FORWARD_AUTHENTICATED_CLIENT_IDENTITY`; an absent forwarded client +identity remains anonymous, and an invalid forwarded identity never falls back to +anonymous. RFC 9440 is preferred. The explicitly selected NGINX escaped-PEM format +uses the same strict validation described in the administrative-server guide. + +## Disclosure and retrieval + +`PUBLIC` objects are anonymously retrievable. `AUTHENTICATED`, `OWNER_ONLY` and +`RESTRICTED` objects require the corresponding end-client identity and scope. +`NOT_PUBLISHED` objects are unavailable. `PUBLIC_UNLISTED` retrieval uses exactly: + +```text +Authorization: Bearer +``` + +Capabilities are never accepted in a query, cookie, form body or alternate +header. They are bound to one realm, object, action and expiry. Capability +responses are private and non-cacheable. An object ID is not authentication +authority, and the repository intentionally provides no leaf-certificate search +or listing endpoint. + +Single certificates and CRLs use canonical DER with `application/pkix-cert` and +`application/pkix-crl`. Explicit chain paths and the stable chain route return +deterministic JSON containing the ordered certificate retrieval links and the +path commitment; no ambiguous concatenated DER representation is defined. + +Immutable public objects use strong ETags and the configured immutable cache +duration. Stable alias routes use both alias and target commitments and a shorter +configured cache duration. Authenticated responses are private and non-cacheable. +`HEAD` performs the same authorization and integrity checks as `GET`, and +`If-None-Match` returns `304` only after access has been re-evaluated. + +The versioned route set is deliberately finite: + +```text +GET|HEAD /public/v1/authorities +GET|HEAD /public/v1/authorities/{authorityId} +GET|HEAD /public/v1/authorities/{authorityId}/issuers/{issuerId}/certificate +GET|HEAD /public/v1/authorities/{authorityId}/issuers/{issuerId}/paths/{pathId} +GET|HEAD /public/v1/authorities/{authorityId}/chain +GET|HEAD /public/v1/authorities/{authorityId}/crl +GET|HEAD /public/v1/certificates/{credentialId} +GET|HEAD /public/v1/status/{statusObjectId} +``` + +Authority listing uses bounded keyset pagination through `after` and `limit`. +No leaf-certificate collection or search route exists. Explicit chain paths are +never inferred from certificate dates, filenames, local trust stores, or list +order. + +## Stable repository aliases + +Issuer generations and chain paths are PKI authority. Repository aliases are +separate operational metadata. `CURRENT_CRL` points to one exact public CRL status +object. `CURRENT_CHAIN` points to one exact issuer generation and immutable chain +path. Updates require expected-current conflict protection and never alter the +target object, issuance selection, revocation state or disclosure policy. + +A typical administrative workflow is: + +```text +status.generate +→ inspect the status object +→ security.disclosure.set to PUBLIC +→ repository.alias.set with type CURRENT_CRL and expected-current commitment +→ GET /public/v1/authorities/{authorityId}/crl +``` + +Chain publication is similarly explicit: validate or register the backend chain +path, make every path certificate public, then set `CURRENT_CHAIN`. Generating a +CRL or rotating an issuer never changes a public alias automatically. + +The direct public-listener example is in +`docs/pki-server-loopback-example.json`. The RFC 9440 trusted-proxy example is in +`docs/pki-server-trusted-proxy-rfc9440-example.json`. The production and NGINX +administrative examples explicitly disable the public listener with +`"publicListener":{"enabled":false}`. A disabled section rejects every ignored +listener field; enabling requires the complete independent transport, +authentication, capacity, deadline, and cache configuration. diff --git a/docs/pki-server-trusted-proxy-nginx-example.json b/docs/pki-server-trusted-proxy-nginx-example.json index 7b387bd..67549ea 100644 --- a/docs/pki-server-trusted-proxy-nginx-example.json +++ b/docs/pki-server-trusted-proxy-nginx-example.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "serverName": "zeroecho-admin-nginx", "realm": { "realmId": "production", @@ -40,5 +40,6 @@ "maximumForwardedChainBytes": 524288 }, "execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000}, + "publicListener": {"enabled":false}, "runtime": {} } diff --git a/docs/pki-server-trusted-proxy-rfc9440-example.json b/docs/pki-server-trusted-proxy-rfc9440-example.json index 450a825..e4f3399 100644 --- a/docs/pki-server-trusted-proxy-rfc9440-example.json +++ b/docs/pki-server-trusted-proxy-rfc9440-example.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "serverName": "zeroecho-admin-proxy", "realm": { "realmId": "production", @@ -41,5 +41,31 @@ "maximumForwardedChainBytes": 524288 }, "execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000}, + "publicListener": { + "enabled":true, + "address":"127.0.0.1", + "port":8444, + "tlsProvider":{"id":"jsse-pkcs12","properties":{"keyStore":"tls/public-server-identity.p12","keyStorePasswordEnvironment":"ZEROECHO_PUBLIC_TLS_KEYSTORE_PASSWORD","trustStore":"tls/proxy-transport-trust.p12","trustStorePasswordEnvironment":"ZEROECHO_PROXY_TRUSTSTORE_PASSWORD"}}, + "allowPlaintextLoopback":false, + "authentication": { + "mode":"TRUSTED_REVERSE_PROXY", + "proxyTransportMappings":[{"mappingId":"public-proxy-transport","principalId":"trusted-proxy","certificateSha256":"1111111111111111111111111111111111111111111111111111111111111111"}], + "forwardedClientMappings":[{"mappingId":"public-forwarded-client","principalId":"repository-client","subjectPublicKeyInfoSha256":"3333333333333333333333333333333333333333333333333333333333333333"}], + "trustedProxyPrincipalIds":["trusted-proxy"], + "forwardedCertificateFormat":"RFC9440", + "forwardedCertificateHeaderName":"Client-Cert", + "forwardedCertificateChainHeaderName":"Client-Cert-Chain", + "administrativeClientTrust":{"id":"jsse-pkcs12-client-trust","properties":{"trustStore":"tls/repository-clients-trust.p12","trustStorePasswordEnvironment":"ZEROECHO_PUBLIC_CLIENT_TRUSTSTORE_PASSWORD"}}, + "maximumForwardedCertificateBytes":65536, + "maximumForwardedChainBytes":524288 + }, + "maximumHeaderBytes":65536, + "maximumBodyBytes":1024, + "execution":{"transportWorkers":4,"transportQueueCapacity":32,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":32,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000}, + "maximumStreamDurationMillis":120000, + "publicImmutableCacheMillis":86400000, + "publicAliasCacheMillis":300000, + "authorityListExposed":true + }, "runtime": {} } diff --git a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java index 4b46d82..60d4a53 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java +++ b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java @@ -49,7 +49,8 @@ import zeroecho.pki.api.PkiId; /** Durable disclosure authority and capability-based retrieval decision service. */ @SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition", - "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" }) + "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel", + "PMD.CyclomaticComplexity" }) public final class DisclosureService { /** Durable one-time delivery classification without retaining the raw token. */ public enum DeliveryState { @@ -267,18 +268,56 @@ public final class DisclosureService { return switch (record.policy()) { case PUBLIC -> Decision.ALLOWED; case PUBLIC_UNLISTED -> capabilityToken.filter(token -> validCapability(record.objectId(), token)) - .isPresent() ? Decision.ALLOWED : Decision.DENIED; + .isPresent() || principal.isPresent() && (ownerRelationship || explicitAdministrativePermission) + ? Decision.ALLOWED : Decision.DENIED; case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled) .filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC) .map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED); case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED; case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED; - case NOT_PUBLISHED -> explicitAdministrativePermission || ownerRelationship - ? Decision.ALLOWED : Decision.DENIED; + case NOT_PUBLISHED -> Decision.DENIED; }; } + /** + * Evaluates public repository retrieval using a point-addressable bearer + * credential. Capability and principal authority are not combined. + */ + public synchronized Decision decideRepository(PkiId objectId, Optional principal, + boolean ownerRelationship, boolean explicitAdministrativePermission, Optional bearer) { + Record record = store.requireDisclosure(objectId); + if (bearer.isPresent()) { + return record.policy() == Policy.PUBLIC_UNLISTED + && validBearer(objectId, bearer.orElseThrow()) ? Decision.ALLOWED : Decision.DENIED; + } + return switch (record.policy()) { + case PUBLIC -> Decision.ALLOWED; + case PUBLIC_UNLISTED -> principal.isPresent() && (ownerRelationship || explicitAdministrativePermission) + ? Decision.ALLOWED : Decision.DENIED; + case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled) + .filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC) + .map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED); + case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission + ? Decision.ALLOWED : Decision.DENIED; + case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED; + case NOT_PUBLISHED -> Decision.DENIED; + }; + } + + /** Returns the strict one-time bearer credential for an issued capability. */ + public static String bearerCredential(Capability capability, byte[] token) { + Objects.requireNonNull(capability, "capability"); + return bearerCredential(capability.capabilityId(), token); + } + + /** Returns the strict bearer credential for one non-secret capability ID. */ + public static String bearerCredential(String capabilityId, byte[] token) { + Permission.requireId(capabilityId, "capability"); + if (token == null || token.length != 32) throw new IllegalArgumentException("Capability token length"); + return capabilityId + '.' + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(token); + } + /** Issues 256 random capability bits and persists only their commitment. */ public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt, String actorPrincipalId) { @@ -335,6 +374,32 @@ public final class DisclosureService { return false; } + private boolean validBearer(PkiId objectId, String bearer) { + if (bearer == null || !bearer.matches("cap-[0-9a-f]{32}\\.[A-Za-z0-9_-]{43}")) return false; + int separator = bearer.indexOf('.'); + DisclosureService.Capability capability; + try { + capability = store.requireCapability(bearer.substring(0, separator)); + } catch (IllegalArgumentException unavailable) { + return false; + } + byte[] token; + try { + token = java.util.Base64.getUrlDecoder().decode(bearer.substring(separator + 1)); + } catch (IllegalArgumentException malformed) { + return false; + } + try { + return token.length == 32 && !capability.revoked() && capability.realmId().equals(realmId) + && capability.objectId().equals(objectId) && "RETRIEVE".equals(capability.action()) + && clock.instant().isBefore(capability.expiresAt()) + && MessageDigest.isEqual(capability.tokenCommitment(), + capabilityCommitment(objectId, capability.expiresAt(), token)); + } finally { + java.util.Arrays.fill(token, (byte) 0); + } + } + private byte[] capabilityCommitment(PkiId objectId, Instant expiry, byte[] token) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java index 87001b4..f28ec92 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java +++ b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java @@ -140,7 +140,15 @@ public final class OperationSecurityDescriptors { control(ServerControlOperation.RevokeCapability.NAME, Permission.Action.DISCLOSURE_CAPABILITY_REVOKE, Permission.ResourceType.CAPABILITY, true, false), control(ServerControlOperation.InspectAuditView.REDACTED, Permission.Action.AUDIT_READ_REDACTED, Permission.ResourceType.AUDIT, false, false), control(ServerControlOperation.InspectAuditView.FULL, Permission.Action.AUDIT_READ_FULL, Permission.ResourceType.AUDIT, false, false), - control(ServerControlOperation.InspectAuditView.PII, Permission.Action.AUDIT_READ_PII, Permission.ResourceType.AUDIT, false, false))); + control(ServerControlOperation.InspectAuditView.PII, Permission.Action.AUDIT_READ_PII, Permission.ResourceType.AUDIT, false, false), + control(ServerControlOperation.InspectRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_READ, + Permission.ResourceType.REPOSITORY_ALIAS, false, false), + control(ServerControlOperation.ListRepositoryAliases.NAME, Permission.Action.REPOSITORY_ALIAS_READ, + Permission.ResourceType.REPOSITORY_ALIAS, false, false), + control(ServerControlOperation.SetRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_MANAGE, + Permission.ResourceType.REPOSITORY_ALIAS, true, true), + control(ServerControlOperation.RemoveRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_MANAGE, + Permission.ResourceType.REPOSITORY_ALIAS, true, true))); } /** Creates a registry and rejects duplicate operation identities. */ @@ -360,6 +368,18 @@ public final class OperationSecurityDescriptors { case ServerControlOperation.RevokeCapability value -> "capability=" + atom(value.capabilityId()); case ServerControlOperation.InspectAuditView value -> "object=" + atom(value.objectId().value()) + ";view=" + value.view().name(); + case ServerControlOperation.InspectRepositoryAlias value -> "authority=" + + atom(value.authorityId().value()) + ";type=" + value.type().name(); + case ServerControlOperation.ListRepositoryAliases value -> "offset=" + value.offset() + + ";limit=" + value.limit(); + case ServerControlOperation.SetRepositoryAlias value -> "authority=" + + atom(value.authorityId().value()) + ";type=" + value.type().name() + ";target=" + + atom(value.targetObjectId().value()) + ";issuer=" + + atom(value.issuerId().map(zeroecho.pki.api.PkiId::value).orElse("")) + ";expected=" + + atom(value.expectedCurrentCommitment().orElse("")); + case ServerControlOperation.RemoveRepositoryAlias value -> "authority=" + + atom(value.authorityId().value()) + ";type=" + value.type().name() + ";expected=" + + atom(value.expectedCurrentCommitment()); }; } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/Permission.java b/pki-server/src/main/java/zeroecho/pki/server/Permission.java index 306a7b8..07f1a94 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/Permission.java +++ b/pki-server/src/main/java/zeroecho/pki/server/Permission.java @@ -70,7 +70,7 @@ public final class Permission { AUDIT_READ_REDACTED(120), AUDIT_READ_FULL(121), AUDIT_READ_PII(122), AUDIT_EXPORT(123), AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142), DISCLOSURE_READ(160), DISCLOSURE_CHANGE(161), DISCLOSURE_CAPABILITY_ISSUE(162), - DISCLOSURE_CAPABILITY_REVOKE(163); + DISCLOSURE_CAPABILITY_REVOKE(163), REPOSITORY_ALIAS_READ(164), REPOSITORY_ALIAS_MANAGE(165); private final int code; @@ -112,7 +112,7 @@ public final class Permission { REALM(1), SERVER_CONFIGURATION(2), PRINCIPAL(3), ROLE(4), GRANT(5), AUTHORITY(10), ISSUER(11), PROFILE(12), POLICY(13), X509_BINDING(14), REQUEST(20), CERTIFICATE(21), REVOCATION(22), STATUS_OBJECT(23), PUBLICATION(24), AUDIT(30), BACKUP(31), RESTORE(32), - DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36); + DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36), REPOSITORY_ALIAS(37); private final int code; ResourceType(int code) { this.code = code; } /** @return stable code */ public int code() { return code; } diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java index 922785b..2107089 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java @@ -38,6 +38,7 @@ import java.security.SecureRandom; import java.time.Clock; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; @@ -45,6 +46,7 @@ import javax.net.ssl.SSLContext; import zeroecho.pki.application.PkiSessionRuntimeDependencies; import zeroecho.pki.server.http.AdministrativeAuthenticator; import zeroecho.pki.server.http.PkiHttpsTransport; +import zeroecho.pki.server.http.PublicRepositoryTransport; import zeroecho.pki.server.spi.PkiServerAuthenticator; /** @@ -64,15 +66,21 @@ public final class PkiHttpsServer implements AutoCloseable { private final ServerRealmContext realm; private final PkiServerAuthenticator authenticator; private final PkiHttpsTransport transport; + private final Optional publicAuthenticator; + private final Optional publicTransport; private final AtomicReference state; private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm, PkiServerAuthenticator authenticator, PkiHttpsTransport transport, + Optional publicAuthenticator, + Optional publicTransport, AtomicReference state) { this.configuration = configuration; this.realm = realm; this.authenticator = authenticator; this.transport = transport; + this.publicAuthenticator = publicAuthenticator; + this.publicTransport = publicTransport; this.state = state; } @@ -101,6 +109,8 @@ public final class PkiHttpsServer implements AutoCloseable { ServerRealmContext realm = null; PkiServerAuthenticator authenticator = null; PkiHttpsTransport transport = null; + PkiServerAuthenticator publicAuthenticator = null; + PublicRepositoryTransport publicTransport = null; try { SSLContext tls = PkiHttpsTransport.resolveTls(exact, loader); realm = ServerRealmContext.open(exact.realm(), runtimeDependencies, clock, random); @@ -119,11 +129,23 @@ public final class PkiHttpsServer implements AutoCloseable { ServerRealmContext sharedRealm = realm; transport = PkiHttpsTransport.startResolved(exact, sharedRealm, authenticator, clock, random, tls, () -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN); + if (exact.publicListener().isPresent()) { + PkiServerConfiguration.PublicListener publicConfiguration = exact.publicListener().orElseThrow(); + validateTrustedProxies(publicConfiguration.authentication(), realm); + AdministrativeAuthenticator resolvedPublicAuthenticator = AdministrativeAuthenticator.create( + publicConfiguration.authentication(), realm::principal, + realm.gateway()::authorizeForwardedIdentity, clock, loader); + publicAuthenticator = resolvedPublicAuthenticator; + publicTransport = PublicRepositoryTransport.start(publicConfiguration, sharedRealm, + resolvedPublicAuthenticator, clock, random, loader, + () -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN); + } state.set(State.READY); realm.auditTransport("SERVER_READY", "system", Map.of("state", "READY")); - return new PkiHttpsServer(exact, realm, authenticator, transport, state); + return new PkiHttpsServer(exact, realm, authenticator, transport, + Optional.ofNullable(publicAuthenticator), Optional.ofNullable(publicTransport), state); } catch (RuntimeException | Error primary) { - closePartial(transport, realm, authenticator, state, primary); + closePartial(publicTransport, publicAuthenticator, transport, realm, authenticator, state, primary); throw primary; } } @@ -139,6 +161,12 @@ public final class PkiHttpsServer implements AutoCloseable { return transport.address(); } + /** @return actual public listener address when the repository listener is enabled */ + public Optional publicAddress() { + if (state.get() == State.TERMINATED) throw new IllegalStateException("HTTPS server is terminated"); + return publicTransport.map(PublicRepositoryTransport::address); + } + /** @return the one lifecycle-owned realm context */ public ServerRealmContext realm() { if (state.get() != State.READY) throw new IllegalStateException("HTTPS server is not ready"); @@ -174,25 +202,50 @@ public final class PkiHttpsServer implements AutoCloseable { primary = failure; } transport.quiesce(); + publicTransport.ifPresent(PublicRepositoryTransport::quiesce); + try { + if (publicTransport.isPresent()) { + publicTransport.orElseThrow().shutdown(configuration.publicListener().orElseThrow() + .execution().gracefulShutdown()); + } + } catch (Throwable failure) { + primary = suppress(primary, failure); + } try { transport.shutdown(configuration.execution().gracefulShutdown()); } catch (Throwable failure) { primary = suppress(primary, failure); } + if (publicAuthenticator.isPresent()) primary = close(publicAuthenticator.orElseThrow(), primary); primary = close(authenticator, primary); primary = close(realm, primary); state.set(State.TERMINATED); rethrow(primary); } - private static void closePartial(PkiHttpsTransport transport, ServerRealmContext realm, + private static void closePartial(PublicRepositoryTransport publicTransport, + PkiServerAuthenticator publicAuthenticator, PkiHttpsTransport transport, ServerRealmContext realm, PkiServerAuthenticator authenticator, AtomicReference state, Throwable primary) { + primary = close(publicTransport, primary); + primary = close(publicAuthenticator, primary); primary = close(transport, primary); primary = close(authenticator, primary); close(realm, primary); state.set(State.TERMINATED); } + private static void validateTrustedProxies(PkiServerConfiguration.Authentication authentication, + ServerRealmContext realm) { + if (authentication.mode() != AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY) return; + for (String principalId : authentication.trustedProxyPrincipalIds()) { + SecurityPrincipal principal = realm.principal(principalId); + if (!principal.enabled() || principal.type() != SecurityPrincipal.Type.SERVICE) { + throw new IllegalArgumentException("Trusted proxy principal is unavailable"); + } + realm.gateway().validateForwardingPrincipal(principalId); + } + } + private static Throwable close(AutoCloseable resource, Throwable primary) { if (resource == null) return primary; try { diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java index 8117875..d92031a 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java @@ -58,13 +58,15 @@ import zeroecho.pki.spi.ProviderConfig; * @param authentication strict client-certificate mappings * @param execution bounded execution and shutdown policy * @param runtime process-local capability references + * @param publicListener optional separately bounded public repository listener */ @SuppressWarnings("PMD") public record PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm, - Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) { + Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime, + Optional publicListener) { /** Current server configuration schema. */ - public static final int CURRENT_VERSION = 2; + public static final int CURRENT_VERSION = 3; /** Validates all security-sensitive fields before resource allocation. */ public PkiServerConfiguration { @@ -75,9 +77,27 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm Objects.requireNonNull(authentication, "authentication"); Objects.requireNonNull(execution, "execution"); Objects.requireNonNull(runtime, "runtime"); + publicListener = Objects.requireNonNull(publicListener, "publicListener"); if (!listener.clientCertificateRequired()) { throw new IllegalArgumentException("Administrative HTTPS requires client certificates"); } + publicListener.ifPresent(publicConfiguration -> { + if (listener.port() != 0 + && listener.port() == publicConfiguration.port()) { + boolean addressOverlap = listener.address().equals(publicConfiguration.address()) + || listener.address().isAnyLocalAddress() + || publicConfiguration.address().isAnyLocalAddress(); + if (addressOverlap) { + throw new IllegalArgumentException("Administrative and public listeners conflict"); + } + } + }); + } + + /** Creates a configuration with the public repository listener disabled. */ + public PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm, + Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) { + this(version, serverName, realm, listener, authentication, execution, runtime, Optional.empty()); } /** @@ -328,6 +348,53 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm } } + /** + * Separately bounded public repository listener configuration. + * + * @param address canonical bind address + * @param port TCP port, including zero for an ephemeral test listener + * @param tlsProvider optional explicit TLS provider; absent only for explicitly permitted loopback development + * @param allowPlaintextLoopback whether the loopback-only development exception is selected + * @param authentication direct or trusted-proxy optional-client authentication configuration + * @param maximumHeaderBytes bounded public request-header bytes + * @param maximumBodyBytes bounded public request-body bytes; repository methods still reject bodies + * @param execution isolated public transport and stream execution bounds + * @param maximumStreamDuration finite stream deadline + * @param publicImmutableCache immutable-object public cache duration + * @param publicAliasCache stable-alias public cache duration + * @param authorityListExposed whether safe public authority metadata routes are enabled + */ + public record PublicListener(InetAddress address, int port, Optional tlsProvider, + boolean allowPlaintextLoopback, Authentication authentication, int maximumHeaderBytes, + int maximumBodyBytes, Execution execution, + Duration maximumStreamDuration, Duration publicImmutableCache, Duration publicAliasCache, + boolean authorityListExposed) { + /** Validates public-listener transport policy and independent finite bounds. */ + public PublicListener { + Objects.requireNonNull(address, "address"); + if (port < 0 || port > 65_535) throw new IllegalArgumentException("Public listener port is invalid"); + tlsProvider = Objects.requireNonNull(tlsProvider, "tlsProvider"); + Objects.requireNonNull(authentication, "authentication"); + bounded(maximumHeaderBytes, 1_024, 1_048_576, "public header bound"); + bounded(maximumBodyBytes, 1, 65_536, "public body bound"); + Objects.requireNonNull(execution, "execution"); + positive(maximumStreamDuration, Duration.ofHours(1), "maximum stream duration"); + nonNegative(publicImmutableCache, Duration.ofDays(365), "immutable cache duration"); + nonNegative(publicAliasCache, Duration.ofDays(1), "alias cache duration"); + if (tlsProvider.isEmpty() && (!allowPlaintextLoopback || !address.isLoopbackAddress())) { + throw new IllegalArgumentException("Public listener TLS is required"); + } + if (tlsProvider.isPresent() && allowPlaintextLoopback) { + throw new IllegalArgumentException("Public listener transport policy is contradictory"); + } + } + + /** @return exact public socket address without DNS resolution */ + public InetSocketAddress socketAddress() { + return new InetSocketAddress(address, port); + } + } + /** * Process-local capability references. * @@ -356,4 +423,11 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm throw new IllegalArgumentException(name + " is invalid"); } } + + private static void nonNegative(Duration value, Duration maximum, String name) { + Objects.requireNonNull(value, name); + if (value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(name + " is invalid"); + } + } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java index 5724f59..8280e64 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java @@ -85,11 +85,12 @@ public final class PkiServerConfigurationCodec { /** Decodes one bounded strict configuration document. */ public static PkiServerConfiguration decode(byte[] document) { Fields root = Fields.of(StrictJson.parse(document, MAXIMUM_CONFIGURATION_BYTES)); - root.exact("version", "serverName", "realm", "listener", "authentication", "execution", "runtime"); + root.allowed(Set.of("version", "serverName", "realm", "listener", "authentication", "execution", + "runtime", "publicListener")); PkiServerConfiguration configuration = new PkiServerConfiguration(root.integer("version"), root.text("serverName"), realm(root.object("realm")), listener(root.object("listener")), authentication(root.object("authentication")), execution(root.object("execution")), - runtime(root.object("runtime"))); + runtime(root.object("runtime")), publicListener(root.object("publicListener"))); root.complete(); return configuration; } @@ -271,6 +272,31 @@ public final class PkiServerConfigurationCodec { return result; } + private static Optional publicListener(Fields value) { + value.allowed(Set.of("enabled", "address", "port", "tlsProvider", "allowPlaintextLoopback", "authentication", + "maximumHeaderBytes", "maximumBodyBytes", "execution", "maximumStreamDurationMillis", + "publicImmutableCacheMillis", + "publicAliasCacheMillis", "authorityListExposed")); + if (!value.bool("enabled")) { + value.exact("enabled"); + value.complete(); + return Optional.empty(); + } + Optional tlsProvider = value.optionalObject("tlsProvider") + .map(PkiServerConfigurationCodec::provider); + PkiServerConfiguration.PublicListener result = new PkiServerConfiguration.PublicListener( + address(value.text("address")), value.integer("port"), tlsProvider, + value.bool("allowPlaintextLoopback"), authentication(value.object("authentication")), + value.integer("maximumHeaderBytes"), value.integer("maximumBodyBytes"), + execution(value.object("execution")), + Duration.ofMillis(value.longValue("maximumStreamDurationMillis")), + Duration.ofMillis(value.longValue("publicImmutableCacheMillis")), + Duration.ofMillis(value.longValue("publicAliasCacheMillis")), + value.bool("authorityListExposed")); + value.complete(); + return Optional.of(result); + } + private static PkiServerConfiguration.RuntimeCapabilities runtime(Fields value) { value.allowed(Set.of("keyUnlockEnvironmentVariable")); PkiServerConfiguration.RuntimeCapabilities result = new PkiServerConfiguration.RuntimeCapabilities( diff --git a/pki-server/src/main/java/zeroecho/pki/server/PublicRepositoryGateway.java b/pki-server/src/main/java/zeroecho/pki/server/PublicRepositoryGateway.java new file mode 100644 index 0000000..26f7c2c --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/PublicRepositoryGateway.java @@ -0,0 +1,338 @@ +/******************************************************************************* + * 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.server; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.api.status.StatusObjectType; +import zeroecho.pki.application.PkiRepository; +import zeroecho.pki.application.PkiRepositoryContent; + +/** Transport-neutral, read-only disclosed repository gateway. */ +@SuppressWarnings({ "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.ExcessiveParameterList" }) +public final class PublicRepositoryGateway { + /** Cache policy classes with distinct privacy semantics. */ + public enum CacheClass { PUBLIC_IMMUTABLE, PUBLIC_ALIAS, PRIVATE_AUTHENTICATED, NO_STORE_CAPABILITY } + + /** Safe public authority metadata. */ + public record Authority(PkiId authorityId, zeroecho.pki.api.ca.CaKind kind, + List issuerIds, PkiId currentIssuanceIssuerId) { + /** Snapshots finite issuer identities. */ + public Authority { issuerIds = List.copyOf(issuerIds); } + } + + /** Exact explicit chain path represented as ordered immutable certificate links. */ + public record Chain(PkiId authorityId, PkiId issuerId, PkiId pathId, + List orderedCredentialIds, String commitment, String etag, CacheClass cacheClass) { + /** Snapshots exact order. */ + public Chain { orderedCredentialIds = List.copyOf(orderedCredentialIds); } + } + + /** Lifecycle-owned binary response descriptor. */ + public record Content(PkiRepositoryContent lease, String mediaType, String etag, CacheClass cacheClass) + implements AutoCloseable { + /** Validates response metadata. */ + public Content { + Objects.requireNonNull(lease); Permission.requireBounded(mediaType, 128, "media type"); + Permission.requireBounded(etag, 256, "ETag"); Objects.requireNonNull(cacheClass); + } + /** Closes the underlying immutable-content lease. */ + @Override public void close() throws java.io.IOException { lease.close(); } + } + + /** Safe request identity, without certificate or bearer-token logging fields. */ + public record Access(Optional principal, Optional capabilityBearer, + String correlationId) { + /** Validates mutually exclusive principal and capability authority. */ + public Access { + principal = Objects.requireNonNull(principal); capabilityBearer = Objects.requireNonNull(capabilityBearer); + Permission.requireBounded(correlationId, 256, "correlation ID"); + if (principal.isPresent() && capabilityBearer.isPresent()) { + throw new IllegalArgumentException("Principal and capability authority cannot be combined"); + } + } + } + + private final RealmId realmId; + private final AuthorityExposurePolicy exposure; + private final PkiRepository repository; + private final ServerControlStore control; + private final RoleTemplateCatalog roles; + private final AuthorizationEngine authorization; + private final BreakGlassService breakGlass; + private final DisclosureService disclosure; + private final RepositoryAliasService aliases; + private final Runnable openCheck; + + /** Creates one gateway over the realm's existing authorities. */ + /* default */ PublicRepositoryGateway(RealmId realmId, AuthorityExposurePolicy exposure, + PkiRepository repository, + ServerControlStore control, RoleTemplateCatalog roles, AuthorizationEngine authorization, + BreakGlassService breakGlass, DisclosureService disclosure, RepositoryAliasService aliases, + Runnable openCheck) { + this.realmId = Objects.requireNonNull(realmId); this.exposure = Objects.requireNonNull(exposure); + this.repository = Objects.requireNonNull(repository); this.control = Objects.requireNonNull(control); + this.roles = Objects.requireNonNull(roles); this.authorization = Objects.requireNonNull(authorization); + this.breakGlass = Objects.requireNonNull(breakGlass); this.disclosure = Objects.requireNonNull(disclosure); + this.aliases = Objects.requireNonNull(aliases); this.openCheck = Objects.requireNonNull(openCheck); + } + + /** Returns one bounded keyset page of exposed public authority metadata. */ + public List authorities(Optional after, int limit) { + openCheck.run(); + if (limit <= 0 || limit > 1_000) { + throw new IllegalArgumentException("Public authority limit is invalid"); + } + List result = new ArrayList<>(limit); + Optional cursor = Objects.requireNonNull(after, "after"); + while (result.size() < limit) { + List page = repository.authorities(cursor, Math.min(1_000, Math.max(64, limit))); + if (page.isEmpty()) { + break; + } + for (CaRecord value : page) { + if (exposure.allows(value.caId())) { + result.add(authority(value)); + if (result.size() == limit) { + break; + } + } + } + cursor = Optional.of(page.getLast().caId()); + if (page.size() < Math.min(1_000, Math.max(64, limit))) { + break; + } + } + return List.copyOf(result); + } + + /** Returns one exact exposed authority. */ + public Authority authority(PkiId authorityId) { + return authority(requireAuthority(authorityId)); + } + + /** Opens one exact issuer-generation certificate after disclosure enforcement. */ + public Content issuerCertificate(PkiId authorityId, PkiId issuerId, Access access) { + requireAuthority(authorityId); + IssuerGeneration issuer = repository.issuer(issuerId).orElseThrow(PublicRepositoryGateway::unavailable); + if (!authorityId.equals(issuer.authorityId())) { + throw unavailable(); + } + Credential credential = repository.credential(issuer.credentialId()) + .orElseThrow(PublicRepositoryGateway::unavailable); + AccessDecision decision = decide(credential.credentialId(), authorityId, + DisclosureService.ObjectType.CA_CERTIFICATE, true, Permission.Action.CERTIFICATE_DOWNLOAD, access); + return content(repository.openCredential(credential.credentialId()), "application/pkix-cert", decision); + } + + /** Resolves one explicit chain path without constructing or preferring a path. */ + public Chain chainPath(PkiId authorityId, PkiId issuerId, PkiId pathId, Access access) { + requireAuthority(authorityId); + IssuerChainPath path = repository.chainPath(pathId).orElseThrow(PublicRepositoryGateway::unavailable); + if (!authorityId.equals(path.authorityId()) || !issuerId.equals(path.issuerId())) { + throw unavailable(); + } + AccessDecision decision = decide(pathId, authorityId, DisclosureService.ObjectType.CA_CHAIN, true, + Permission.Action.CA_CHAIN_DOWNLOAD, access); + return chain(path, decision, path.pathCommitment(), false); + } + + /** Resolves the stable chain route only through CURRENT_CHAIN metadata. */ + public Chain currentChain(PkiId authorityId, Access access) { + requireAuthority(authorityId); + RepositoryAliasService.Record alias = aliases.require(authorityId, RepositoryAliasService.Type.CURRENT_CHAIN); + if (alias.state() != RepositoryAliasService.State.ACTIVE) { + throw unavailable(); + } + IssuerChainPath path = repository.chainPath(alias.pathId().orElseThrow()) + .orElseThrow(PublicRepositoryGateway::unavailable); + if (!authorityId.equals(path.authorityId()) || !alias.issuerId().orElseThrow().equals(path.issuerId()) + || !alias.targetCommitment().equals(path.pathCommitment())) { + throw unavailable(); + } + AccessDecision decision = decide(path.pathId(), authorityId, DisclosureService.ObjectType.CA_CHAIN, true, + Permission.Action.CA_CHAIN_DOWNLOAD, access); + return chain(path, decision, alias.recordCommitment(), true); + } + + /** Opens one exact leaf certificate; object identity alone grants no access. */ + public Content credential(PkiId credentialId, Access access) { + Credential credential = repository.credential(credentialId).orElseThrow(PublicRepositoryGateway::unavailable); + PkiId authorityId = credential.issuerRef().caId(); + requireAuthority(authorityId); + AccessDecision decision = decide(credentialId, authorityId, DisclosureService.ObjectType.LEAF_CERTIFICATE, + false, Permission.Action.CERTIFICATE_DOWNLOAD, access); + return content(repository.openCredential(credentialId), "application/pkix-cert", decision); + } + + /** Opens one exact status object after role and disclosure checks. */ + public Content statusObject(PkiId statusObjectId, Access access) { + StatusObject status = repository.statusObject(statusObjectId).orElseThrow(PublicRepositoryGateway::unavailable); + requireAuthority(status.issuerCaId()); + boolean crl = status.type() == StatusObjectType.CRL; + DisclosureService.ObjectType type = crl ? DisclosureService.ObjectType.CRL + : DisclosureService.ObjectType.STATUS_OBJECT; + AccessDecision decision = decide(statusObjectId, status.issuerCaId(), type, crl, + crl ? Permission.Action.CRL_DOWNLOAD : Permission.Action.CERTIFICATE_READ_CONTENT, access); + return content(repository.openStatusObject(statusObjectId), crl ? "application/pkix-crl" + : "application/octet-stream", decision); + } + + /** Resolves the stable current-CRL route only through CURRENT_CRL metadata. */ + public Content currentCrl(PkiId authorityId, Access access) { + requireAuthority(authorityId); + RepositoryAliasService.Record alias = aliases.require(authorityId, RepositoryAliasService.Type.CURRENT_CRL); + if (alias.state() != RepositoryAliasService.State.ACTIVE) { + throw unavailable(); + } + StatusObject status = repository.statusObject(alias.targetObjectId()) + .orElseThrow(PublicRepositoryGateway::unavailable); + if (!authorityId.equals(status.issuerCaId()) || status.type() != StatusObjectType.CRL + || !alias.targetCommitment().equals(status.content().sha256())) { + throw unavailable(); + } + AccessDecision decision = decide(status.statusObjectId(), authorityId, DisclosureService.ObjectType.CRL, + true, Permission.Action.CRL_DOWNLOAD, access); + PkiRepositoryContent lease = repository.openStatusObject(status.statusObjectId()); + return new Content(lease, "application/pkix-crl", etag(alias.recordCommitment(), decision.commitment()), + decision.cacheClass() == CacheClass.PUBLIC_IMMUTABLE ? CacheClass.PUBLIC_ALIAS + : decision.cacheClass()); + } + + private AccessDecision decide(PkiId objectId, PkiId authorityId, DisclosureService.ObjectType type, + boolean normativePublic, Permission.Action action, Access access) { + Objects.requireNonNull(access); + Optional record = control.findDisclosure(objectId); + if (record.isEmpty()) { + if (!normativePublic || access.capabilityBearer().isPresent()) { + throw unavailable(); + } + DisclosureService.Policy policy = disclosure.defaultPolicy(type, false, false); + if (policy != DisclosureService.Policy.PUBLIC) { + throw unavailable(); + } + return new AccessDecision(CacheClass.PUBLIC_IMMUTABLE, "default:" + type.name()); + } + DisclosureService.Record exact = record.orElseThrow(); + if (exact.objectType() != type) { + throw unavailable(); + } + boolean owner = access.principal().map(SecurityPrincipal::principalId) + .flatMap(id -> exact.ownerPrincipalId().filter(id::equals)).isPresent(); + boolean permitted = access.principal().filter(SecurityPrincipal::enabled) + .map(principal -> explicit(principal, action, authorityId, objectId, owner, type)).orElse(false); + if (disclosure.decideRepository(objectId, access.principal(), owner, permitted, + access.capabilityBearer()) != DisclosureService.Decision.ALLOWED) { + throw unavailable(); + } + CacheClass cache = access.capabilityBearer().isPresent() ? CacheClass.NO_STORE_CAPABILITY + : access.principal().isPresent() ? CacheClass.PRIVATE_AUTHENTICATED + : CacheClass.PUBLIC_IMMUTABLE; + return new AccessDecision(cache, exact.policyCommitment() + ':' + exact.updatedAt().toEpochMilli()); + } + + private boolean explicit(SecurityPrincipal principal, Permission.Action action, PkiId authorityId, + PkiId objectId, boolean owner, DisclosureService.ObjectType objectType) { + List grants = new ArrayList<>(control.grantsFor(principal.principalId())); + for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principal.principalId())) { + grants.addAll(roles.instantiate(assignment)); + } + BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principal.principalId()); + grants.addAll(emergency.grants()); + Permission.ResourceType resourceType = switch (objectType) { + case CRL, STATUS_OBJECT -> Permission.ResourceType.STATUS_OBJECT; + case CA_CHAIN -> Permission.ResourceType.AUTHORITY; + case CA_CERTIFICATE, LEAF_CERTIFICATE -> Permission.ResourceType.CERTIFICATE; + }; + Permission.Resource resource = new Permission.Resource(resourceType, + new Permission.Scope(realmId, Optional.of(authorityId), Optional.empty(), Optional.empty()), + Optional.of(objectId), Optional.empty()); + return authorization.authorize(new AuthorizationEngine.Request(realmId, exposure, principal, action, + resource, owner ? Permission.Relationship.OWN : Permission.Relationship.ANY, + Permission.DataView.CONTENT_FULL, Permission.Context.empty(), grants, emergency.grantIds())).allowed(); + } + + private CaRecord requireAuthority(PkiId authorityId) { + openCheck.run(); + if (!exposure.allows(authorityId)) { + throw unavailable(); + } + return repository.authority(authorityId).orElseThrow(PublicRepositoryGateway::unavailable); + } + + private static Content content(PkiRepositoryContent lease, String mediaType, AccessDecision decision) { + return new Content(lease, mediaType, etag(lease.sha256(), decision.commitment()), decision.cacheClass()); + } + + private static Chain chain(IssuerChainPath path, AccessDecision decision, String routeCommitment, + boolean aliasRoute) { + return new Chain(path.authorityId(), path.issuerId(), path.pathId(), path.orderedCredentialIds(), + path.pathCommitment(), etag(routeCommitment, decision.commitment()), + aliasRoute && decision.cacheClass() == CacheClass.PUBLIC_IMMUTABLE ? CacheClass.PUBLIC_ALIAS + : decision.cacheClass()); + } + + private static Authority authority(CaRecord value) { + return new Authority(value.caId(), value.kind(), value.issuerIds(), value.currentIssuanceIssuerId()); + } + + private static String etag(String first, String second) { + try { + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest((first + '\n' + second).getBytes(StandardCharsets.UTF_8))); + return '"' + hash + '"'; + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + + private static IllegalArgumentException unavailable() { + return new IllegalArgumentException("Repository resource unavailable"); + } + + private record AccessDecision(CacheClass cacheClass, String commitment) { } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/RepositoryAliasService.java b/pki-server/src/main/java/zeroecho/pki/server/RepositoryAliasService.java new file mode 100644 index 0000000..75977e4 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/RepositoryAliasService.java @@ -0,0 +1,266 @@ +/******************************************************************************* + * 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.server; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.api.status.StatusObjectType; +import zeroecho.pki.application.PkiRepository; + +/** Durable non-authoritative repository alias administration. */ +@SuppressWarnings({ "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.LinguisticNaming", + "PMD.ExcessiveParameterList" }) +public final class RepositoryAliasService { + /** Closed stable repository alias types. */ + public enum Type { + /** Stable route to one explicit immutable CRL. */ + CURRENT_CRL(1), + /** Stable route to one explicit immutable issuer chain path. */ + CURRENT_CHAIN(2); + private final int code; + Type(int code) { this.code = code; } + /** @return stable persistence code */ public int code() { return code; } + /** Resolves a stable persistence code. */ + public static Type fromCode(int code) { + return switch (code) { case 1 -> CURRENT_CRL; case 2 -> CURRENT_CHAIN; + default -> throw new IllegalArgumentException("Unknown repository alias type"); }; + } + } + + /** Durable alias state; removal never deletes its immutable target. */ + public enum State { + /** Alias resolves its exact target. */ ACTIVE(1), + /** Alias has been deliberately removed. */ REMOVED(2); + private final int code; + State(int code) { this.code = code; } + /** @return stable persistence code */ public int code() { return code; } + /** Resolves a stable persistence code. */ + public static State fromCode(int code) { + return switch (code) { case 1 -> ACTIVE; case 2 -> REMOVED; + default -> throw new IllegalArgumentException("Unknown repository alias state"); }; + } + } + + /** Immutable finite alias metadata; no content bytes or filesystem paths. */ + public record Record(String aliasId, RealmId realmId, PkiId authorityId, Type type, PkiId targetObjectId, + Optional issuerId, Optional pathId, String targetCommitment, Instant createdAt, + Instant updatedAt, String actorPrincipalId, State state, String recordCommitment) { + /** Validates exact alias identity and commitments. */ + public Record { + Permission.requireId(aliasId, "repository alias"); + Objects.requireNonNull(realmId, "realmId"); + Objects.requireNonNull(authorityId, "authorityId"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(targetObjectId, "targetObjectId"); + issuerId = Objects.requireNonNull(issuerId, "issuerId"); + pathId = Objects.requireNonNull(pathId, "pathId"); + requireDigest(targetCommitment); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(updatedAt, "updatedAt"); + Permission.requirePrincipal(actorPrincipalId); + Objects.requireNonNull(state, "state"); + requireDigest(recordCommitment); + if (!aliasId.equals(idFor(realmId, authorityId, type))) { + throw new IllegalArgumentException("Repository alias identity mismatch"); + } + if (type == Type.CURRENT_CHAIN != issuerId.isPresent() + || type == Type.CURRENT_CHAIN != pathId.isPresent()) { + throw new IllegalArgumentException("Repository alias target shape mismatch"); + } + if (!recordCommitment.equals(commitmentFor(realmId, authorityId, type, targetObjectId, + issuerId, pathId, targetCommitment, state))) { + throw new IllegalArgumentException("Repository alias record commitment mismatch"); + } + } + } + + private final RealmId realmId; + private final ServerControlStore store; + private final PkiRepository repository; + private final DisclosureService disclosure; + private final Clock clock; + private final SafeAudit audit; + + /** Creates a repository alias authority over one realm control store. */ + public RepositoryAliasService(RealmId realmId, ServerControlStore store, PkiRepository repository, + DisclosureService disclosure, Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink) { + this.realmId = Objects.requireNonNull(realmId, "realmId"); + this.store = Objects.requireNonNull(store, "store"); + this.repository = Objects.requireNonNull(repository, "repository"); + this.disclosure = Objects.requireNonNull(disclosure, "disclosure"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.audit = new SafeAudit(clock, auditSink); + } + + /** Returns an exact alias, including a durable removed record. */ + public Record require(PkiId authorityId, Type type) { + return store.requireRepositoryAlias(idFor(realmId, authorityId, type)); + } + + /** Returns a bounded alias page. */ + public ServerControlStore.Page list(int offset, int limit) { + return store.repositoryAliases(offset, limit); + } + + /** Atomically publishes a validated current-CRL alias. */ + public Record setCurrentCrl(PkiId authorityId, PkiId statusObjectId, Optional expected, + String actorPrincipalId) { + StatusObject status = repository.statusObject(statusObjectId) + .orElseThrow(() -> new IllegalArgumentException("Status object is unavailable")); + if (!authorityId.equals(status.issuerCaId()) || status.type() != StatusObjectType.CRL) { + throw new IllegalArgumentException("Current CRL target is invalid"); + } + requirePublic(statusObjectId, DisclosureService.ObjectType.CRL, true); + return set(authorityId, Type.CURRENT_CRL, statusObjectId, Optional.empty(), Optional.empty(), + status.content().sha256(), expected, actorPrincipalId); + } + + /** Atomically publishes one exact current public chain path. */ + public Record setCurrentChain(PkiId authorityId, PkiId issuerId, PkiId pathId, Optional expected, + String actorPrincipalId) { + IssuerGeneration issuer = repository.issuer(issuerId) + .orElseThrow(() -> new IllegalArgumentException("Issuer generation is unavailable")); + IssuerChainPath path = repository.chainPath(pathId) + .orElseThrow(() -> new IllegalArgumentException("Issuer chain path is unavailable")); + if (!authorityId.equals(issuer.authorityId()) || !authorityId.equals(path.authorityId()) + || !issuerId.equals(path.issuerId())) { + throw new IllegalArgumentException("Current chain target is outside the authority"); + } + for (PkiId credentialId : path.orderedCredentialIds()) { + requirePublic(credentialId, DisclosureService.ObjectType.CA_CERTIFICATE, false); + } + return set(authorityId, Type.CURRENT_CHAIN, pathId, Optional.of(issuerId), Optional.of(pathId), + path.pathCommitment(), expected, actorPrincipalId); + } + + /** Atomically removes an alias without deleting its target. */ + public Record remove(PkiId authorityId, Type type, String expectedCommitment, String actorPrincipalId) { + Record current = require(authorityId, type); + if (current.state() == State.REMOVED) { + return current; + } + Instant now = clock.instant(); + Record removed = record(current.realmId(), current.authorityId(), current.type(), current.targetObjectId(), + current.issuerId(), current.pathId(), current.targetCommitment(), current.createdAt(), now, + actorPrincipalId, State.REMOVED); + store.replaceRepositoryAlias(current, removed, Optional.of(expectedCommitment)); + audit.record("REPOSITORY_ALIAS_REMOVE", actorPrincipalId, Optional.of(current.targetObjectId()), + Map.of("type", type.name())); + return removed; + } + + private Record set(PkiId authorityId, Type type, PkiId targetId, Optional issuerId, + Optional pathId, String targetCommitment, Optional expected, String actor) { + Instant now = clock.instant(); + String aliasId = idFor(realmId, authorityId, type); + Optional current = store.findRepositoryAlias(aliasId); + if (current.isPresent() && expected.isEmpty()) { + Record existing = current.orElseThrow(); + if (existing.state() == State.ACTIVE && existing.targetObjectId().equals(targetId) + && existing.issuerId().equals(issuerId) && existing.pathId().equals(pathId) + && existing.targetCommitment().equals(targetCommitment)) { + return existing; + } + throw new IllegalStateException("Repository alias update requires expected-current commitment"); + } + Record value = record(realmId, authorityId, type, targetId, issuerId, pathId, targetCommitment, + current.map(Record::createdAt).orElse(now), now, actor, State.ACTIVE); + if (current.isEmpty()) { + if (expected.isPresent()) { + throw new IllegalStateException("Repository alias expectation conflicts"); + } + store.createRepositoryAlias(value); + } else { + store.replaceRepositoryAlias(current.orElseThrow(), value, expected); + } + audit.record("REPOSITORY_ALIAS_SET", actor, Optional.of(targetId), Map.of("type", type.name())); + return value; + } + + private void requirePublic(PkiId objectId, DisclosureService.ObjectType type, boolean crl) { + DisclosureService.Policy effective = store.findDisclosure(objectId).map(DisclosureService.Record::policy) + .orElseGet(() -> disclosure.defaultPolicy(type, false, false)); + if (effective != DisclosureService.Policy.PUBLIC) { + throw new SecurityException(crl ? "CRL is not public" : "Chain credential is not public"); + } + } + + private static Record record(RealmId realmId, PkiId authorityId, Type type, PkiId target, + Optional issuer, Optional path, String targetCommitment, Instant createdAt, + Instant updatedAt, String actor, State state) { + String commitment = commitmentFor(realmId, authorityId, type, target, issuer, path, targetCommitment, state); + return new Record(idFor(realmId, authorityId, type), realmId, authorityId, type, target, issuer, path, + targetCommitment, createdAt, updatedAt, actor, state, commitment); + } + + /** Returns the deterministic alias ID for one realm, authority, and type. */ + public static String idFor(RealmId realmId, PkiId authorityId, Type type) { + return "repository-alias-" + digest(realmId.value() + "\n" + authorityId.value() + "\n" + type.name()) + .substring(0, 32); + } + + private static String commitmentFor(RealmId realmId, PkiId authorityId, Type type, PkiId target, + Optional issuer, Optional path, String targetCommitment, State state) { + return digest(String.join("\n", realmId.value(), authorityId.value(), type.name(), target.value(), + issuer.map(PkiId::value).orElse(""), path.map(PkiId::value).orElse(""), targetCommitment, + state.name())); + } + + private static String digest(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + + private static void requireDigest(String value) { + if (value == null || !value.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Repository alias commitment is invalid"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java index 115430d..caaa77b 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java @@ -56,7 +56,9 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re ServerControlOperation.InspectBreakGlass, ServerControlOperation.ListBreakGlass, ServerControlOperation.RevokeBreakGlass, ServerControlOperation.InspectDisclosure, ServerControlOperation.SetDisclosure, ServerControlOperation.IssueCapability, - ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView { + ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView, + ServerControlOperation.InspectRepositoryAlias, ServerControlOperation.ListRepositoryAliases, + ServerControlOperation.SetRepositoryAlias, ServerControlOperation.RemoveRepositoryAlias { /** @return stable operation identity */ String name(); @@ -271,6 +273,41 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re case METADATA_FULL, CONTENT_FULL -> FULL; case PII_FULL -> PII; }; } } + /** Exact repository alias inspection. */ + record InspectRepositoryAlias(PkiId authorityId, RepositoryAliasService.Type type) + implements ServerControlOperation { + public static final String NAME = "repository.alias.inspect"; + public InspectRepositoryAlias { Objects.requireNonNull(authorityId); Objects.requireNonNull(type); } + @Override public String name() { return NAME; } + } + + /** Bounded repository alias page. */ + record ListRepositoryAliases(int offset, int limit) implements ServerControlOperation { + public static final String NAME = "repository.alias.list"; + public ListRepositoryAliases { page(offset, limit); } + @Override public String name() { return NAME; } + } + + /** Exact CRL or chain alias publication with expected-current protection. */ + record SetRepositoryAlias(PkiId authorityId, RepositoryAliasService.Type type, PkiId targetObjectId, + Optional issuerId, Optional expectedCurrentCommitment) implements ServerControlOperation { + public static final String NAME = "repository.alias.set"; + public SetRepositoryAlias { + Objects.requireNonNull(authorityId); Objects.requireNonNull(type); Objects.requireNonNull(targetObjectId); + issuerId = Objects.requireNonNull(issuerId); expectedCurrentCommitment = Objects.requireNonNull(expectedCurrentCommitment); + } + @Override public String name() { return NAME; } + } + + /** Durable repository alias removal without target mutation. */ + record RemoveRepositoryAlias(PkiId authorityId, RepositoryAliasService.Type type, + String expectedCurrentCommitment) implements ServerControlOperation { + public static final String NAME = "repository.alias.remove"; + public RemoveRepositoryAlias { Objects.requireNonNull(authorityId); Objects.requireNonNull(type); + if (expectedCurrentCommitment == null || !expectedCurrentCommitment.matches("[0-9a-f]{64}")) throw invalid(); } + @Override public String name() { return NAME; } + } + private static void page(int offset, int limit) { if (offset < 0 || limit <= 0 || limit > 256) throw invalid(); } diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java index bf38eec..0f85edf 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java @@ -60,6 +60,7 @@ public final class ServerControlOperationExecutor { private final BreakGlassService breakGlass; private final DisclosureService disclosure; private final AuditorViews auditorViews; + private final Optional repositoryAliases; private final OperationSecurityDescriptors descriptors; private final Map approvalPolicies; @@ -68,6 +69,7 @@ public final class ServerControlOperationExecutor { ServerControlStore store, RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure, AuditorViews auditorViews, + Optional repositoryAliases, OperationSecurityDescriptors descriptors, Map approvalPolicies) { this.realmId = Objects.requireNonNull(realmId, "realmId"); @@ -79,6 +81,7 @@ public final class ServerControlOperationExecutor { this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass"); this.disclosure = Objects.requireNonNull(disclosure, "disclosure"); this.auditorViews = Objects.requireNonNull(auditorViews, "auditorViews"); + this.repositoryAliases = Objects.requireNonNull(repositoryAliases, "repositoryAliases"); this.descriptors = Objects.requireNonNull(descriptors, "descriptors"); this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies")); } @@ -165,6 +168,29 @@ public final class ServerControlOperationExecutor { case ServerControlOperation.RevokeCapability value -> ordinary(operation, capability(disclosure.revokeCapability(value.capabilityId(), actor))); case ServerControlOperation.InspectAuditView value -> ordinary(operation, auditView(value, actor)); + case ServerControlOperation.InspectRepositoryAlias value -> ordinary(operation, + alias(aliases().require(value.authorityId(), value.type()))); + case ServerControlOperation.ListRepositoryAliases value -> ordinary(operation, + page(aliases().list(value.offset(), value.limit()), ServerControlOperationExecutor::alias)); + case ServerControlOperation.SetRepositoryAlias value -> ordinary(operation, + alias(publishAlias(value, actor))); + case ServerControlOperation.RemoveRepositoryAlias value -> ordinary(operation, + alias(aliases().remove(value.authorityId(), value.type(), value.expectedCurrentCommitment(), actor))); + }; + } + + private RepositoryAliasService aliases() { + return repositoryAliases.orElseThrow(() -> new IllegalStateException("Repository aliases are unavailable")); + } + + private RepositoryAliasService.Record publishAlias(ServerControlOperation.SetRepositoryAlias value, + String actor) { + return switch (value.type()) { + case CURRENT_CRL -> aliases().setCurrentCrl(value.authorityId(), value.targetObjectId(), + value.expectedCurrentCommitment(), actor); + case CURRENT_CHAIN -> aliases().setCurrentChain(value.authorityId(), + value.issuerId().orElseThrow(() -> new IllegalArgumentException("issuerId is required")), + value.targetObjectId(), value.expectedCurrentCommitment(), actor); }; } @@ -291,6 +317,19 @@ public final class ServerControlOperationExecutor { return object("principalId", text(value.principalId()), "type", text(value.type().name()), "displayName", text(value.displayName()), "enabled", bool(value.enabled())); } + + private static PkiOperationValue alias(RepositoryAliasService.Record value) { + Map fields = new LinkedHashMap<>(); + fields.put("aliasId", text(value.aliasId())); + fields.put("authorityId", text(value.authorityId().value())); + fields.put("type", text(value.type().name())); + fields.put("targetObjectId", text(value.targetObjectId().value())); + value.issuerId().ifPresent(id -> fields.put("issuerId", text(id.value()))); + value.pathId().ifPresent(id -> fields.put("pathId", text(id.value()))); + fields.put("state", text(value.state().name())); + fields.put("recordCommitment", text(value.recordCommitment())); + return new PkiOperationValue.ObjectValue(fields); + } private static PkiOperationValue template(RoleTemplateCatalog.Template value) { List actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code)) .map(item -> (PkiOperationValue) text(item.name())).toList(); diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java index 0565477..9500a42 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java @@ -79,7 +79,7 @@ import zeroecho.pki.spi.store.TransactionalMetadataStore; "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel", "PMD.ControlStatementBraces", "PMD.PreserveStackTrace", "PMD.CloseResource", "PMD.UseEnumCollections", "PMD.CommentRequired", "PMD.UncommentedEmptyMethodBody", - "PMD.TooManyMethods" }) + "PMD.TooManyMethods", "PMD.ExcessivePublicCount" }) public final class ServerControlStore implements AutoCloseable { /** Stable namespace for the realm record. */ public static final String REALM = "io.zeroecho.server.realm"; /** Stable namespace for principals. */ public static final String PRINCIPAL = "io.zeroecho.server.principal"; @@ -89,9 +89,11 @@ public final class ServerControlStore implements AutoCloseable { /** Stable namespace for break-glass records. */ public static final String BREAK_GLASS = "io.zeroecho.server.break-glass"; /** Stable namespace for disclosure records. */ public static final String DISCLOSURE = "io.zeroecho.server.disclosure"; /** Stable namespace for capability commitments. */ public static final String CAPABILITY = "io.zeroecho.server.capability"; + /** Stable namespace for non-authoritative public repository aliases. */ + public static final String REPOSITORY_ALIAS = "io.zeroecho.server.repository-alias"; private static final int MAGIC = 0x5a455331; - private static final int SCHEMA = 2; + private static final int SCHEMA = 3; private static final int MAXIMUM_RECORD_BYTES = 1_048_576; private static final int MAXIMUM_STRING_BYTES = 16_384; private static final int MAXIMUM_COLLECTION = 4_096; @@ -103,6 +105,7 @@ public final class ServerControlStore implements AutoCloseable { private static final int KIND_BREAK_GLASS = 6; private static final int KIND_DISCLOSURE = 7; private static final int KIND_CAPABILITY = 8; + private static final int KIND_REPOSITORY_ALIAS = 9; /** * Durable realm-control identity and commitments. @@ -308,6 +311,11 @@ public final class ServerControlStore implements AutoCloseable { .orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable")); } + /** Finds one disclosure record without converting absence into an error. */ + public synchronized Optional findDisclosure(PkiId objectId) { + return read(DISCLOSURE, objectId.value(), KIND_DISCLOSURE, ServerControlStore::readDisclosure); + } + /** Returns a bounded stable page of disclosure records. */ public synchronized Page disclosures(int offset, int limit) { return scanPage(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure, offset, limit); @@ -346,6 +354,43 @@ public final class ServerControlStore implements AutoCloseable { .filter(item -> item.objectId().equals(objectId)).toList(); } + /** Creates one exact repository alias record. */ + public synchronized void createRepositoryAlias(RepositoryAliasService.Record value) { + create(REPOSITORY_ALIAS, value.aliasId(), output -> writeRepositoryAlias(output, value)); + } + + /** Finds one exact repository alias record. */ + public synchronized Optional findRepositoryAlias(String aliasId) { + return read(REPOSITORY_ALIAS, aliasId, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias); + } + + /** Returns one exact repository alias record. */ + public synchronized RepositoryAliasService.Record requireRepositoryAlias(String aliasId) { + return findRepositoryAlias(aliasId) + .orElseThrow(() -> new IllegalArgumentException("Repository alias is unavailable")); + } + + /** Atomically replaces an alias after exact expected-current validation. */ + public synchronized void replaceRepositoryAlias(RepositoryAliasService.Record current, + RepositoryAliasService.Record updated, Optional expectedCurrentCommitment) { + requireSame(current.aliasId(), updated.aliasId()); + RepositoryAliasService.Record authoritative = requireRepositoryAlias(current.aliasId()); + if (!authoritative.equals(current)) { + throw new IllegalStateException("Repository alias changed concurrently"); + } + if (expectedCurrentCommitment.isPresent() + && !expectedCurrentCommitment.orElseThrow().equals(current.recordCommitment())) { + throw new IllegalStateException("Repository alias expected-current commitment differs"); + } + replace(REPOSITORY_ALIAS, current.aliasId(), output -> writeRepositoryAlias(output, updated)); + } + + /** Returns a bounded repository alias page. */ + public synchronized Page repositoryAliases(int offset, int limit) { + return scanPage(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias, + offset, limit); + } + /** * Strictly decodes and cross-checks every authoritative control record from * stable metadata snapshots before the realm is exposed. @@ -359,6 +404,7 @@ public final class ServerControlStore implements AutoCloseable { scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass); scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure); scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability); + scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias); } /** Validates durable relationships against the active strict role catalog. */ @@ -392,6 +438,10 @@ public final class ServerControlStore implements AutoCloseable { : scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability)) { requirePrincipalReference(principals, capability.issuerPrincipalId()); } + for (RepositoryAliasService.Record alias + : scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias)) { + requirePrincipalReference(principals, alias.actorPrincipalId()); + } } /** Opens no sidecar authority and closes the sole metadata authority idempotently. */ @@ -555,6 +605,7 @@ public final class ServerControlStore implements AutoCloseable { case BreakGlassService.Record item -> item.breakGlassId(); case DisclosureService.Record item -> item.objectId().value(); case DisclosureService.Capability item -> item.capabilityId(); + case RepositoryAliasService.Record item -> item.aliasId(); default -> throw new IllegalArgumentException("Unsupported control record type"); }; } @@ -704,6 +755,32 @@ public final class ServerControlStore implements AutoCloseable { DisclosureService.DeliveryState.fromCode(in.readInt()), in.readBoolean()); } + private static void writeRepositoryAlias(DataOutputStream out, RepositoryAliasService.Record value) + throws IOException { + out.writeInt(KIND_REPOSITORY_ALIAS); + writeString(out, value.aliasId()); + writeString(out, value.realmId().value()); + writeString(out, value.authorityId().value()); + out.writeInt(value.type().code()); + writeString(out, value.targetObjectId().value()); + writeOptionalPki(out, value.issuerId()); + writeOptionalPki(out, value.pathId()); + writeString(out, value.targetCommitment()); + writeInstant(out, value.createdAt()); + writeInstant(out, value.updatedAt()); + writeString(out, value.actorPrincipalId()); + out.writeInt(value.state().code()); + writeString(out, value.recordCommitment()); + } + + private static RepositoryAliasService.Record readRepositoryAlias(DataInputStream in) throws IOException { + return new RepositoryAliasService.Record(readString(in), new RealmId(readString(in)), + new PkiId(readString(in)), RepositoryAliasService.Type.fromCode(in.readInt()), + new PkiId(readString(in)), readOptionalPki(in), readOptionalPki(in), readString(in), + readInstant(in), readInstant(in), readString(in), RepositoryAliasService.State.fromCode(in.readInt()), + readString(in)); + } + private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException { writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId()); writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId()); diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java index 8e3aedc..9059280 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java @@ -163,6 +163,7 @@ public final class ServerOperationGateway { public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control, RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure, AuditorViews auditorViews, + Optional repositoryAliases, OperationSecurityDescriptors descriptors, PkiOperationExecutor executor, PkiResourceScopeResolver resourceScopes, Map approvalPolicies, @@ -177,7 +178,8 @@ public final class ServerOperationGateway { this.descriptors = Objects.requireNonNull(descriptors, "descriptors"); this.executor = Objects.requireNonNull(executor, "executor"); this.controlExecutor = new ServerControlOperationExecutor(realmId, exposure, control, roles, - authorization, approvals, breakGlass, disclosure, auditorViews, descriptors, approvalPolicies); + authorization, approvals, breakGlass, disclosure, auditorViews, repositoryAliases, descriptors, + approvalPolicies); this.resourceScopes = Objects.requireNonNull(resourceScopes, "resourceScopes"); this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies")); this.audit = new SafeAudit(clock, auditSink); @@ -194,7 +196,8 @@ public final class ServerOperationGateway { this(realmId, exposure, control, roles, authorization, approvals, breakGlass, new DisclosureService(realmId, control, DisclosureService.Defaults.recommended(), clock, new java.security.SecureRandom(), auditSink), new AuditorViews(clock, auditSink), - descriptors, executor, resourceScopes, approvalPolicies, clock, auditSink, openCheck); + Optional.empty(), descriptors, executor, resourceScopes, approvalPolicies, clock, auditSink, + openCheck); } /** diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java index 247d885..b256ac2 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java @@ -76,6 +76,8 @@ public final class ServerRealmContext implements AutoCloseable { private final ApprovalService approvals; private final BreakGlassService breakGlass; private final DisclosureService disclosure; + private final RepositoryAliasService repositoryAliases; + private final PublicRepositoryGateway publicRepository; private final AuditorViews auditorViews; private final ServerOperationGateway gateway; private final SafeAudit audit; @@ -84,6 +86,7 @@ public final class ServerRealmContext implements AutoCloseable { private ServerRealmContext(ServerRealmConfiguration configuration, ServerControlStore control, PkiSession session, RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure, + RepositoryAliasService repositoryAliases, AuditorViews auditorViews, AuditSink auditSink, Clock clock) { this.configuration = configuration; this.control = control; @@ -93,10 +96,15 @@ public final class ServerRealmContext implements AutoCloseable { this.approvals = approvals; this.breakGlass = breakGlass; this.disclosure = disclosure; + this.repositoryAliases = repositoryAliases; + this.publicRepository = new PublicRepositoryGateway(configuration.realmId(), configuration.authorityExposure(), + session.repository(), control, roles, authorization, breakGlass, disclosure, repositoryAliases, + this::requireOpen); this.auditorViews = auditorViews; this.audit = new SafeAudit(clock, auditSink); this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(), control, roles, authorization, approvals, breakGlass, disclosure, auditorViews, + Optional.of(repositoryAliases), new OperationSecurityDescriptors(), session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink, this::requireOpen); @@ -143,9 +151,11 @@ public final class ServerRealmContext implements AutoCloseable { BreakGlassService breakGlass = new BreakGlassService(control, clock, audit); DisclosureService disclosure = new DisclosureService(exact.realmId(), control, exact.disclosureDefaults(), clock, random, audit); + RepositoryAliasService aliases = new RepositoryAliasService(exact.realmId(), control, + session.repository(), disclosure, clock, audit); AuditorViews views = new AuditorViews(clock, audit); ServerRealmContext result = new ServerRealmContext(exact, control, session, roles, authorization, - approvals, breakGlass, disclosure, views, audit, clock); + approvals, breakGlass, disclosure, aliases, views, audit, clock); result.audit.record("REALM_OPEN", "system", Optional.empty(), Map.of("realm", exact.realmId().value())); return result; } catch (RuntimeException | Error primary) { @@ -168,6 +178,10 @@ public final class ServerRealmContext implements AutoCloseable { public BreakGlassService breakGlass() { requireOpen(); return breakGlass; } /** @return durable disclosure decision service */ public DisclosureService disclosure() { requireOpen(); return disclosure; } + /** @return durable non-authoritative public repository alias service */ + public RepositoryAliasService repositoryAliases() { requireOpen(); return repositoryAliases; } + /** @return read-only disclosed public repository gateway */ + public PublicRepositoryGateway publicRepository() { requireOpen(); return publicRepository; } /** @return explicit auditor projection service */ public AuditorViews auditorViews() { requireOpen(); return auditorViews; } /** @return authorized typed-operation gateway */ diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AdministrativeAuthenticator.java b/pki-server/src/main/java/zeroecho/pki/server/http/AdministrativeAuthenticator.java index f112168..d5d804f 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/AdministrativeAuthenticator.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/AdministrativeAuthenticator.java @@ -122,6 +122,35 @@ public final class AdministrativeAuthenticator implements PkiServerAuthenticator return proxy(context); } + /** + * Authenticates only the backend transport for an anonymous public request. + * This method is valid solely in trusted-proxy mode and never manufactures an + * end-client principal. + * + * @param context backend TLS peer context without operation data + * @return authorized trusted-proxy principal, or empty on any safe failure + */ + public Optional authenticatePublicProxyTransport(PkiServerAuthenticationContext context) { + Objects.requireNonNull(context, "context"); + if (configuration.mode() != AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY) { + return Optional.empty(); + } + PkiServerAuthenticationResult result = transportMapper.authenticate(context); + if (!(result instanceof PkiServerAuthenticationResult.Authenticated authenticated)) { + return Optional.empty(); + } + String principalId = authenticated.endClientPrincipalId(); + if (!configuration.trustedProxyPrincipalIds().contains(principalId)) { + return Optional.empty(); + } + try { + return forwardingAuthorization.apply(principalId, context.requestId()).allowed() + ? Optional.of(principalId) : Optional.empty(); + } catch (RuntimeException unavailable) { + return Optional.empty(); + } + } + private PkiServerAuthenticationResult direct(PkiServerAuthenticationContext context) { if (ForwardedClientCertificateParser.containsForwardedIdentity(context.requestHeaders())) { return rejected(AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(), diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java index 2a00549..4b1fac8 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java @@ -56,6 +56,7 @@ import zeroecho.pki.server.ApprovalService; import zeroecho.pki.server.DisclosureService; import zeroecho.pki.server.Permission; import zeroecho.pki.server.RealmId; +import zeroecho.pki.server.RepositoryAliasService; import zeroecho.pki.server.RoleTemplateCatalog; import zeroecho.pki.server.SecurityPrincipal; import zeroecho.pki.server.ServerControlOperation; @@ -366,6 +367,30 @@ final class HttpOperationCodec { yield new ServerControlOperation.InspectAuditView(fields.pkiId("objectId"), view, fields.optionalText("reasonReference")); } + case ServerControlOperation.InspectRepositoryAlias.NAME -> { + fields.exact("authorityId", "type"); + yield new ServerControlOperation.InspectRepositoryAlias(fields.pkiId("authorityId"), + RepositoryAliasService.Type.valueOf(fields.text("type"))); + } + case ServerControlOperation.ListRepositoryAliases.NAME -> { + fields.exact("offset", "limit"); + yield new ServerControlOperation.ListRepositoryAliases(fields.integer("offset"), + fields.integer("limit")); + } + case ServerControlOperation.SetRepositoryAlias.NAME -> { + fields.allowed(Set.of("authorityId", "type", "targetObjectId", "issuerId", + "expectedCurrentCommitment")); + yield new ServerControlOperation.SetRepositoryAlias(fields.pkiId("authorityId"), + RepositoryAliasService.Type.valueOf(fields.text("type")), + fields.pkiId("targetObjectId"), fields.optionalText("issuerId").map(PkiId::new), + fields.optionalText("expectedCurrentCommitment")); + } + case ServerControlOperation.RemoveRepositoryAlias.NAME -> { + fields.exact("authorityId", "type", "expectedCurrentCommitment"); + yield new ServerControlOperation.RemoveRepositoryAlias(fields.pkiId("authorityId"), + RepositoryAliasService.Type.valueOf(fields.text("type")), + fields.text("expectedCurrentCommitment")); + } default -> throw new SecurityException("Control operation is not exposed"); }; } @@ -378,6 +403,12 @@ final class HttpOperationCodec { case ServerControlOperation.EvaluateAuthorization value -> value.resource().scope(); case ServerControlOperation.RequestApproval value -> value.targetResource().scope(); case ServerControlOperation.CreateBreakGlass value -> value.grant().scope(); + case ServerControlOperation.InspectRepositoryAlias value -> authorityScope(realmId, authority, + value.authorityId()); + case ServerControlOperation.SetRepositoryAlias value -> authorityScope(realmId, authority, + value.authorityId()); + case ServerControlOperation.RemoveRepositoryAlias value -> authorityScope(realmId, authority, + value.authorityId()); default -> new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty()); }; } @@ -388,10 +419,19 @@ final class HttpOperationCodec { case ServerControlOperation.SetDisclosure value -> Optional.of(value.objectId()); case ServerControlOperation.IssueCapability value -> Optional.of(value.objectId()); case ServerControlOperation.InspectAuditView value -> Optional.of(value.objectId()); + case ServerControlOperation.SetRepositoryAlias value -> Optional.of(value.targetObjectId()); default -> Optional.empty(); }; } + private static Permission.Scope authorityScope(RealmId realmId, Optional requested, + PkiId embedded) { + if (requested.isEmpty() || !requested.orElseThrow().equals(embedded)) { + throw new IllegalArgumentException("Repository alias and authority scope differ"); + } + return new Permission.Scope(realmId, requested, Optional.empty(), Optional.empty()); + } + private static boolean realmWide(PkiOperation operation) { return operation instanceof PkiOperation.ListAuthorities || operation instanceof PkiOperation.CreateAuthority diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java index 7875241..897d92a 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java @@ -84,12 +84,12 @@ final class HttpResponses { byte[] token = sensitive.consumeToken(); try { MapBuilder result = new MapBuilder(sensitive.safeResult().fields()); - result.put("token", new PkiOperationValue.Text(java.util.Base64.getUrlEncoder() - .withoutPadding().encodeToString(token))); - Response encoded = encode(200, requestId, operation, "SUCCEEDED", - new PkiOperationValue.ObjectValue(result.values()), null); String capabilityId = ((PkiOperationValue.Text) sensitive.safeResult().fields() .get("capabilityId")).value(); + result.put("token", new PkiOperationValue.Text( + zeroecho.pki.server.DisclosureService.bearerCredential(capabilityId, token))); + Response encoded = encode(200, requestId, operation, "SUCCEEDED", + new PkiOperationValue.ObjectValue(result.values()), null); return new Response(encoded.statusCode(), encoded.body(), true, java.util.Optional.of(capabilityId)); } finally { java.util.Arrays.fill(token, (byte) 0); diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryHttpHandler.java b/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryHttpHandler.java new file mode 100644 index 0000000..e17be9a --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryHttpHandler.java @@ -0,0 +1,561 @@ +/******************************************************************************* + * 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.server.http; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.BooleanSupplier; + +import javax.net.ssl.SSLPeerUnverifiedException; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpsExchange; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.application.PkiOperationValue; +import zeroecho.pki.server.AdministrativeAuthenticationMode; +import zeroecho.pki.server.PublicRepositoryGateway; +import zeroecho.pki.server.PkiServerConfiguration; +import zeroecho.pki.server.SecurityPrincipal; +import zeroecho.pki.server.ServerRealmContext; +import zeroecho.pki.server.spi.PkiServerAuthenticationContext; +import zeroecho.pki.server.spi.PkiServerAuthenticationResult; + +/** Exact read-only HTTP adapter for the isolated public repository namespace. */ +@SuppressWarnings("PMD") +final class PublicRepositoryHttpHandler implements HttpHandler { + private static final int BUFFER_SIZE = 16_384; + private static final String JSON = "application/json; charset=utf-8"; + private final PkiServerConfiguration.PublicListener configuration; + private final ServerRealmContext realm; + private final AdministrativeAuthenticator authenticator; + private final ServerRuntime runtime; + private final Clock clock; + private final RequestIds requestIds; + private final BooleanSupplier ready; + + PublicRepositoryHttpHandler(PkiServerConfiguration.PublicListener configuration, + ServerRealmContext realm, AdministrativeAuthenticator authenticator, ServerRuntime runtime, + Clock clock, RequestIds requestIds, BooleanSupplier ready) { + this.configuration = java.util.Objects.requireNonNull(configuration); + this.realm = java.util.Objects.requireNonNull(realm); + this.authenticator = java.util.Objects.requireNonNull(authenticator); + this.runtime = java.util.Objects.requireNonNull(runtime); + this.clock = java.util.Objects.requireNonNull(clock); + this.requestIds = java.util.Objects.requireNonNull(requestIds); + this.ready = java.util.Objects.requireNonNull(ready); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + String requestId = "unavailable-request"; + String auditPrincipal = "anonymous"; + boolean admitted = false; + try { + requireHeadersBounded(exchange.getRequestHeaders()); + requireNoBody(exchange.getRequestHeaders()); + requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER)); + if (!ready.getAsBoolean()) { + sendFailure(exchange, requestId, 503, "NOT_READY"); + return; + } + URI uri = exchange.getRequestURI(); + if (uri.getRawFragment() != null || uri.getRawPath().indexOf('%') >= 0) { + sendFailure(exchange, requestId, 400, "MALFORMED_REQUEST"); + return; + } + if (!runtime.tryAdmit()) { + realm.auditTransport("PUBLIC_OVERLOAD", "anonymous", + Map.of("request", requestId, "classification", "ADMISSION_REJECTED")); + sendFailure(exchange, requestId, 429, "ADMISSION_REJECTED"); + return; + } + admitted = true; + Identity identity = authenticate(exchange, requestId); + if (!identity.accepted()) { + sendFailure(exchange, requestId, 401, "PUBLIC_AUTHENTICATION_FAILED"); + return; + } + PublicRepositoryGateway.Access access = access(exchange.getRequestHeaders(), identity, requestId); + auditPrincipal = access.principal().map(SecurityPrincipal::principalId) + .orElseGet(() -> access.capabilityBearer().isPresent() ? "capability" : "anonymous"); + Instant deadline = clock.instant().plus(configuration.maximumStreamDuration()); + execute(exchange, uri, requestId, access, deadline); + realm.auditTransport("PUBLIC_RETRIEVAL_COMPLETE", auditPrincipal, + Map.of("request", requestId, "classification", "COMPLETED")); + } catch (AuthenticationFailure rejected) { + realm.auditTransport("PUBLIC_AUTHENTICATION_DENIED", "anonymous", + Map.of("request", requestId, "classification", "DENIED")); + sendFailure(exchange, requestId, 401, "PUBLIC_AUTHENTICATION_FAILED"); + } catch (MalformedRequest malformed) { + sendFailure(exchange, requestId, 400, "MALFORMED_REQUEST"); + } catch (IllegalArgumentException unavailable) { + realm.auditTransport("PUBLIC_RETRIEVAL_DENIED", auditPrincipal, + Map.of("request", requestId, "classification", "NOT_DISCLOSED")); + sendFailure(exchange, requestId, 404, "RESOURCE_UNAVAILABLE"); + } catch (RuntimeException failure) { + sendFailure(exchange, requestId, 500, "SAFE_INTERNAL_FAILURE"); + } finally { + if (admitted) runtime.releaseAdmission(); + exchange.close(); + } + } + + private static void requireNoBody(Headers headers) { + if (headers.containsKey("Transfer-Encoding")) { + throw new MalformedRequest(); + } + List contentLength = headers.get("Content-Length"); + if (contentLength != null && (contentLength.size() != 1 || !"0".equals(contentLength.get(0)))) { + throw new MalformedRequest(); + } + } + + private void execute(HttpExchange exchange, URI uri, String requestId, + PublicRepositoryGateway.Access access, Instant deadline) throws IOException { + ServerRuntime.Submitted submitted; + java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean(); + try { + submitted = runtime.submit(cancellation -> { + started.set(true); + cancellation.throwIfCancelled(); + if (!clock.instant().isBefore(deadline)) { + throw new StreamDeadlineExceeded(); + } + route(exchange, uri, requestId, access, deadline); + return null; + }); + } catch (RejectedExecutionException overloaded) { + sendFailure(exchange, requestId, 429, "PUBLIC_STREAM_QUEUE_FULL"); + return; + } + try { + long remaining = Math.max(1L, java.time.Duration.between(clock.instant(), deadline).toMillis()); + submitted.future().get(remaining, TimeUnit.MILLISECONDS); + } catch (TimeoutException timeout) { + submitted.cancellation().cancel(); + submitted.future().cancel(true); + if (!started.get()) { + sendFailure(exchange, requestId, 504, "PUBLIC_STREAM_DEADLINE"); + } + } catch (InterruptedException interrupted) { + submitted.cancellation().cancel(); + submitted.future().cancel(true); + Thread.currentThread().interrupt(); + if (!started.get()) { + sendFailure(exchange, requestId, 504, "PUBLIC_STREAM_CANCELLED"); + } + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof StreamDeadlineExceeded) { + sendFailure(exchange, requestId, 504, "PUBLIC_STREAM_DEADLINE"); + } else if (cause instanceof IOException ioFailure) { + throw ioFailure; + } else if (cause instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } else if (cause instanceof Error error) { + throw error; + } else { + throw new IllegalStateException("Public repository execution failed"); + } + } finally { + submitted.finish(); + } + } + + private void route(HttpExchange exchange, URI uri, String requestId, + PublicRepositoryGateway.Access access, Instant deadline) throws IOException { + boolean head = "HEAD".equals(exchange.getRequestMethod()); + if (!head && !"GET".equals(exchange.getRequestMethod())) { + sendFailure(exchange, requestId, 405, "METHOD_NOT_ALLOWED"); + return; + } + String[] part = uri.getRawPath().split("/", -1); + if (part.length == 4 && "".equals(part[0]) && "public".equals(part[1]) + && "v1".equals(part[2]) && "authorities".equals(part[3])) { + if (!configuration.authorityListExposed()) throw unavailable(); + AuthorityPage page = authorityPage(uri.getRawQuery()); + authorities(exchange, requestId, head, page); + return; + } + if (uri.getRawQuery() != null) throw new MalformedRequest(); + if (part.length == 5 && prefix(part, "authorities")) { + authority(exchange, id(part[4]), requestId, head); + return; + } + if (part.length == 8 && prefix(part, "authorities") && "issuers".equals(part[5]) + && "certificate".equals(part[7])) { + content(exchange, realm.publicRepository().issuerCertificate(id(part[4]), id(part[6]), access), + requestId, head, false, deadline); + return; + } + if (part.length == 9 && prefix(part, "authorities") && "issuers".equals(part[5]) + && "paths".equals(part[7])) { + chain(exchange, realm.publicRepository().chainPath(id(part[4]), id(part[6]), id(part[8]), access), + requestId, head); + return; + } + if (part.length == 6 && prefix(part, "authorities") && "chain".equals(part[5])) { + chain(exchange, realm.publicRepository().currentChain(id(part[4]), access), requestId, head); + return; + } + if (part.length == 6 && prefix(part, "authorities") && "crl".equals(part[5])) { + content(exchange, realm.publicRepository().currentCrl(id(part[4]), access), requestId, head, true, + deadline); + return; + } + if (part.length == 5 && "".equals(part[0]) && "public".equals(part[1]) + && "v1".equals(part[2]) && "certificates".equals(part[3])) { + content(exchange, realm.publicRepository().credential(id(part[4]), access), requestId, head, false, + deadline); + return; + } + if (part.length == 5 && "".equals(part[0]) && "public".equals(part[1]) + && "v1".equals(part[2]) && "status".equals(part[3])) { + content(exchange, realm.publicRepository().statusObject(id(part[4]), access), requestId, head, false, + deadline); + return; + } + throw unavailable(); + } + + private static boolean prefix(String[] part, String resource) { + return "".equals(part[0]) && "public".equals(part[1]) && "v1".equals(part[2]) + && resource.equals(part[3]); + } + + private void authorities(HttpExchange exchange, String requestId, boolean head, AuthorityPage page) + throws IOException { + List entries = realm.publicRepository().authorities(page.after(), page.limit()).stream() + .map(PublicRepositoryHttpHandler::authorityValue).map(PkiOperationValue.class::cast).toList(); + Map fields = new LinkedHashMap<>(); + fields.put("count", new PkiOperationValue.IntegerValue(entries.size())); + fields.put("authorities", new PkiOperationValue.ListValue(entries)); + cache(exchange.getResponseHeaders(), PublicRepositoryGateway.CacheClass.PUBLIC_ALIAS); + metadata(exchange, requestId, "repository.authority.list", new PkiOperationValue.ObjectValue(fields), head); + } + + private void authority(HttpExchange exchange, PkiId authorityId, String requestId, boolean head) + throws IOException { + cache(exchange.getResponseHeaders(), PublicRepositoryGateway.CacheClass.PUBLIC_ALIAS); + metadata(exchange, requestId, "repository.authority.inspect", + authorityValue(realm.publicRepository().authority(authorityId)), head); + } + + private static AuthorityPage authorityPage(String query) { + if (query == null) return new AuthorityPage(Optional.empty(), 100); + if (query.isEmpty() || query.length() > 1_024 || query.indexOf('%') >= 0) throw new MalformedRequest(); + Optional after = Optional.empty(); + int limit = 100; + boolean foundAfter = false; + boolean foundLimit = false; + for (String field : query.split("&", -1)) { + int separator = field.indexOf('='); + if (separator <= 0 || separator != field.lastIndexOf('=')) throw new MalformedRequest(); + String name = field.substring(0, separator); + String value = field.substring(separator + 1); + if ("after".equals(name) && !foundAfter && !value.isEmpty()) { + if (!value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,255}")) throw new MalformedRequest(); + after = Optional.of(new PkiId(value)); + foundAfter = true; + } else if ("limit".equals(name) && !foundLimit && value.matches("[1-9][0-9]{0,3}")) { + try { limit = Integer.parseInt(value); } catch (NumberFormatException failure) { + throw new MalformedRequest(); + } + if (limit > 1_000) throw new MalformedRequest(); + foundLimit = true; + } else { + throw new MalformedRequest(); + } + } + return new AuthorityPage(after, limit); + } + + private static PkiOperationValue.ObjectValue authorityValue(PublicRepositoryGateway.Authority value) { + Map fields = new LinkedHashMap<>(); + fields.put("authorityId", new PkiOperationValue.Text(value.authorityId().value())); + fields.put("kind", new PkiOperationValue.Text(value.kind().name())); + fields.put("currentIssuanceIssuerId", new PkiOperationValue.Text( + value.currentIssuanceIssuerId().value())); + fields.put("issuerIds", new PkiOperationValue.ListValue(value.issuerIds().stream() + .map(PkiId::value).map(PkiOperationValue.Text::new).map(PkiOperationValue.class::cast).toList())); + return new PkiOperationValue.ObjectValue(fields); + } + + private void chain(HttpExchange exchange, PublicRepositoryGateway.Chain chain, String requestId, + boolean head) throws IOException { + Map fields = new LinkedHashMap<>(); + fields.put("authorityId", new PkiOperationValue.Text(chain.authorityId().value())); + fields.put("issuerId", new PkiOperationValue.Text(chain.issuerId().value())); + fields.put("pathId", new PkiOperationValue.Text(chain.pathId().value())); + fields.put("pathCommitment", new PkiOperationValue.Text(chain.commitment())); + fields.put("certificates", new PkiOperationValue.ListValue(chain.orderedCredentialIds().stream() + .map(id -> "/public/v1/certificates/" + id.value()).map(PkiOperationValue.Text::new) + .map(PkiOperationValue.class::cast).toList())); + cache(exchange.getResponseHeaders(), chain.cacheClass()); + exchange.getResponseHeaders().set("ETag", chain.etag()); + if (notModified(exchange, chain.etag())) { + exchange.sendResponseHeaders(304, -1); + return; + } + metadata(exchange, requestId, "repository.chain.inspect", new PkiOperationValue.ObjectValue(fields), head); + } + + private void metadata(HttpExchange exchange, String requestId, String operation, + PkiOperationValue.ObjectValue value, boolean head) throws IOException { + byte[] body = HttpResponses.success(requestId, operation, value).body(); + exchange.getResponseHeaders().set("Content-Type", JSON); + exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + exchange.getResponseHeaders().set(RequestIds.HEADER, requestId); + exchange.sendResponseHeaders(200, head ? -1 : body.length); + if (!head) try (OutputStream output = exchange.getResponseBody()) { output.write(body); } + } + + private void content(HttpExchange exchange, PublicRepositoryGateway.Content content, String requestId, + boolean head, boolean alias, Instant deadline) throws IOException { + try (content) { + if (!accepts(exchange.getRequestHeaders(), content.mediaType())) { + sendFailure(exchange, requestId, 406, "REPRESENTATION_NOT_ACCEPTABLE"); + return; + } + prevalidate(content.lease(), deadline); + Headers headers = exchange.getResponseHeaders(); + headers.set("Content-Type", content.mediaType()); + headers.set("Content-Length", Long.toString(content.lease().length())); + headers.set("ETag", content.etag()); + headers.set("X-Content-Type-Options", "nosniff"); + headers.set(RequestIds.HEADER, requestId); + cache(headers, alias && content.cacheClass() == PublicRepositoryGateway.CacheClass.PUBLIC_IMMUTABLE + ? PublicRepositoryGateway.CacheClass.PUBLIC_ALIAS : content.cacheClass()); + if (notModified(exchange, content.etag())) { + headers.remove("Content-Length"); + exchange.sendResponseHeaders(304, -1); + return; + } + if (head) { + exchange.sendResponseHeaders(200, -1); + return; + } + exchange.sendResponseHeaders(200, content.lease().length()); + try (InputStream input = content.lease().openStream(); OutputStream output = exchange.getResponseBody()) { + transfer(input, output, deadline); + } + } + } + + private void prevalidate(zeroecho.pki.application.PkiRepositoryContent content, Instant deadline) + throws IOException { + try (InputStream input = content.openStream()) { transfer(input, OutputStream.nullOutputStream(), deadline); } + } + + private void transfer(InputStream input, OutputStream output, Instant deadline) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = input.read(buffer)) >= 0) { + if (!clock.instant().isBefore(deadline) || Thread.currentThread().isInterrupted()) { + throw new IOException("Public repository stream deadline exceeded"); + } + output.write(buffer, 0, read); + } + } + + private Identity authenticate(HttpExchange exchange, String requestId) { + Map> headers = authenticationHeaders(exchange.getRequestHeaders()); + boolean forwarded = ForwardedClientCertificateParser.containsForwardedIdentity(headers); + AdministrativeAuthenticationMode mode = configuration.authentication().mode(); + if (mode == AdministrativeAuthenticationMode.DIRECT_MTLS && forwarded) return Identity.rejected(); + Optional context = tlsContext(exchange, requestId, headers); + if (mode == AdministrativeAuthenticationMode.DIRECT_MTLS) { + if (context.isEmpty()) return Identity.anonymous(); + return identity(authenticator.authenticate(context.orElseThrow())); + } + if (context.isEmpty()) return Identity.rejected(); + if (!forwarded) return authenticator.authenticatePublicProxyTransport(context.orElseThrow()) + .map(transport -> new Identity(true, Optional.of(transport), Optional.empty())) + .orElseGet(Identity::rejected); + return identity(authenticator.authenticate(context.orElseThrow())); + } + + private Identity identity(PkiServerAuthenticationResult result) { + if (!(result instanceof PkiServerAuthenticationResult.Authenticated authenticated)) { + return Identity.rejected(); + } + return new Identity(true, authenticated.transportPrincipalId(), + Optional.of(realm.principal(authenticated.endClientPrincipalId()))); + } + + private Optional tlsContext(HttpExchange exchange, String requestId, + Map> headers) { + if (!(exchange instanceof HttpsExchange https)) return Optional.empty(); + try { + Certificate[] peer = https.getSSLSession().getPeerCertificates(); + List chain = new ArrayList<>(); + for (Certificate certificate : peer) { + if (!(certificate instanceof X509Certificate x509)) return Optional.empty(); + chain.add(x509); + } + return Optional.of(new PkiServerAuthenticationContext(chain, https.getSSLSession().getProtocol(), + https.getSSLSession().getCipherSuite(), requestId, realm.configuration().realmId(), headers)); + } catch (SSLPeerUnverifiedException failure) { + return Optional.empty(); + } + } + + private PublicRepositoryGateway.Access access(Headers headers, Identity identity, String requestId) { + List values = headers.get("Authorization"); + if (headers.containsKey("Cookie")) throw new AuthenticationFailure(); + Optional bearer = Optional.empty(); + if (values != null) { + if (values.size() != 1 || !values.getFirst().matches("Bearer cap-[0-9a-f]{32}\\.[A-Za-z0-9_-]{43}")) { + throw new AuthenticationFailure(); + } + bearer = Optional.of(values.getFirst().substring("Bearer ".length())); + } + if (bearer.isPresent() && identity.principal().isPresent()) { + throw new AuthenticationFailure(); + } + return new PublicRepositoryGateway.Access(identity.principal(), bearer, requestId); + } + + private Map> authenticationHeaders(Headers headers) { + java.util.Set accepted = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER); + accepted.add(ForwardedClientCertificateParser.RFC_CERTIFICATE_HEADER); + accepted.add(ForwardedClientCertificateParser.RFC_CHAIN_HEADER); + accepted.add(ForwardedClientCertificateParser.DIRECT_REJECTED_NGINX_HEADER); + configuration.authentication().forwardedCertificateHeaderName().ifPresent(accepted::add); + configuration.authentication().forwardedCertificateChainHeaderName().ifPresent(accepted::add); + Map> result = new LinkedHashMap<>(); + for (Map.Entry> entry : headers.entrySet()) { + if (accepted.contains(entry.getKey())) result.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return Map.copyOf(result); + } + + private void requireHeadersBounded(Headers headers) { + int total = 0; + for (Map.Entry> entry : headers.entrySet()) { + total = Math.addExact(total, entry.getKey().getBytes(StandardCharsets.UTF_8).length); + for (String value : entry.getValue()) { + total = Math.addExact(total, value.getBytes(StandardCharsets.UTF_8).length); + if (total > configuration.maximumHeaderBytes()) { + throw new IllegalArgumentException("Public request headers are oversized"); + } + } + } + } + + private void cache(Headers headers, PublicRepositoryGateway.CacheClass value) { + switch (value) { + case PUBLIC_IMMUTABLE -> headers.set("Cache-Control", "public, max-age=" + + configuration.publicImmutableCache().toSeconds() + ", immutable"); + case PUBLIC_ALIAS -> headers.set("Cache-Control", "public, max-age=" + + configuration.publicAliasCache().toSeconds()); + case PRIVATE_AUTHENTICATED -> headers.set("Cache-Control", "private, no-store"); + case NO_STORE_CAPABILITY -> { + headers.set("Cache-Control", "private, no-store"); + headers.set("Pragma", "no-cache"); + headers.set("Referrer-Policy", "no-referrer"); + } + } + } + + private static boolean accepts(Headers headers, String mediaType) { + List values = headers.get("Accept"); + return values == null || values.size() == 1 && ("*/*".equals(values.getFirst()) + || mediaType.equalsIgnoreCase(values.getFirst())); + } + + private static boolean notModified(HttpExchange exchange, String etag) { + List values = exchange.getRequestHeaders().get("If-None-Match"); + return values != null && values.size() == 1 && etag.equals(values.getFirst()); + } + + private void sendFailure(HttpExchange exchange, String requestId, int status, String code) throws IOException { + byte[] body = HttpResponses.failure(status, requestId, "public.repository", "REPOSITORY_FAILURE", + code, code, false, false).body(); + exchange.getResponseHeaders().set("Content-Type", JSON); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + exchange.getResponseHeaders().set(RequestIds.HEADER, requestId); + exchange.sendResponseHeaders(status, body.length); + try (OutputStream output = exchange.getResponseBody()) { output.write(body); } + } + + private static PkiId id(String value) { + if (!value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,255}")) throw unavailable(); + return new PkiId(value); + } + + private static IllegalArgumentException unavailable() { + return new IllegalArgumentException("Public repository resource unavailable"); + } + + private static final class AuthenticationFailure extends RuntimeException { + private static final long serialVersionUID = 1L; + } + + private static final class MalformedRequest extends RuntimeException { + private static final long serialVersionUID = 1L; + } + + private static final class StreamDeadlineExceeded extends RuntimeException { + private static final long serialVersionUID = 1L; + } + + private record AuthorityPage(Optional after, int limit) { } + + private record Identity(boolean accepted, Optional transportPrincipal, + Optional principal) { + private static Identity anonymous() { return new Identity(true, Optional.empty(), Optional.empty()); } + private static Identity rejected() { return new Identity(false, Optional.empty(), Optional.empty()); } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryTransport.java b/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryTransport.java new file mode 100644 index 0000000..da8c0fa --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryTransport.java @@ -0,0 +1,143 @@ +/******************************************************************************* + * 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.server.http; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.util.Objects; +import java.util.function.BooleanSupplier; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; + +import zeroecho.pki.server.AdministrativeAuthenticationMode; +import zeroecho.pki.server.PkiServerConfiguration; +import zeroecho.pki.server.ServerRealmContext; + +/** Lifecycle owner for the isolated, optionally enabled public repository listener. */ +public final class PublicRepositoryTransport implements AutoCloseable { + private final HttpServer listener; + private final ServerRuntime runtime; + + private PublicRepositoryTransport(HttpServer listener, ServerRuntime runtime) { + this.listener = listener; + this.runtime = runtime; + } + + /** Starts one separately bounded public listener over the shared realm. */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + public static PublicRepositoryTransport start(PkiServerConfiguration.PublicListener configuration, + ServerRealmContext realm, AdministrativeAuthenticator authenticator, Clock clock, + SecureRandom random, ClassLoader loader, BooleanSupplier ready) { + Objects.requireNonNull(configuration, "configuration"); + Objects.requireNonNull(realm, "realm"); + Objects.requireNonNull(authenticator, "authenticator"); + ServerRuntime runtime = null; + HttpServer listener = null; + try { + runtime = new ServerRuntime(configuration.execution(), true); + if (configuration.tlsProvider().isPresent()) { + SSLContext context = TlsProviders.create(configuration.tlsProvider().orElseThrow(), loader); + HttpsServer secure = HttpsServer.create(configuration.socketAddress(), + configuration.execution().transportQueueCapacity()); + secure.setHttpsConfigurator(configurator(context, + configuration.authentication().mode() + == AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY)); + listener = secure; + } else { + listener = HttpServer.create(configuration.socketAddress(), + configuration.execution().transportQueueCapacity()); + } + listener.setExecutor(runtime.transportExecutor()); + listener.createContext("/", new PublicRepositoryHttpHandler(configuration, realm, authenticator, + runtime, clock, new RequestIds(random), ready)); + listener.start(); + return new PublicRepositoryTransport(listener, runtime); + } catch (IOException failure) { + closePartial(listener, runtime); + throw new IllegalStateException("Public repository listener initialization failed", failure); + } catch (RuntimeException | Error failure) { + closePartial(listener, runtime); + throw failure; + } + } + + /** @return actual public listener address */ + public InetSocketAddress address() { return listener.getAddress(); } + + /** Prevents new public stream admission. */ + public void quiesce() { runtime.quiesce(); } + + /** Stops the listener and its independent bounded resources. */ + public void shutdown(Duration graceful) { + runtime.quiesce(); + int seconds = Math.toIntExact(Math.min(Integer.MAX_VALUE, + Objects.requireNonNull(graceful, "graceful").toSeconds())); + listener.stop(seconds); + runtime.close(); + } + + @Override public void close() { shutdown(Duration.ZERO); } + + private static HttpsConfigurator configurator(SSLContext context, boolean proxyMode) { + return new HttpsConfigurator(context) { + @Override public void configure(HttpsParameters parameters) { + SSLParameters secure = context.getDefaultSSLParameters(); + if (proxyMode) { + secure.setNeedClientAuth(true); + } else { + secure.setWantClientAuth(true); + } + parameters.setSSLParameters(secure); + } + }; + } + + private static void closePartial(HttpServer listener, ServerRuntime runtime) { + if (listener != null) { + listener.stop(0); + } + if (runtime != null) { + runtime.close(); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java b/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java index 81bf117..1632d00 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java @@ -54,6 +54,8 @@ import zeroecho.pki.server.PkiServerConfiguration; final class ServerRuntime implements AutoCloseable { static final String TRANSPORT_PREFIX = "zeroecho-pki-https-"; static final String OPERATION_PREFIX = "zeroecho-pki-operation-"; + static final String PUBLIC_TRANSPORT_PREFIX = "zeroecho-pki-public-https-"; + static final String PUBLIC_STREAM_PREFIX = "zeroecho-pki-public-stream-"; static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown"; private final ThreadPoolExecutor transport; @@ -65,11 +67,15 @@ final class ServerRuntime implements AutoCloseable { private final AtomicBoolean accepting = new AtomicBoolean(true); ServerRuntime(PkiServerConfiguration.Execution configuration) { + this(configuration, false); + } + + ServerRuntime(PkiServerConfiguration.Execution configuration, boolean publicLane) { this.configuration = configuration; transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(), - new NamedThreadFactory(TRANSPORT_PREFIX)); + new NamedThreadFactory(publicLane ? PUBLIC_TRANSPORT_PREFIX : TRANSPORT_PREFIX)); operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(), - new NamedThreadFactory(OPERATION_PREFIX)); + new NamedThreadFactory(publicLane ? PUBLIC_STREAM_PREFIX : OPERATION_PREFIX)); admitted = new Semaphore(configuration.maximumAdmittedRequests(), true); } @@ -132,8 +138,10 @@ final class ServerRuntime implements AutoCloseable { quiesce(); transport.shutdown(); operations.shutdown(); - boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX); - boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX); + boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX) + || Thread.currentThread().getName().startsWith(PUBLIC_TRANSPORT_PREFIX); + boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX) + || Thread.currentThread().getName().startsWith(PUBLIC_STREAM_PREFIX); if (!operationWorker) { await(operations, configuration.gracefulShutdown()); } diff --git a/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java b/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java index 69d3e0f..889eb7d 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java +++ b/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java @@ -126,6 +126,18 @@ public final class HttpServerTestSupport { return configuration(directory, authentication); } + public static PkiServerConfiguration withPublicListener(PkiServerConfiguration source, + PkiServerConfiguration.Authentication authentication) throws Exception { + PkiServerConfiguration.Execution publicExecution = new PkiServerConfiguration.Execution(2, 4, 2, 4, 4, + Duration.ofSeconds(5), Duration.ofSeconds(10), Duration.ofSeconds(1), Duration.ofSeconds(1)); + PkiServerConfiguration.PublicListener listener = new PkiServerConfiguration.PublicListener( + InetAddress.getByName("127.0.0.1"), 0, + Optional.of(new ProviderConfig("test-tls", Map.of())), false, authentication, 16_384, + 1_024, publicExecution, Duration.ofSeconds(10), Duration.ofHours(1), Duration.ofMinutes(1), true); + return new PkiServerConfiguration(source.version(), source.serverName(), source.realm(), source.listener(), + source.authentication(), source.execution(), source.runtime(), Optional.of(listener)); + } + private static PkiServerConfiguration.Authentication directAuthentication(X509Certificate client) throws Exception { PkiServerConfiguration.ClientCertificateMapping mapping = mapping("administrator-map", "administrator", diff --git a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java index c79b013..7f33de0 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java @@ -43,6 +43,7 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.io.OutputStream; import java.security.MessageDigest; import java.time.Duration; import java.util.HexFormat; @@ -56,6 +57,28 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.application.PkiSessionRuntimeDependencies; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.IssuerRef; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.ca.CaKind; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.CaState; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.ca.IssuerGenerationState; +import zeroecho.pki.api.content.DurableContentReference; +import zeroecho.pki.api.credential.CaProfileBinding; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialStatus; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.impl.framework.x509.bc.SimpleAttributeSet; +import zeroecho.pki.impl.fs.FilesystemPkiStore; +import zeroecho.pki.impl.fs.FsPkiStoreOptions; +import zeroecho.pki.spi.store.ContentSink; import zeroecho.pki.server.http.MutualTlsAuthenticator; import zeroecho.pki.server.http.TestTlsProvider; import zeroecho.pki.server.spi.PkiServerAuthenticationContext; @@ -79,6 +102,33 @@ class PkiHttpsServerTest { "{\"version\":1,\"version\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8))); assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Execution(1, 1, 1, 1, 1, Duration.ofSeconds(2), Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1))); + PkiServerConfiguration.Execution publicExecution = new PkiServerConfiguration.Execution(1, 1, 1, 1, 1, + Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1)); + assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.PublicListener( + java.net.InetAddress.getByName("192.0.2.1"), 8444, Optional.empty(), true, + valid.authentication(), 4_096, 1_024, publicExecution, Duration.ofSeconds(1), Duration.ZERO, + Duration.ZERO, true)); + PkiServerConfiguration.PublicListener conflicting = new PkiServerConfiguration.PublicListener( + valid.listener().address(), 8443, Optional.of(valid.listener().tlsProvider()), false, + valid.authentication(), 4_096, 1_024, publicExecution, Duration.ofSeconds(1), Duration.ZERO, + Duration.ZERO, true); + PkiServerConfiguration.Listener fixedAdministrative = new PkiServerConfiguration.Listener( + valid.listener().address(), 8443, valid.listener().tlsProvider(), true, 16_384, 65_536); + assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration(valid.version(), + valid.serverName(), valid.realm(), fixedAdministrative, valid.authentication(), valid.execution(), + valid.runtime(), Optional.of(conflicting))); + assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration(2, valid.serverName(), + valid.realm(), valid.listener(), valid.authentication(), valid.execution(), valid.runtime())); + String production = java.nio.file.Files.readString(java.nio.file.Path.of( + "..", "docs", "pki-server-production-example.json")); + assertTrue(PkiServerConfigurationCodec.decode(production.getBytes( + java.nio.charset.StandardCharsets.UTF_8)).publicListener().isEmpty()); + assertThrows(IllegalArgumentException.class, () -> PkiServerConfigurationCodec.decode(production + .replace("\"publicListener\": {\"enabled\": false},\n", "") + .getBytes(java.nio.charset.StandardCharsets.UTF_8))); + assertThrows(IllegalArgumentException.class, () -> PkiServerConfigurationCodec.decode(production + .replace("{\"enabled\": false}", "{\"enabled\":false,\"port\":8444}") + .getBytes(java.nio.charset.StandardCharsets.UTF_8))); System.out.println("...strict-security-fields=true"); System.out.println("...ok"); } @@ -184,6 +234,124 @@ class PkiHttpsServerTest { System.out.println("...ok"); } + @Test + void isolatedPublicListenerAllowsAnonymousMetadataWithoutAdminRouteOverlap() throws Exception { + System.out.println("isolatedPublicListenerAllowsAnonymousMetadataWithoutAdminRouteOverlap"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + PkiServerConfiguration administrative = HttpServerTestSupport.configuration( + temporaryDirectory.resolve("public-direct"), tls.clientCertificate()); + PkiServerConfiguration configuration = HttpServerTestSupport.withPublicListener(administrative, + administrative.authentication()); + seed(configuration); + try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(), + ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) { + HttpClient anonymous = HttpClient.newBuilder().sslContext(tls.anonymousContext()).build(); + URI publicBase = URI.create("https://localhost:" + server.publicAddress().orElseThrow().getPort()); + HttpResponse authorities = get(anonymous, publicBase.resolve("/public/v1/authorities")); + assertEquals(200, authorities.statusCode()); + assertTrue(authorities.body().contains("\"count\":0")); + assertEquals(404, get(anonymous, publicBase.resolve("/admin/v1/realm")).statusCode()); + + HttpClient administrator = HttpClient.newBuilder().sslContext(tls.clientContext()).build(); + URI adminBase = URI.create("https://localhost:" + server.address().getPort()); + assertEquals(404, get(administrator, adminBase.resolve("/public/v1/authorities")).statusCode()); + System.out.println("...adminPort=" + server.address().getPort() + " publicPort=" + + server.publicAddress().orElseThrow().getPort()); + } + assertFalse(Thread.getAllStackTraces().keySet().stream().anyMatch(thread -> thread.isAlive() + && thread.getName().startsWith("zeroecho-pki-public-"))); + System.out.println("...ok"); + } + + @Test + void trustedProxyPublicRequestMayRemainAnonymousButTransportIsAuthenticated() throws Exception { + System.out.println("trustedProxyPublicRequestMayRemainAnonymousButTransportIsAuthenticated"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + PkiServerConfiguration administrative = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory.resolve("public-proxy"), tls, ForwardedCertificateFormat.RFC9440); + PkiServerConfiguration configuration = HttpServerTestSupport.withPublicListener(administrative, + administrative.authentication()); + seed(configuration); + try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(), + ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) { + HttpClient proxy = HttpClient.newBuilder().sslContext(tls.proxyContext()).build(); + URI uri = URI.create("https://localhost:" + server.publicAddress().orElseThrow().getPort() + + "/public/v1/authorities"); + assertEquals(200, get(proxy, uri).statusCode()); + HttpClient untrusted = HttpClient.newBuilder().sslContext(tls.clientContext()).build(); + assertEquals(401, get(untrusted, uri).statusCode()); + System.out.println("...proxyTransport=authenticated,endClient=anonymous"); + } + System.out.println("...ok"); + } + + @Test + void publicIssuerAndAliasedChainUseExactAuthoritativeRecordsAndValidators() throws Exception { + System.out.println("publicIssuerAndAliasedChainUseExactAuthoritativeRecordsAndValidators"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + java.nio.file.Path directory = temporaryDirectory.resolve("public-content"); + PkiServerConfiguration administrative = HttpServerTestSupport.configuration(directory, + tls.clientCertificate()); + PkiServerConfiguration configuration = HttpServerTestSupport.withPublicListener(administrative, + administrative.authentication()); + SeededIssuer seeded = seedIssuer(directory.resolve("pki-store"), tls.rootCertificate()); + seed(configuration); + try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(), + ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) { + server.realm().repositoryAliases().setCurrentChain(seeded.authorityId(), seeded.issuerId(), + seeded.pathId(), Optional.empty(), "administrator"); + HttpClient anonymous = HttpClient.newBuilder().sslContext(tls.anonymousContext()).build(); + URI base = URI.create("https://localhost:" + server.publicAddress().orElseThrow().getPort()); + URI certificateUri = base.resolve("/public/v1/authorities/" + seeded.authorityId().value() + + "/issuers/" + seeded.issuerId().value() + "/certificate"); + HttpResponse certificate = anonymous.send(HttpRequest.newBuilder(certificateUri).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(200, certificate.statusCode()); + assertTrue(MessageDigest.isEqual(tls.rootCertificate().getEncoded(), certificate.body())); + String etag = certificate.headers().firstValue("ETag").orElseThrow(); + HttpResponse head = anonymous.send(HttpRequest.newBuilder(certificateUri) + .method("HEAD", HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.discarding()); + assertEquals(200, head.statusCode()); + assertEquals(Long.toString(tls.rootCertificate().getEncoded().length), + head.headers().firstValue("Content-Length").orElseThrow()); + HttpResponse unchanged = anonymous.send(HttpRequest.newBuilder(certificateUri) + .header("If-None-Match", etag).GET().build(), HttpResponse.BodyHandlers.discarding()); + assertEquals(304, unchanged.statusCode()); + URI chainUri = base.resolve("/public/v1/authorities/" + seeded.authorityId().value() + "/chain"); + HttpResponse chain = get(anonymous, chainUri); + assertEquals(200, chain.statusCode()); + assertTrue(chain.body().contains(seeded.pathId().value())); + assertTrue(chain.body().contains(seeded.credentialId().value())); + server.realm().disclosure().register(seeded.credentialId(), DisclosureService.ObjectType.LEAF_CERTIFICATE, + DisclosureService.Policy.PUBLIC_UNLISTED, Optional.empty(), "2".repeat(64), "administrator"); + DisclosureService.IssuedCapability issued = server.realm().disclosure().issueCapability( + seeded.credentialId(), ServerTestSupport.CLOCK.instant().plusSeconds(600), "administrator"); + byte[] rawToken = issued.token(); + String bearer; + try { + bearer = DisclosureService.bearerCredential(issued.capability(), rawToken); + } finally { + java.util.Arrays.fill(rawToken, (byte) 0); + } + URI leafUri = base.resolve("/public/v1/certificates/" + seeded.credentialId().value()); + assertEquals(404, get(anonymous, leafUri).statusCode()); + HttpResponse capable = anonymous.send(HttpRequest.newBuilder(leafUri) + .header("Authorization", "Bearer " + bearer).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(200, capable.statusCode()); + assertEquals("private, no-store", capable.headers().firstValue("Cache-Control").orElseThrow()); + assertTrue(MessageDigest.isEqual(tls.rootCertificate().getEncoded(), capable.body())); + assertEquals(400, get(anonymous, URI.create(leafUri + "?token=" + bearer)).statusCode()); + server.realm().disclosure().revokeCapability(issued.capability().capabilityId(), "administrator"); + assertEquals(404, anonymous.send(HttpRequest.newBuilder(leafUri) + .header("Authorization", "Bearer " + bearer).GET().build(), + HttpResponse.BodyHandlers.discarding()).statusCode()); + System.out.println("...issuer=" + seeded.issuerId().value().substring(0, 16) + + " path=" + seeded.pathId().value().substring(0, 16)); + } + System.out.println("...ok"); + } + @Test void packagedEntryPointProvidesHelpAndSafeValidationFailure() { System.out.println("packagedEntryPointProvidesHelpAndSafeValidationFailure"); @@ -321,6 +489,40 @@ class PkiHttpsServerTest { System.out.println("...ok"); } + private SeededIssuer seedIssuer(java.nio.file.Path root, java.security.cert.X509Certificate certificate) + throws Exception { + PkiId authorityId = new PkiId("authority-public-root"); + PkiId credentialId = new PkiId("credential-public-root"); + PkiId issuerId = IssuerGeneration.idFor(authorityId, credentialId); + IssuerChainPath path = IssuerChainPath.create(authorityId, issuerId, List.of(credentialId)); + DurableContentReference reference; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), + ServerTestSupport.CLOCK); + ContentSink sink = store.stagedContent().beginContent(Encoding.DER, + DurableContentReference.Lifecycle.PERSISTED); + OutputStream output = sink.outputStream()) { + output.write(certificate.getEncoded()); + reference = sink.complete(); + InstantBounds bounds = new InstantBounds(certificate.getNotBefore().toInstant(), + certificate.getNotAfter().toInstant()); + KeyRef key = new KeyRef("public-root-key"); + SubjectRef subject = new SubjectRef(certificate.getSubjectX500Principal().getName()); + Credential credential = new Credential(credentialId, new FormatId("x509"), + new IssuerRef(authorityId, issuerId, path.pathId()), subject, + new Validity(bounds.notBefore(), bounds.notAfter()), certificate.getSerialNumber().toString(), + new PkiId("public-key-public-root"), + new CaProfileBinding(new CertificateProfileRef("public-root-profile", 1, new byte[32])), + CredentialStatus.ISSUED, reference, new SimpleAttributeSet()); + store.putCredential(credential); + store.putIssuerGeneration(new IssuerGeneration(issuerId, authorityId, credentialId, key, + IssuerGenerationState.ACTIVE, "0".repeat(64), "1".repeat(64))); + store.putIssuerChainPath(path); + store.putCa(new CaRecord(authorityId, CaKind.ROOT, CaState.ACTIVE, key, subject, + List.of(issuerId), issuerId, path.pathId())); + } + return new SeededIssuer(authorityId, credentialId, issuerId, path.pathId()); + } + private void seed(PkiServerConfiguration configuration) throws Exception { seed(configuration, true); } @@ -394,4 +596,8 @@ class PkiHttpsServerTest { .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString()); } + + private record SeededIssuer(PkiId authorityId, PkiId credentialId, PkiId issuerId, PkiId pathId) { } + + private record InstantBounds(java.time.Instant notBefore, java.time.Instant notAfter) { } } diff --git a/pki-server/src/test/java/zeroecho/pki/server/RepositoryAliasServiceTest.java b/pki-server/src/test/java/zeroecho/pki/server/RepositoryAliasServiceTest.java new file mode 100644 index 0000000..a4ed63a --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/RepositoryAliasServiceTest.java @@ -0,0 +1,209 @@ +/******************************************************************************* + * 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.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.ca.IssuerGenerationState; +import zeroecho.pki.api.content.DurableContentReference; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.api.status.StatusObjectType; +import zeroecho.pki.application.PkiRepository; +import zeroecho.pki.application.PkiRepositoryContent; +import zeroecho.pki.impl.framework.x509.bc.SimpleAttributeSet; + +/** Durable explicit repository-alias authority coverage. */ +final class RepositoryAliasServiceTest { + @TempDir Path directory; + + @Test + void currentCrlUsesExpectedCurrentAndSurvivesRecovery() throws Exception { + System.out.println("currentCrlUsesExpectedCurrentAndSurvivesRecovery"); + PkiId statusId = new PkiId("status-current-crl"); + RepositoryFixtures repository = new RepositoryFixtures(statusId); + RepositoryAliasService.Record created; + try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(directory)) { + opened.store().createPrincipal(ServerTestSupport.principal("alias-admin")); + RepositoryAliasService aliases = service(opened, repository); + created = aliases.setCurrentCrl(ServerTestSupport.AUTHORITY, statusId, Optional.empty(), "alias-admin"); + assertEquals(RepositoryAliasService.Type.CURRENT_CRL, created.type()); + assertEquals(created, aliases.setCurrentCrl(ServerTestSupport.AUTHORITY, statusId, + Optional.empty(), "alias-admin")); + assertThrows(IllegalStateException.class, () -> aliases.setCurrentCrl(ServerTestSupport.AUTHORITY, + new PkiId("status-other"), Optional.empty(), "alias-admin")); + System.out.println("...alias=" + created.aliasId() + " commitment=" + + created.recordCommitment().substring(0, 12)); + } + try (ServerControlStore store = new ServerControlStore( + zeroecho.pki.impl.fs.PosixTransactionalMetadataStore.open( + directory.resolve("server-control.log"), java.util.OptionalLong.of(1_048_576)))) { + ServerTestSupport.OpenedStore reopened = new ServerTestSupport.OpenedStore( + directory.resolve("server-control.log"), store, new ServerTestSupport.RecordingAudit()); + assertEquals(created, service(reopened, repository).require(ServerTestSupport.AUTHORITY, + RepositoryAliasService.Type.CURRENT_CRL)); + } + System.out.println("...ok"); + } + + @Test + void currentChainBindsExactIssuerPathWithoutChangingIssuanceAuthority() throws Exception { + System.out.println("currentChainBindsExactIssuerPathWithoutChangingIssuanceAuthority"); + RepositoryFixtures repository = new RepositoryFixtures(new PkiId("status-current-crl")); + try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(directory)) { + opened.store().createPrincipal(ServerTestSupport.principal("alias-admin")); + DisclosureService disclosure = disclosure(opened); + RepositoryAliasService aliases = service(opened, repository, disclosure); + RepositoryAliasService.Record alias = aliases.setCurrentChain(ServerTestSupport.AUTHORITY, + repository.issuer.issuerId(), repository.path.pathId(), Optional.empty(), "alias-admin"); + assertEquals(repository.path.pathCommitment(), alias.targetCommitment()); + assertEquals(repository.path.pathId(), alias.pathId().orElseThrow()); + assertEquals(repository.issuer.issuerId(), alias.issuerId().orElseThrow()); + assertEquals(repository.authority.issuanceChainPathId(), repository.path.pathId()); + PublicRepositoryGateway gateway = gateway(opened, repository, disclosure, aliases); + PublicRepositoryGateway.Chain publicChain = gateway.currentChain(ServerTestSupport.AUTHORITY, + new PublicRepositoryGateway.Access(Optional.empty(), Optional.empty(), "request-chain")); + assertEquals(repository.path.orderedCredentialIds(), publicChain.orderedCredentialIds()); + assertEquals(repository.path.pathCommitment(), publicChain.commitment()); + RepositoryAliasService.Record removed = aliases.remove(ServerTestSupport.AUTHORITY, + RepositoryAliasService.Type.CURRENT_CHAIN, alias.recordCommitment(), "alias-admin"); + assertEquals(RepositoryAliasService.State.REMOVED, removed.state()); + assertEquals(repository.path.pathId(), repository.authority.issuanceChainPathId()); + assertThrows(IllegalArgumentException.class, () -> gateway.currentChain(ServerTestSupport.AUTHORITY, + new PublicRepositoryGateway.Access(Optional.empty(), Optional.empty(), "request-removed"))); + System.out.println("...path=" + repository.path.pathId().value() + " state=" + removed.state()); + } + System.out.println("...ok"); + } + + private RepositoryAliasService service(ServerTestSupport.OpenedStore opened, PkiRepository repository) + throws Exception { + return service(opened, repository, disclosure(opened)); + } + + private RepositoryAliasService service(ServerTestSupport.OpenedStore opened, PkiRepository repository, + DisclosureService disclosure) { + return new RepositoryAliasService(ServerTestSupport.REALM, opened.store(), repository, disclosure, + ServerTestSupport.CLOCK, opened.audit()); + } + + private DisclosureService disclosure(ServerTestSupport.OpenedStore opened) throws Exception { + SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); + random.setSeed(new byte[] { 7, 6, 5, 4 }); + return new DisclosureService(ServerTestSupport.REALM, opened.store(), + DisclosureService.Defaults.recommended(), ServerTestSupport.CLOCK, random, opened.audit()); + } + + private PublicRepositoryGateway gateway(ServerTestSupport.OpenedStore opened, PkiRepository repository, + DisclosureService disclosure, RepositoryAliasService aliases) { + return new PublicRepositoryGateway(ServerTestSupport.REALM, + new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), true), + repository, opened.store(), RoleTemplateCatalog.load(getClass().getClassLoader()), + new AuthorizationEngine(ServerTestSupport.CLOCK), + new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), + disclosure, aliases, () -> { }); + } + + private static final class RepositoryFixtures implements PkiRepository { + private static final String ZERO = "0".repeat(64); + private final PkiId credentialId = new PkiId("credential-authority"); + private final IssuerGeneration issuer = new IssuerGeneration( + IssuerGeneration.idFor(ServerTestSupport.AUTHORITY, credentialId), ServerTestSupport.AUTHORITY, + credentialId, new KeyRef("issuer-key"), IssuerGenerationState.ACTIVE, ZERO, ZERO); + private final IssuerChainPath path = IssuerChainPath.create(ServerTestSupport.AUTHORITY, + issuer.issuerId(), List.of(credentialId)); + private final CaRecord authority = new CaRecord(ServerTestSupport.AUTHORITY, + zeroecho.pki.api.ca.CaKind.ROOT, zeroecho.pki.api.ca.CaState.ACTIVE, + new KeyRef("issuer-key"), new zeroecho.pki.api.SubjectRef("CN=Authority"), + List.of(issuer.issuerId()), issuer.issuerId(), path.pathId()); + private final StatusObject status; + + private RepositoryFixtures(PkiId statusId) { + status = new StatusObject(statusId, new FormatId("x509"), ServerTestSupport.AUTHORITY, + StatusObjectType.CRL, Instant.EPOCH, Optional.empty(), reference(statusId), + new SimpleAttributeSet()); + } + + @Override public Optional authority(PkiId id) { + return authority.caId().equals(id) ? Optional.of(authority) : Optional.empty(); + } + @Override public List authorities(Optional after, int limit) { return List.of(authority); } + @Override public Optional issuer(PkiId id) { + return issuer.issuerId().equals(id) ? Optional.of(issuer) : Optional.empty(); + } + @Override public Optional chainPath(PkiId id) { + return path.pathId().equals(id) ? Optional.of(path) : Optional.empty(); + } + @Override public Optional credential(PkiId id) { return Optional.empty(); } + @Override public Optional statusObject(PkiId id) { + if (status.statusObjectId().equals(id)) return Optional.of(status); + if ("status-other".equals(id.value())) { + return Optional.of(new StatusObject(id, status.formatId(), status.issuerCaId(), status.type(), + status.thisUpdate(), status.nextUpdate(), reference(id), status.attributes())); + } + return Optional.empty(); + } + @Override public PkiRepositoryContent openCredential(PkiId id) { throw new UnsupportedOperationException(); } + @Override public PkiRepositoryContent openStatusObject(PkiId id) { throw new UnsupportedOperationException(); } + + private static DurableContentReference reference(PkiId id) { + return new DurableContentReference() { + @Override public String storeId() { return "store"; } + @Override public String contentId() { return id.value(); } + @Override public Encoding encoding() { return Encoding.DER; } + @Override public long length() { return 1L; } + @Override public String sha256() { return ZERO; } + @Override public Lifecycle lifecycle() { return Lifecycle.PERSISTED; } + }; + } + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java b/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java index 03a0dc0..62eadac 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java @@ -63,10 +63,10 @@ class ServerControlOperationExecutorTest { void catalogContainsOneExplicitPairOfOperationFamilies() { System.out.println("catalogContainsOneExplicitPairOfOperationFamilies"); OperationSecurityDescriptors catalog = new OperationSecurityDescriptors(); - assertEquals(50, catalog.descriptors().size()); + assertEquals(54, catalog.descriptors().size()); assertEquals(16, catalog.descriptors().values().stream() .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); - assertEquals(34, catalog.descriptors().values().stream() + assertEquals(38, catalog.descriptors().values().stream() .filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION) .count()); assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION, @@ -211,7 +211,7 @@ class ServerControlOperationExecutorTest { new AuthorizationEngine(ServerTestSupport.CLOCK), new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), disclosure, - new AuditorViews(ServerTestSupport.CLOCK, opened.audit()), descriptors, + new AuditorViews(ServerTestSupport.CLOCK, opened.audit()), Optional.empty(), descriptors, Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, policy)); } diff --git a/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java b/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java index 8205974..33c4b1d 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java @@ -184,7 +184,7 @@ class ServerOperationGatewayTest { void descriptorRegistryRejectsUnknownAndDuplicateOperations() { System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations"); OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors(); - assertEquals(50, descriptors.descriptors().size()); + assertEquals(54, descriptors.descriptors().size()); assertEquals(16, descriptors.descriptors().values().stream() .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration())); diff --git a/pki/src/main/java/zeroecho/pki/api/CaService.java b/pki/src/main/java/zeroecho/pki/api/CaService.java index 9a5470b..426f815 100644 --- a/pki/src/main/java/zeroecho/pki/api/CaService.java +++ b/pki/src/main/java/zeroecho/pki/api/CaService.java @@ -44,6 +44,8 @@ import zeroecho.pki.api.ca.CaRolloverCommand; import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.ca.IntermediateCreateCommand; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; import zeroecho.pki.api.credential.Credential; /** @@ -179,4 +181,27 @@ public interface CaService { * @throws PkiException if listing fails */ List listCas(CaQuery query); + + /** Returns one exact issuer generation owned by this PKI authority. */ + IssuerGeneration getIssuerGeneration(PkiId issuerId); + + /** Returns one exact immutable issuer chain path. */ + IssuerChainPath getIssuerChainPath(PkiId pathId); + + /** Lists all explicit paths for an exact issuer generation. */ + List listIssuerChainPaths(PkiId issuerId); + + /** + * Atomically selects the exact issuer generation and path used for future + * issuance and bundle construction. + */ + void selectIssuancePath(PkiId caId, PkiId issuerId, PkiId pathId, String reason); + + /** + * Registers an additional explicit path for an existing issuer generation by + * appending one already-authoritative parent path. + * + * @return immutable registered path + */ + IssuerChainPath registerIssuerChainPath(PkiId caId, PkiId issuerId, PkiId parentPathId); } diff --git a/pki/src/main/java/zeroecho/pki/api/IssuerRef.java b/pki/src/main/java/zeroecho/pki/api/IssuerRef.java index 97b96af..25884bb 100644 --- a/pki/src/main/java/zeroecho/pki/api/IssuerRef.java +++ b/pki/src/main/java/zeroecho/pki/api/IssuerRef.java @@ -34,21 +34,38 @@ package zeroecho.pki.api; /** - * References an issuing CA entity. + * References the exact issuer generation that produced a credential. * * @param caId identifier of the CA entity acting as issuer + * @param issuerId canonical issuer-generation identifier + * @param chainPathId exact issuance chain path selected for the credential */ -public record IssuerRef(PkiId caId) { +public record IssuerRef(PkiId caId, PkiId issuerId, PkiId chainPathId) { + + /** + * Creates a transient unresolved reference used only at framework adapter + * boundaries. Application services must replace it before persistence. + * + * @param caId logical issuer authority + */ + public IssuerRef(PkiId caId) { + this(caId, new PkiId("issuer-unresolved:" + caId.value()), new PkiId("path-unresolved:" + caId.value())); + } /** * Creates an issuer reference. * - * @param caId CA identifier - * @throws IllegalArgumentException if {@code caId} is null + * @throws IllegalArgumentException if either identifier is {@code null} */ public IssuerRef { if (caId == null) { throw new IllegalArgumentException("caId must not be null"); } + if (issuerId == null) { + throw new IllegalArgumentException("issuerId must not be null"); + } + if (chainPathId == null) { + throw new IllegalArgumentException("chainPathId must not be null"); + } } } diff --git a/pki/src/main/java/zeroecho/pki/api/ca/CaRecord.java b/pki/src/main/java/zeroecho/pki/api/ca/CaRecord.java index ed6917a..f2cd7fc 100644 --- a/pki/src/main/java/zeroecho/pki/api/ca/CaRecord.java +++ b/pki/src/main/java/zeroecho/pki/api/ca/CaRecord.java @@ -33,17 +33,14 @@ ******************************************************************************/ package zeroecho.pki.api.ca; -import java.util.HashSet; import java.util.List; -import java.util.Set; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.SubjectRef; /** - * Represents a CA entity and the ordered identifiers of its issued CA - * credentials. + * Represents one logical CA authority and its explicit issuer generations. * *

* A CA entity may have multiple CA credentials to support: @@ -60,12 +57,12 @@ import zeroecho.pki.api.SubjectRef; * @param issuerKeyRef key reference used for issuing operations (private key * reference) * @param subjectRef normalized subject reference - * @param credentialIds ordered identifiers of the credentials currently - * associated with the entity (historical and active); - * duplicates and {@code null} elements are rejected + * @param issuerIds canonical issuer-generation identifiers owned by the authority + * @param currentIssuanceIssuerId exact generation selected for new issuance + * @param issuanceChainPathId exact chain path selected for issuance bundles */ public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyRef, SubjectRef subjectRef, - List credentialIds) { + List issuerIds, PkiId currentIssuanceIssuerId, PkiId issuanceChainPathId) { /** * Creates a CA record. @@ -90,18 +87,19 @@ public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyR if (subjectRef == null) { throw new IllegalArgumentException("subjectRef must not be null"); } - if (credentialIds == null) { - throw new IllegalArgumentException("credentialIds must not be null"); + if (issuerIds == null || issuerIds.isEmpty()) { + throw new IllegalArgumentException("issuerIds must not be null/empty"); } - Set uniqueCredentialIds = new HashSet<>(credentialIds.size()); - for (PkiId credentialId : credentialIds) { - if (credentialId == null) { - throw new IllegalArgumentException("credentialIds must not contain null"); - } - if (!uniqueCredentialIds.add(credentialId)) { - throw new IllegalArgumentException("credentialIds must not contain duplicates"); - } + if (issuerIds.stream().anyMatch(java.util.Objects::isNull) + || issuerIds.size() != new java.util.HashSet<>(issuerIds).size()) { + throw new IllegalArgumentException("issuerIds must contain unique non-null identifiers"); } - credentialIds = List.copyOf(credentialIds); + if (currentIssuanceIssuerId == null || !issuerIds.contains(currentIssuanceIssuerId)) { + throw new IllegalArgumentException("currentIssuanceIssuerId must identify an owned generation"); + } + if (issuanceChainPathId == null) { + throw new IllegalArgumentException("issuanceChainPathId must not be null"); + } + issuerIds = List.copyOf(issuerIds); } } diff --git a/pki/src/main/java/zeroecho/pki/api/ca/IssuerChainPath.java b/pki/src/main/java/zeroecho/pki/api/ca/IssuerChainPath.java new file mode 100644 index 0000000..9e49b17 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/ca/IssuerChainPath.java @@ -0,0 +1,104 @@ +/******************************************************************************* + * 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.ca; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +import zeroecho.pki.api.PkiId; + +/** + * Immutable explicitly ordered issuer chain from selected issuer certificate to + * its trust-anchor certificate. + */ +public record IssuerChainPath(PkiId pathId, PkiId authorityId, PkiId issuerId, + List orderedCredentialIds, String pathCommitment) { + /** Maximum supported certificates in one explicit PKI path. */ + public static final int MAX_CERTIFICATES = 32; + + /** Creates and validates an immutable chain path. */ + public IssuerChainPath { + Objects.requireNonNull(pathId, "pathId"); + Objects.requireNonNull(authorityId, "authorityId"); + Objects.requireNonNull(issuerId, "issuerId"); + if (orderedCredentialIds == null || orderedCredentialIds.isEmpty() + || orderedCredentialIds.size() > MAX_CERTIFICATES + || orderedCredentialIds.stream().anyMatch(Objects::isNull) + || orderedCredentialIds.size() != new HashSet<>(orderedCredentialIds).size()) { + throw new IllegalArgumentException("orderedCredentialIds must be nonempty, unique, and non-null"); + } + orderedCredentialIds = List.copyOf(orderedCredentialIds); + String expected = commitmentFor(authorityId, issuerId, orderedCredentialIds); + if (!expected.equals(pathCommitment)) { + throw new IllegalArgumentException("pathCommitment does not match the ordered path"); + } + if (!pathId.equals(idFor(expected))) { + throw new IllegalArgumentException("pathId does not match the path commitment"); + } + } + + /** Creates a canonical path from its exact ordered credential identities. */ + public static IssuerChainPath create(PkiId authorityId, PkiId issuerId, List credentials) { + String commitment = commitmentFor(authorityId, issuerId, credentials); + return new IssuerChainPath(idFor(commitment), authorityId, issuerId, credentials, commitment); + } + + /** Returns the canonical path identity for a path commitment. */ + public static PkiId idFor(String commitment) { + if (commitment == null || !commitment.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("commitment must be lowercase SHA-256 hexadecimal"); + } + return new PkiId("path:" + commitment.substring(0, 32)); + } + + /** Returns the canonical commitment to authority, generation, and order. */ + public static String commitmentFor(PkiId authorityId, PkiId issuerId, List credentials) { + Objects.requireNonNull(authorityId, "authorityId"); + Objects.requireNonNull(issuerId, "issuerId"); + Objects.requireNonNull(credentials, "credentials"); + StringBuilder canonical = new StringBuilder(authorityId.value()).append('\n').append(issuerId.value()); + credentials.forEach(id -> canonical.append('\n').append(Objects.requireNonNull(id, "credential").value())); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonical.toString().getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/ca/IssuerGeneration.java b/pki/src/main/java/zeroecho/pki/api/ca/IssuerGeneration.java new file mode 100644 index 0000000..d66fb46 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/ca/IssuerGeneration.java @@ -0,0 +1,84 @@ +/******************************************************************************* + * 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.ca; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; + +/** Immutable authority record for one concrete CA issuer generation. */ +public record IssuerGeneration(PkiId issuerId, PkiId authorityId, PkiId credentialId, KeyRef signingKeyRef, + IssuerGenerationState state, String profilePolicyCommitment, String x509BindingCommitment) { + + /** Creates a validated issuer generation. */ + public IssuerGeneration { + Objects.requireNonNull(issuerId, "issuerId"); + Objects.requireNonNull(authorityId, "authorityId"); + Objects.requireNonNull(credentialId, "credentialId"); + Objects.requireNonNull(signingKeyRef, "signingKeyRef"); + Objects.requireNonNull(state, "state"); + requireCommitment(profilePolicyCommitment, "profilePolicyCommitment"); + requireCommitment(x509BindingCommitment, "x509BindingCommitment"); + if (!issuerId.equals(idFor(authorityId, credentialId))) { + throw new IllegalArgumentException("issuerId does not match the authority and credential"); + } + } + + /** Returns the canonical identity for an authority credential generation. */ + public static PkiId idFor(PkiId authorityId, PkiId credentialId) { + Objects.requireNonNull(authorityId, "authorityId"); + Objects.requireNonNull(credentialId, "credentialId"); + return new PkiId("issuer:" + sha256(authorityId.value() + "\n" + credentialId.value()).substring(0, 32)); + } + + private static void requireCommitment(String value, String field) { + if (value == null || !value.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException(field + " must be lowercase SHA-256 hexadecimal"); + } + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/ca/IssuerGenerationState.java b/pki/src/main/java/zeroecho/pki/api/ca/IssuerGenerationState.java new file mode 100644 index 0000000..8582efe --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/ca/IssuerGenerationState.java @@ -0,0 +1,46 @@ +/******************************************************************************* + * 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.ca; + +/** Durable lifecycle state of one concrete CA issuer generation. */ +public enum IssuerGenerationState { + /** Available for explicitly selected issuance. */ + ACTIVE, + /** Retained for validation but unavailable for new issuance. */ + RETIRED, + /** Known or suspected compromised generation. */ + COMPROMISED, + /** Administratively disabled generation. */ + DISABLED +} diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java index 9bc7229..6162316 100644 --- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java @@ -386,7 +386,8 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { Map fields = fields(); fields.put("credentialId", text(credential.credentialId().value())); fields.put(FORMAT_ID, text(credential.formatId().value())); - fields.put("issuerId", text(credential.issuerRef().caId().value())); + fields.put("authorityId", text(credential.issuerRef().caId().value())); + fields.put("issuerId", text(credential.issuerRef().issuerId().value())); fields.put("publicKeyId", text(credential.publicKeyId().value())); fields.put("profileId", text(profile.profileId())); fields.put("profileVersion", integer(profile.profileVersion())); @@ -623,7 +624,8 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { fields.put("caId", text(record.caId().value())); fields.put("kind", text(record.kind().name())); fields.put("state", text(record.state().name())); - fields.put("credentialCount", integer(record.credentialIds().size())); + fields.put("issuerGenerationCount", integer(record.issuerIds().size())); + fields.put("currentIssuanceIssuerId", text(record.currentIssuanceIssuerId().value())); return fields; } diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiRepository.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiRepository.java new file mode 100644 index 0000000..b7e10fa --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiRepository.java @@ -0,0 +1,200 @@ +/******************************************************************************* + * 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.application; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.bouncycastle.cert.X509CRLHolder; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.asn1.ASN1InputStream; +import org.bouncycastle.asn1.x509.CertificateList; + +import zeroecho.core.io.RepeatableContent; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.api.status.StatusObjectType; +import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; +import zeroecho.pki.spi.store.PkiStore; + +/** Store-backed read-only repository application service. */ +final class DefaultPkiRepository implements PkiRepository { + private static final int MAX_PAGE = 1_000; + private final PkiStore store; + private final Runnable requireOpen; + + /* default */ DefaultPkiRepository(PkiStore store, Runnable requireOpen) { + this.store = Objects.requireNonNull(store, "store"); + this.requireOpen = Objects.requireNonNull(requireOpen, "requireOpen"); + } + + @Override public Optional authority(PkiId authorityId) { + requireOpen.run(); + return store.getCa(Objects.requireNonNull(authorityId, "authorityId")); + } + + @Override public List authorities(Optional afterAuthorityId, int limit) { + requireOpen.run(); + Objects.requireNonNull(afterAuthorityId, "afterAuthorityId"); + if (limit <= 0 || limit > MAX_PAGE) { + throw new IllegalArgumentException("limit must be between 1 and " + MAX_PAGE); + } + return store.listCasPage(afterAuthorityId, limit); + } + + @Override public Optional issuer(PkiId issuerId) { + requireOpen.run(); + return store.getIssuerGeneration(Objects.requireNonNull(issuerId, "issuerId")); + } + + @Override public Optional chainPath(PkiId pathId) { + requireOpen.run(); + return store.getIssuerChainPath(Objects.requireNonNull(pathId, "pathId")); + } + + @Override public Optional credential(PkiId credentialId) { + requireOpen.run(); + return store.getCredential(Objects.requireNonNull(credentialId, "credentialId")); + } + + @Override public Optional statusObject(PkiId statusObjectId) { + requireOpen.run(); + return store.getStatusObject(Objects.requireNonNull(statusObjectId, "statusObjectId")); + } + + @SuppressWarnings("PMD.AvoidCatchingGenericException") + @Override public PkiRepositoryContent openCredential(PkiId credentialId) { + requireOpen.run(); + Credential credential = credential(credentialId).orElseThrow(() -> new PkiException("Credential not found")); + if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())) { + throw new PkiException("Credential is not an X.509 repository object"); + } + try { + RepeatableContent content = store.stagedContent().openContent(credential.content()); + try { + validateCertificate(content, credential.content().length(), credential.content().sha256()); + } catch (IOException | RuntimeException failure) { + closeAfterValidationFailure(content, failure); + throw failure; + } + return new PkiRepositoryContent(credential.credentialId(), credential.issuerRef().caId(), + PkiRepositoryContent.Role.CERTIFICATE, credential.content(), + content); + } catch (IOException exception) { + throw new PkiException("Credential content unavailable", exception); + } + } + + @SuppressWarnings("PMD.AvoidCatchingGenericException") + @Override public PkiRepositoryContent openStatusObject(PkiId statusObjectId) { + requireOpen.run(); + StatusObject object = statusObject(statusObjectId).orElseThrow(() -> new PkiException("Status object not found")); + PkiRepositoryContent.Role role = object.type() == StatusObjectType.CRL + || object.type() == StatusObjectType.DELTA_CRL ? PkiRepositoryContent.Role.CRL + : PkiRepositoryContent.Role.STATUS_OBJECT; + try { + RepeatableContent content = store.stagedContent().openContent(object.content()); + try { + if (role == PkiRepositoryContent.Role.CRL) { + validateCrl(content, object.content().length(), object.content().sha256()); + } + } catch (IOException | RuntimeException failure) { + closeAfterValidationFailure(content, failure); + throw failure; + } + return new PkiRepositoryContent(object.statusObjectId(), object.issuerCaId(), role, object.content(), + content); + } catch (IOException exception) { + throw new PkiException("Status object content unavailable", exception); + } + } + + private static void validateCertificate(RepeatableContent content, long expectedLength, String expectedDigest) + throws IOException { + try (InputStream input = content.openStream(); ASN1InputStream asn1 = new ASN1InputStream(input)) { + X509CertificateHolder holder = new X509CertificateHolder( + org.bouncycastle.asn1.x509.Certificate.getInstance(asn1.readObject())); + requireComplete(asn1); + requireCanonical(holder.getEncoded(), expectedLength, expectedDigest, "certificate"); + } + } + + private static void validateCrl(RepeatableContent content, long expectedLength, String expectedDigest) + throws IOException { + try (InputStream input = content.openStream(); ASN1InputStream asn1 = new ASN1InputStream(input)) { + X509CRLHolder holder = new X509CRLHolder(CertificateList.getInstance(asn1.readObject())); + requireComplete(asn1); + requireCanonical(holder.getEncoded(), expectedLength, expectedDigest, "CRL"); + } + } + + private static void requireComplete(ASN1InputStream input) throws IOException { + if (input.readObject() != null) { + throw new IOException("Repository DER has trailing input"); + } + } + + private static void closeAfterValidationFailure(RepeatableContent content, Throwable primary) { + try { + content.close(); + } catch (IOException closeFailure) { + primary.addSuppressed(closeFailure); + } + } + + private static void requireCanonical(byte[] canonical, long expectedLength, String expectedDigest, String role) + throws IOException { + try { + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); + if (canonical.length != expectedLength || !MessageDigest.isEqual( + digest.getBytes(java.nio.charset.StandardCharsets.US_ASCII), + expectedDigest.getBytes(java.nio.charset.StandardCharsets.US_ASCII))) { + throw new IOException("Repository " + role + " is not canonical DER"); + } + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java index 7190684..79e6953 100644 --- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java @@ -116,6 +116,7 @@ final class DefaultPkiSession implements PkiSession { private final X509AlgorithmBindingRegistry algorithmBindings; private final PkiResourceScopeResolver resourceScopes; private final PkiOperationExecutor operations; + private final PkiRepository repository; private final AtomicBoolean closed = new AtomicBoolean(); private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit, @@ -134,10 +135,17 @@ final class DefaultPkiSession implements PkiSession { this.signatureWorkflow = graph.signatureWorkflow(); this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings"); this.resourceScopes = new DefaultPkiResourceScopeResolver(store, this::requireOpen); + this.repository = new DefaultPkiRepository(store, this::requireOpen); this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities, requests, issuance, statusObjects, publications, algorithmBindings, this::requireOpen); } + @Override + public PkiRepository repository() { + requireOpen(); + return repository; + } + /* default */ static PkiSession open(PkiSessionConfiguration configuration) { return open(configuration, runtimeDependencies(configuration), Clock.systemUTC(), ProductionBootstrap.INSTANCE); } diff --git a/pki/src/main/java/zeroecho/pki/application/PkiRepository.java b/pki/src/main/java/zeroecho/pki/application/PkiRepository.java new file mode 100644 index 0000000..92014ac --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiRepository.java @@ -0,0 +1,64 @@ +/******************************************************************************* + * 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.application; + +import java.util.List; +import java.util.Optional; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.status.StatusObject; + +/** Read-only authoritative repository facade owned by one {@link PkiSession}. */ +public interface PkiRepository { + /** Returns an exact logical authority. */ + Optional authority(PkiId authorityId); + /** Returns a finite canonical authority page after an optional exclusive key. */ + List authorities(Optional afterAuthorityId, int limit); + /** Returns an exact issuer generation. */ + Optional issuer(PkiId issuerId); + /** Returns an exact immutable issuer chain path. */ + Optional chainPath(PkiId pathId); + /** Returns an exact credential metadata record. */ + Optional credential(PkiId credentialId); + /** Returns an exact status-object metadata record. */ + Optional statusObject(PkiId statusObjectId); + /** Opens validated immutable certificate content. */ + PkiRepositoryContent openCredential(PkiId credentialId); + /** Opens validated immutable status-object content. */ + PkiRepositoryContent openStatusObject(PkiId statusObjectId); +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiRepositoryContent.java b/pki/src/main/java/zeroecho/pki/application/PkiRepositoryContent.java new file mode 100644 index 0000000..cac644d --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiRepositoryContent.java @@ -0,0 +1,87 @@ +/******************************************************************************* + * 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.application; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +import zeroecho.core.io.RepeatableContent; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.content.DurableContentReference; + +/** Lifecycle-owned immutable repository content lease without store internals. */ +public final class PkiRepositoryContent implements AutoCloseable { + /** Closed semantic role of public repository content. */ + public enum Role { + /** X.509 certificate DER. */ + CERTIFICATE, + /** X.509 certificate-revocation-list DER. */ + CRL, + /** Other immutable status-object representation. */ + STATUS_OBJECT + } + + private final PkiId objectId; + private final PkiId authorityId; + private final Role role; + private final DurableContentReference reference; + private final RepeatableContent content; + + /** Creates a validated application-layer content lease. */ + /* default */ PkiRepositoryContent(PkiId objectId, PkiId authorityId, Role role, + DurableContentReference reference, + RepeatableContent content) { + this.objectId = Objects.requireNonNull(objectId, "objectId"); + this.authorityId = Objects.requireNonNull(authorityId, "authorityId"); + this.role = Objects.requireNonNull(role, "role"); + this.reference = Objects.requireNonNull(reference, "reference"); + this.content = Objects.requireNonNull(content, "content"); + } + + /** @return exact object identity */ + public PkiId objectId() { return objectId; } + /** @return exact owning authority */ + public PkiId authorityId() { return authorityId; } + /** @return semantic representation role */ + public Role role() { return role; } + /** @return exact validated content length */ + public long length() { return reference.length(); } + /** @return immutable SHA-256 content commitment */ + public String sha256() { return reference.sha256(); } + /** Opens a new integrity-checking sequential content stream. */ + public InputStream openStream() throws IOException { return content.openStream(); } + /** Releases provider-owned lease resources. */ + @Override public void close() throws IOException { content.close(); } +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSession.java b/pki/src/main/java/zeroecho/pki/application/PkiSession.java index bd2102e..c2b1c29 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiSession.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiSession.java @@ -135,6 +135,9 @@ public interface PkiSession extends AutoCloseable { /** @return shared typed operation executor owned by this session */ PkiOperationExecutor operations(); + /** @return read-only authoritative repository facade owned by this session */ + PkiRepository repository(); + /** * Closes services and backend resources in reverse construction order. * Repeated calls are harmless; primary and suppressed failures are preserved. 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 46bffd4..37d60b4 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java @@ -376,10 +376,13 @@ public final class DefaultCaService implements CaService { PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16)); + PkiId issuerId = zeroecho.pki.api.ca.IssuerGeneration.idFor(caId, credId); + zeroecho.pki.api.ca.IssuerChainPath rootPath = IssuerAuthorities.rootPath(caId, issuerId, credId); PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spki.bytes())); - Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(), + Credential credential = new Credential(credId, command.formatId(), + new IssuerRef(caId, issuerId, rootPath.pathId()), request.subjectRef(), validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), CredentialStatus.ISSUED, CredentialContent.stage(store, certDer), SimpleAttributeSet.builder().build()); @@ -387,8 +390,10 @@ public final class DefaultCaService implements CaService { requireCaCertificateMatches(credential, credential, request, caId, CREATE_ROOT_REJECTED, BACKEND_CRED_MISMATCH); store.putCredential(credential); + store.putIssuerGeneration(IssuerAuthorities.generation(caId, keyRef, credential)); + store.putIssuerChainPath(rootPath); CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, request.subjectRef(), - List.of(credential.credentialId())); + List.of(issuerId), issuerId, rootPath.pathId()); store.putCa(ca); return caId; } @@ -448,6 +453,8 @@ public final class DefaultCaService implements CaService { PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16)); + PkiId issuerId = zeroecho.pki.api.ca.IssuerGeneration.idFor(caId, credId); + zeroecho.pki.api.ca.IssuerChainPath rootPath = IssuerAuthorities.rootPath(caId, issuerId, credId); byte[] spkiDer; try { @@ -464,7 +471,8 @@ public final class DefaultCaService implements CaService { ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA, command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime, Optional.empty(), serial, authority); - Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(), + Credential credential = new Credential(credId, command.formatId(), + new IssuerRef(caId, issuerId, rootPath.pathId()), request.subjectRef(), validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), CredentialStatus.ISSUED, command.existingCaCredential(), SimpleAttributeSet.builder().build()); @@ -473,8 +481,10 @@ public final class DefaultCaService implements CaService { ROOT_CREDENTIAL_INVALID); requireValidImportedRoot(command, holder); store.putCredential(credential); + store.putIssuerGeneration(IssuerAuthorities.generation(caId, command.keyRef(), credential)); + store.putIssuerChainPath(rootPath); CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), request.subjectRef(), - List.of(credential.credentialId())); + List.of(issuerId), issuerId, rootPath.pathId()); store.putCa(ca); return caId; } @@ -526,15 +536,14 @@ public final class DefaultCaService implements CaService { CaRecord issuer = getCa(command.issuerCaId()); ensureActive(issuer, "issuer"); - if (issuer.credentialIds().isEmpty()) { - throw new PkiException("Issuer CA has no credentials"); - } if (!framework.formatId().equals(command.formatId())) { throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.empty(), "FORMAT_UNSUPPORTED"); } EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(), CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation)); + zeroecho.pki.api.ca.IssuerGeneration parentGeneration = IssuerAuthorities.current(store, issuer); + zeroecho.pki.api.ca.IssuerChainPath parentPath = IssuerAuthorities.issuancePath(store, issuer); requireHistoricalCaProfile(issuerCredential, issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA : CertificateProfileKind.INTERMEDIATE_CA); @@ -568,18 +577,28 @@ public final class DefaultCaService implements CaService { } requireCaBinding(backendCredential, issue.profileReference(), CREATE_INT_REJECTED, command.formatId(), Optional.of(caId)); - Credential cred; + Credential rawCredential; try { - cred = CredentialSnapshots.copy(backendCredential); + rawCredential = CredentialSnapshots.copy(backendCredential); } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId), BACKEND_CRED_MISMATCH); } - requireCaCertificateMatches(cred, issuerCredential, issue, caId, CREATE_INT_REJECTED, BACKEND_CRED_MISMATCH); + requireCaCertificateMatches(rawCredential, issuerCredential, issue, caId, CREATE_INT_REJECTED, + BACKEND_CRED_MISMATCH); + Credential cred = IssuerAuthorities.withIssuer(rawCredential, + new IssuerRef(issuer.caId(), parentGeneration.issuerId(), parentPath.pathId())); store.putCredential(cred); + zeroecho.pki.api.ca.IssuerGeneration subjectGeneration = IssuerAuthorities.generation(caId, + command.keyRef().orElseThrow(), cred); + zeroecho.pki.api.ca.IssuerChainPath subjectPath = IssuerAuthorities.childPath(caId, + subjectGeneration.issuerId(), cred.credentialId(), parentPath); + store.putIssuerGeneration(subjectGeneration); + store.putIssuerChainPath(subjectPath); CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(), - issue.subjectRef(), List.of(cred.credentialId())); + issue.subjectRef(), List.of(subjectGeneration.issuerId()), subjectGeneration.issuerId(), + subjectPath.pathId()); store.putCa(subject); return caId; } @@ -631,6 +650,8 @@ public final class DefaultCaService implements CaService { EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(), CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation)); + zeroecho.pki.api.ca.IssuerGeneration parentGeneration = IssuerAuthorities.current(store, issuer); + zeroecho.pki.api.ca.IssuerChainPath parentPath = IssuerAuthorities.issuancePath(store, issuer); requireHistoricalCaProfile(issuerCredential, issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA : CertificateProfileKind.INTERMEDIATE_CA); @@ -661,21 +682,30 @@ public final class DefaultCaService implements CaService { } requireCaBinding(backendCredential, gated.profileReference(), ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId())); - Credential cred; + Credential rawCredential; try { - cred = CredentialSnapshots.copy(backendCredential); + rawCredential = CredentialSnapshots.copy(backendCredential); } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()), BACKEND_CRED_MISMATCH); } - requireCaCertificateMatches(cred, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED, + requireCaCertificateMatches(rawCredential, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED, BACKEND_CRED_MISMATCH); + Credential cred = IssuerAuthorities.withIssuer(rawCredential, + new IssuerRef(issuer.caId(), parentGeneration.issuerId(), parentPath.pathId())); store.putCredential(cred); + zeroecho.pki.api.ca.IssuerGeneration subjectGeneration = IssuerAuthorities.generation(subject.caId(), + subject.issuerKeyRef(), cred); + zeroecho.pki.api.ca.IssuerChainPath subjectPath = IssuerAuthorities.childPath(subject.caId(), + subjectGeneration.issuerId(), cred.credentialId(), parentPath); + store.putIssuerGeneration(subjectGeneration); + store.putIssuerChainPath(subjectPath); - List updated = new ArrayList<>(subject.credentialIds()); - updated.add(cred.credentialId()); + List updated = new ArrayList<>(subject.issuerIds()); + updated.add(subjectGeneration.issuerId()); CaRecord updatedCa = new CaRecord(subject.caId(), subject.kind(), subject.state(), subject.issuerKeyRef(), - subject.subjectRef(), List.copyOf(updated)); + subject.subjectRef(), List.copyOf(updated), subject.currentIssuanceIssuerId(), + subject.issuanceChainPathId()); store.putCa(updatedCa); return cred; } @@ -758,7 +788,8 @@ public final class DefaultCaService implements CaService { } CaRecord updated = new CaRecord(existing.caId(), existing.kind(), state, existing.issuerKeyRef(), - existing.subjectRef(), existing.credentialIds()); + existing.subjectRef(), existing.issuerIds(), existing.currentIssuanceIssuerId(), + existing.issuanceChainPathId()); store.putCa(updated); if (LOG.isLoggable(Level.INFO)) { @@ -810,17 +841,67 @@ public final class DefaultCaService implements CaService { return false; } if (query.formatId().isPresent()) { - if (r.credentialIds().isEmpty()) { - return false; - } - PkiId lastId = r.credentialIds().get(r.credentialIds().size() - 1); - Credential last = requireCredential(lastId); - return query.formatId().get().equals(last.formatId()); + Credential current = IssuerAuthorities.currentCredential(store, r); + return query.formatId().get().equals(current.formatId()); } return true; }).toList(); } + @Override + public zeroecho.pki.api.ca.IssuerGeneration getIssuerGeneration(PkiId issuerId) { + Objects.requireNonNull(issuerId, "issuerId"); + return store.getIssuerGeneration(issuerId).orElseThrow(() -> new PkiException("Issuer generation not found")); + } + + @Override + public zeroecho.pki.api.ca.IssuerChainPath getIssuerChainPath(PkiId pathId) { + Objects.requireNonNull(pathId, "pathId"); + return store.getIssuerChainPath(pathId).orElseThrow(() -> new PkiException("Issuer chain path not found")); + } + + @Override + public List listIssuerChainPaths(PkiId issuerId) { + return store.listIssuerChainPaths(Objects.requireNonNull(issuerId, "issuerId")); + } + + @Override + public void selectIssuancePath(PkiId caId, PkiId issuerId, PkiId pathId, String reason) { + Objects.requireNonNull(caId, "caId"); + Objects.requireNonNull(issuerId, "issuerId"); + Objects.requireNonNull(pathId, "pathId"); + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason must not be null/blank"); + } + CaRecord ca = getCa(caId); + zeroecho.pki.api.ca.IssuerGeneration generation = getIssuerGeneration(issuerId); + zeroecho.pki.api.ca.IssuerChainPath path = getIssuerChainPath(pathId); + if (!ca.issuerIds().contains(issuerId) || !caId.equals(generation.authorityId()) + || generation.state() != zeroecho.pki.api.ca.IssuerGenerationState.ACTIVE + || !caId.equals(path.authorityId()) || !issuerId.equals(path.issuerId())) { + throw new PkiException("Issuance selection is outside the authority"); + } + store.putCa(new CaRecord(ca.caId(), ca.kind(), ca.state(), ca.issuerKeyRef(), ca.subjectRef(), + ca.issuerIds(), issuerId, pathId)); + } + + @Override + public zeroecho.pki.api.ca.IssuerChainPath registerIssuerChainPath(PkiId caId, PkiId issuerId, + PkiId parentPathId) { + CaRecord ca = getCa(Objects.requireNonNull(caId, "caId")); + zeroecho.pki.api.ca.IssuerGeneration generation = getIssuerGeneration( + Objects.requireNonNull(issuerId, "issuerId")); + zeroecho.pki.api.ca.IssuerChainPath parent = getIssuerChainPath( + Objects.requireNonNull(parentPathId, "parentPathId")); + if (!ca.issuerIds().contains(issuerId) || !caId.equals(generation.authorityId())) { + throw new PkiException("Issuer generation is outside the authority"); + } + zeroecho.pki.api.ca.IssuerChainPath path = IssuerAuthorities.childPath(caId, issuerId, + generation.credentialId(), parent); + store.putIssuerChainPath(path); + return path; + } + private static void ensureActive(CaRecord ca, String role) { if (ca.state() != CaState.ACTIVE) { throw new PkiException("CA not ACTIVE: " + role); @@ -829,37 +910,17 @@ public final class DefaultCaService implements CaService { private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use, EffectiveCredentialStatusResolver.Evaluation evaluation) { - Credential lastRejected = null; - EffectiveCredentialStatus lastStatus = null; - for (PkiId credentialId : issuer.credentialIds()) { - Credential credential = requireCredential(credentialId); - if (credential == null || !formatId.equals(credential.formatId())) { - continue; - } - EffectiveCredentialStatus status; - try { - status = evaluation.resolve(credential); - } catch (PkiException exception) { - CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use, - StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null); - throw exception; - } - if (status == EffectiveCredentialStatus.USABLE) { - return credential; - } - lastRejected = credential; - lastStatus = status; + Credential credential = IssuerAuthorities.currentCredential(store, issuer); + if (!formatId.equals(credential.formatId())) { + throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } - if (lastRejected != null) { - CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use, - "ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus); + EffectiveCredentialStatus status = evaluation.resolve(credential); + if (status != EffectiveCredentialStatus.USABLE) { + CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use, + "ISSUER_CREDENTIAL_UNAVAILABLE", status); + throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } - throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); - } - - private Credential requireCredential(PkiId credentialId) { - return store.getCredential(credentialId) - .orElseThrow(() -> new PkiException("CA credential not found")); + return credential; } private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) { @@ -943,9 +1004,9 @@ public final class DefaultCaService implements CaService { return framework.formatId().equals(credential.formatId()) && credential.content().encoding() == Encoding.DER && credential.status() == CredentialStatus.ISSUED && credential.subjectRef().equals(request.subjectRef()) - && credential.issuerRef() - .equals(new IssuerRef(request.certificateType() == CertificateProfileKind.ROOT_CA ? subjectCaId - : request.issuerCaId())); + && credential.issuerRef().caId() + .equals(request.certificateType() == CertificateProfileKind.ROOT_CA ? subjectCaId + : request.issuerCaId()); } private static boolean matchesCaCertificateIdentity(X509CertificateHolder holder, diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java index 14cc188..a33c527 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java @@ -234,10 +234,6 @@ public final class DefaultIssuanceService implements IssuanceService { if (issuer.state() != CaState.ACTIVE) { throw new PkiException("Issuer CA not ACTIVE"); } - if (issuer.credentialIds().isEmpty()) { - throw new PkiException("Issuer CA has no credentials"); - } - VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command); ActiveCertificateProfile active; try { @@ -257,6 +253,8 @@ public final class DefaultIssuanceService implements IssuanceService { EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(), CredentialUse.END_ENTITY_ISSUER, statusEvaluation)); + zeroecho.pki.api.ca.IssuerGeneration generation = IssuerAuthorities.current(store, issuer); + zeroecho.pki.api.ca.IssuerChainPath issuancePath = IssuerAuthorities.issuancePath(store, issuer); ValidatedCertificateRequest validated; try { validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred, @@ -274,8 +272,10 @@ public final class DefaultIssuanceService implements IssuanceService { throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH"); } requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request()); - store.putCredential(bundle.credential()); - return bundle; + Credential exactCredential = IssuerAuthorities.withIssuer(bundle.credential(), + new zeroecho.pki.api.IssuerRef(issuer.caId(), generation.issuerId(), issuancePath.pathId())); + store.putCredential(exactCredential); + return new CredentialBundle(exactCredential, pathContent(issuancePath)); } /** @@ -303,32 +303,17 @@ public final class DefaultIssuanceService implements IssuanceService { Objects.requireNonNull(issuer, "issuer"); Objects.requireNonNull(formatId, "formatId"); - Credential lastRejected = null; - EffectiveCredentialStatus lastStatus = null; - for (PkiId credentialId : issuer.credentialIds()) { - Credential c = requireIssuerCredential(credentialId); - if (c == null || !formatId.equals(c.formatId())) { - continue; - } - EffectiveCredentialStatus status; - try { - status = evaluation.resolve(c); - } catch (PkiException exception) { - CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), c, use, - StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null); - throw exception; - } - if (status == EffectiveCredentialStatus.USABLE) { - return c; - } - lastRejected = c; - lastStatus = status; + Credential credential = IssuerAuthorities.currentCredential(store, issuer); + if (!formatId.equals(credential.formatId())) { + throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } - if (lastRejected != null) { - CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use, - "ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus); + EffectiveCredentialStatus status = evaluation.resolve(credential); + if (status != EffectiveCredentialStatus.USABLE) { + CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use, + "ISSUER_CREDENTIAL_UNAVAILABLE", status); + throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } - throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); + return credential; } private Credential requireIssuerCredential(PkiId credentialId) { @@ -459,7 +444,7 @@ public final class DefaultIssuanceService implements IssuanceService { validated.profileReference()); if (!framework.formatId().equals(credential.formatId()) || credential.content().encoding() != Encoding.DER || !credential.subjectRef().equals(validated.subjectRef()) - || !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId())) + || !credential.issuerRef().caId().equals(validated.issuerCaId()) || credential.status() != CredentialStatus.ISSUED) { throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH"); } @@ -585,16 +570,15 @@ public final class DefaultIssuanceService implements IssuanceService { * credential. * *

- * This implementation currently returns a minimal bundle containing only the - * resolved leaf credential and an empty chain. Chain discovery, issuer path - * construction, and publication-aware bundle assembly are intentionally left to - * higher layers. + * This implementation returns the resolved leaf credential with the exact + * immutable chain path selected and persisted when the credential was issued. + * It never infers a path from collection order, dates, filenames, or the public + * repository's independently managed current-chain alias. *

* * @param command bundle construction command identifying the leaf credential; * must not be {@code null} - * @return minimal credential bundle containing the resolved leaf credential and - * no chain elements + * @return credential bundle containing the resolved leaf and its exact issuance path * @throws NullPointerException if {@code command} is {@code null} * @throws PkiException if the requested credential does not exist in * the store or is not currently usable @@ -619,8 +603,18 @@ public final class DefaultIssuanceService implements IssuanceService { throw new PkiException( "Credential trust rejected: code=" + StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE); } - // Minimal bundle: leaf only. Chain selection and publication are higher-layer - // concerns. - return new CredentialBundle(leaf, List.of()); + zeroecho.pki.api.ca.IssuerChainPath path = store.getIssuerChainPath(leaf.issuerRef().chainPathId()) + .orElseThrow(() -> new PkiException("Credential issuance chain path not found")); + if (!leaf.issuerRef().issuerId().equals(path.issuerId()) + || !leaf.issuerRef().caId().equals(path.authorityId())) { + throw new PkiException("Credential issuance chain path mismatch"); + } + return new CredentialBundle(leaf, pathContent(path)); + } + + private List pathContent( + zeroecho.pki.api.ca.IssuerChainPath path) { + return path.orderedCredentialIds().stream().map(this::requireIssuerCredential) + .map(Credential::content).toList(); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java index a71d4c0..35c9790 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java @@ -214,9 +214,6 @@ public final class DefaultStatusObjectService implements StatusObjectService { if (ca.state() != CaState.ACTIVE) { throw new PkiException("Issuer CA not ACTIVE"); } - if (ca.credentialIds().isEmpty()) { - throw new PkiException("Issuer CA has no credentials"); - } EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation); @@ -488,35 +485,17 @@ public final class DefaultStatusObjectService implements StatusObjectService { private Credential selectIssuerCredential(CaRecord ca, StatusObjectGenerateCommand command, EffectiveCredentialStatusResolver.Evaluation evaluation) { - Credential lastRejected = null; - EffectiveCredentialStatus lastStatus = null; - List credentialIds = ca.credentialIds(); - for (int index = credentialIds.size() - 1; index >= 0; index--) { - Credential credential = store.getCredential(credentialIds.get(index)) - .orElseThrow(DefaultStatusObjectService::crlGenerationFailure); - if (credential == null || !command.formatId().equals(credential.formatId())) { - continue; - } - EffectiveCredentialStatus status; - try { - status = evaluation.resolve(credential); - } catch (PkiException exception) { - CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, - CredentialUse.STATUS_OBJECT_ISSUER, - StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null); - throw exception; - } - if (status == EffectiveCredentialStatus.USABLE) { - return credential; - } - lastRejected = credential; - lastStatus = status; + Credential credential = IssuerAuthorities.currentCredential(store, ca); + if (!command.formatId().equals(credential.formatId())) { + throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } - if (lastRejected != null) { - CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, - CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus); + EffectiveCredentialStatus status = evaluation.resolve(credential); + if (status != EffectiveCredentialStatus.USABLE) { + CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, + CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", status); + throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } - throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); + return credential; } /** diff --git a/pki/src/main/java/zeroecho/pki/impl/core/IssuerAuthorities.java b/pki/src/main/java/zeroecho/pki/impl/core/IssuerAuthorities.java new file mode 100644 index 0000000..e7e9222 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/IssuerAuthorities.java @@ -0,0 +1,110 @@ +/******************************************************************************* + * 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.core; + +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +import zeroecho.pki.api.IssuerRef; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.ca.IssuerGenerationState; +import zeroecho.pki.api.credential.CaProfileBinding; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.spi.store.PkiStore; + +/** Exact issuer-generation and chain-path composition shared by PKI services. */ +final class IssuerAuthorities { + private IssuerAuthorities() { + // utility + } + + /* default */ static IssuerGeneration current(PkiStore store, CaRecord authority) { + IssuerGeneration generation = store.getIssuerGeneration(authority.currentIssuanceIssuerId()) + .orElseThrow(() -> new PkiException("Current issuer generation not found")); + if (!authority.caId().equals(generation.authorityId()) || generation.state() != IssuerGenerationState.ACTIVE) { + throw new PkiException("Current issuer generation unavailable"); + } + return generation; + } + + /* default */ static IssuerChainPath issuancePath(PkiStore store, CaRecord authority) { + IssuerChainPath path = store.getIssuerChainPath(authority.issuanceChainPathId()) + .orElseThrow(() -> new PkiException("Issuance chain path not found")); + if (!authority.caId().equals(path.authorityId()) + || !authority.currentIssuanceIssuerId().equals(path.issuerId())) { + throw new PkiException("Issuance chain selection mismatch"); + } + return path; + } + + /* default */ static Credential currentCredential(PkiStore store, CaRecord authority) { + IssuerGeneration generation = current(store, authority); + return store.getCredential(generation.credentialId()) + .orElseThrow(() -> new PkiException("Current issuer credential not found")); + } + + /* default */ static IssuerGeneration generation(PkiId authorityId, zeroecho.pki.api.KeyRef keyRef, + Credential credential) { + CertificateProfileRef profile = ((CaProfileBinding) credential.profileBinding()).reference(); + String profileCommitment = HexFormat.of().formatHex(profile.canonicalSha256()); + return new IssuerGeneration(IssuerGeneration.idFor(authorityId, credential.credentialId()), authorityId, + credential.credentialId(), keyRef, IssuerGenerationState.ACTIVE, profileCommitment, + profileCommitment); + } + + /* default */ static Credential withIssuer(Credential credential, IssuerRef issuerRef) { + Objects.requireNonNull(credential, "credential"); + return new Credential(credential.credentialId(), credential.formatId(), issuerRef, credential.subjectRef(), + credential.validity(), credential.serialOrUniqueId(), credential.publicKeyId(), + credential.profileBinding(), credential.status(), credential.content(), credential.attributes()); + } + + /* default */ static IssuerChainPath rootPath(PkiId authorityId, PkiId issuerId, PkiId credentialId) { + return IssuerChainPath.create(authorityId, issuerId, List.of(credentialId)); + } + + /* default */ static IssuerChainPath childPath(PkiId authorityId, PkiId issuerId, PkiId credentialId, + IssuerChainPath parentPath) { + List credentials = new java.util.ArrayList<>(); + credentials.add(credentialId); + credentials.addAll(parentPath.orderedCredentialIds()); + return IssuerChainPath.create(authorityId, issuerId, credentials); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index cd7adfa..2eb9433 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -73,10 +73,16 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Stream; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; + + import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.WorkflowStateRecord; @@ -167,12 +173,12 @@ import zeroecho.pki.spi.store.RevocationHistory; */ @SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods", "PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl", - "PMD.PreserveStackTrace" }) + "PMD.PreserveStackTrace", "PMD.NcssCount" }) public final class FilesystemPkiStore implements PkiStore, Closeable { private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); - /* package */ static final String CURRENT_STORE_VERSION = "v4"; + /* package */ static final String CURRENT_STORE_VERSION = "v5"; private static final String SIGN_RECORD_NAMESPACE = "io.zeroecho.pki.signing-record"; private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner"; private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record"; @@ -580,6 +586,101 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { .toList(); } + @Override + public List listCasPage(Optional afterCaId, int limit) { + requireStoreUsable(); + Objects.requireNonNull(afterCaId, "afterCaId"); + if (limit <= 0 || limit > 1_000) { + throw new IllegalArgumentException("limit must be between 1 and 1000"); + } + Path root = paths.root().resolve("cas").resolve("by-id"); + if (!Files.isDirectory(root)) { + return List.of(); + } + String after = afterCaId.map(PkiId::value).orElse(""); + List selected = new ArrayList<>(limit); + try (java.nio.file.DirectoryStream entries = Files.newDirectoryStream(root)) { + for (Path entry : entries) { + Path current = entry.resolve(FsPaths.CURRENT_FILE); + if (!Files.isDirectory(entry) || !Files.isRegularFile(current)) { + continue; + } + CaRecord record = validateCaCredentialReferences( + FsCodec.decode(FsCodec.CA_RECORD, FsOperations.readAll(current), stagedContent)); + if (record.caId().value().compareTo(after) <= 0) { + continue; + } + int position = java.util.Collections.binarySearch(selected, record, + Comparator.comparing(value -> value.caId().value())); + selected.add(position < 0 ? -position - 1 : position, record); + if (selected.size() > limit) { + selected.remove(limit); + } + } + return List.copyOf(selected); + } catch (IOException exception) { + throw new IllegalStateException("list CA page failed", exception); + } + } + + @Override + public void putIssuerGeneration(IssuerGeneration generation) { + requireStoreUsable(); + Objects.requireNonNull(generation, "generation"); + Credential credential = getCredential(generation.credentialId()) + .orElseThrow(() -> new IllegalStateException("Issuer credential reference is missing")); + if (!credential.credentialId().equals(generation.credentialId())) { + throw new IllegalStateException("Issuer credential identity mismatch"); + } + writeOnce(paths.issuerGenerationPath(generation.issuerId()), + FsCodec.encode(FsCodec.ISSUER_GENERATION, generation), "ISSUER_GENERATION", + FsUtil.safeId(generation.issuerId())); + } + + @Override + public Optional getIssuerGeneration(PkiId issuerId) { + requireStoreUsable(); + Objects.requireNonNull(issuerId, "issuerId"); + return readOptional(paths.issuerGenerationPath(issuerId), FsCodec.ISSUER_GENERATION) + .map(this::validateIssuerGeneration); + } + + @Override + public List listIssuerGenerations(PkiId authorityId) { + requireStoreUsable(); + Objects.requireNonNull(authorityId, "authorityId"); + Path root = paths.root().resolve("issuer-generations").resolve("by-id"); + return listBinaryRecords(root, FsCodec.ISSUER_GENERATION).stream().map(this::validateIssuerGeneration) + .filter(value -> authorityId.equals(value.authorityId())).toList(); + } + + @Override + public void putIssuerChainPath(IssuerChainPath path) { + requireStoreUsable(); + Objects.requireNonNull(path, "path"); + validateIssuerChainPath(path); + writeOnce(paths.issuerChainPath(path.pathId()), FsCodec.encode(FsCodec.ISSUER_CHAIN_PATH, path), + "ISSUER_CHAIN_PATH", FsUtil.safeId(path.pathId())); + } + + @Override + public Optional getIssuerChainPath(PkiId pathId) { + requireStoreUsable(); + Objects.requireNonNull(pathId, "pathId"); + return readOptional(paths.issuerChainPath(pathId), FsCodec.ISSUER_CHAIN_PATH) + .map(this::validateIssuerChainPath); + } + + @Override + public List listIssuerChainPaths(PkiId issuerId) { + requireStoreUsable(); + Objects.requireNonNull(issuerId, "issuerId"); + Path root = paths.root().resolve("issuer-chain-paths").resolve("by-id"); + return listBinaryRecords(root, FsCodec.ISSUER_CHAIN_PATH).stream() + .map(this::validateIssuerChainPath) + .filter(value -> issuerId.equals(value.issuerId())).toList(); + } + @Override public void putCredential(final Credential credential) { requireStoreUsable(); @@ -603,15 +704,112 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") private CaRecord validateCaCredentialReferences(CaRecord record) { - for (PkiId credentialId : record.credentialIds()) { - if (getCredential(credentialId).isEmpty()) { - throw new IllegalStateException("CA credential reference is missing"); + for (PkiId issuerId : record.issuerIds()) { + IssuerGeneration generation = getIssuerGeneration(issuerId) + .orElseThrow(() -> new IllegalStateException("CA issuer-generation reference is missing")); + if (!record.caId().equals(generation.authorityId())) { + throw new IllegalStateException("CA issuer-generation authority mismatch"); } } + IssuerChainPath issuancePath = getIssuerChainPath(record.issuanceChainPathId()) + .orElseThrow(() -> new IllegalStateException("CA issuance chain path is missing")); + if (!record.caId().equals(issuancePath.authorityId()) + || !record.currentIssuanceIssuerId().equals(issuancePath.issuerId())) { + throw new IllegalStateException("CA issuance selection mismatch"); + } return record; } + private IssuerGeneration validateIssuerGeneration(IssuerGeneration generation) { + getCredential(generation.credentialId()) + .orElseThrow(() -> new IllegalStateException("Issuer credential reference is missing")); + return generation; + } + + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + private IssuerChainPath validateIssuerChainPath(IssuerChainPath path) { + IssuerGeneration generation = getIssuerGeneration(path.issuerId()) + .orElseThrow(() -> new IllegalStateException("Issuer generation is missing")); + if (!path.authorityId().equals(generation.authorityId()) + || !path.orderedCredentialIds().get(0).equals(generation.credentialId())) { + throw new IllegalStateException("Issuer chain path generation mismatch"); + } + List credentials = path.orderedCredentialIds().stream() + .map(id -> getCredential(id).orElseThrow( + () -> new IllegalStateException("Issuer chain credential is missing"))).toList(); + long aggregate = 0L; + for (Credential credential : credentials) { + aggregate = Math.addExact(aggregate, credential.content().length()); + if (aggregate > 32L * 1024L * 1024L) { + throw new IllegalStateException("Issuer chain exceeds the aggregate artifact limit"); + } + } + try { + List holders = new ArrayList<>(credentials.size()); + for (Credential credential : credentials) { + holders.add(certificateHolder(credential)); + } + for (int index = 0; index + 1 < credentials.size(); index++) { + Credential credential = credentials.get(index); + Credential parent = credentials.get(index + 1); + X509CertificateHolder holder = holders.get(index); + X509CertificateHolder parentHolder = holders.get(index + 1); + IssuerGeneration signingGeneration = getIssuerGeneration(credential.issuerRef().issuerId()) + .orElseThrow(() -> new IllegalStateException("Issuer chain parent generation is missing")); + boolean parentGeneration = listIssuerGenerations(credential.issuerRef().caId()).stream() + .anyMatch(candidate -> parent.credentialId().equals(candidate.credentialId())); + if (!credential.issuerRef().caId().equals(signingGeneration.authorityId()) + || !parentGeneration + || !holder.getIssuer().equals(parentHolder.getSubject()) + || !holder.isSignatureValid(new JcaContentVerifierProviderBuilder() + .build(parentHolder.getSubjectPublicKeyInfo()))) { + throw new IllegalStateException("Issuer chain relationship is invalid"); + } + } + X509CertificateHolder anchor = holders.get(holders.size() - 1); + if (!anchor.getIssuer().equals(anchor.getSubject()) || !anchor.isSignatureValid( + new JcaContentVerifierProviderBuilder().build(anchor.getSubjectPublicKeyInfo()))) { + throw new IllegalStateException("Issuer chain trust anchor is invalid"); + } + } catch (IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalStateException("Issuer chain validation failed", exception); + } + return path; + } + + private X509CertificateHolder certificateHolder(Credential credential) throws IOException { + long length = credential.content().length(); + if (length <= 0L || length > 1024L * 1024L || length > Integer.MAX_VALUE) { + throw new IllegalStateException("Issuer certificate exceeds the artifact limit"); + } + byte[] encoded = new byte[(int) length]; + try (RepeatableContent content = stagedContent.openContent(credential.content()); + InputStream input = content.openStream()) { + int offset = 0; + while (offset < encoded.length) { + int count = input.read(encoded, offset, encoded.length - offset); + if (count < 0) { + throw new IOException("Issuer certificate is truncated"); + } + offset += count; + } + if (input.read() >= 0) { + throw new IOException("Issuer certificate length changed"); + } + X509CertificateHolder holder = new X509CertificateHolder(encoded); + if (!MessageDigest.isEqual(encoded, holder.getEncoded())) { + throw new IOException("Issuer certificate is not canonical DER"); + } + return holder; + } finally { + Arrays.fill(encoded, (byte) 0); + } + } + @Override public void putRequest(final ParsedCertificationRequest request) { requireStoreUsable(); @@ -2650,6 +2848,22 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + private List listBinaryRecords(final Path byIdDir, final FsCodec.Schema schema) { + if (!Files.isDirectory(byIdDir)) { + return List.of(); + } + try (Stream files = Files.list(byIdDir)) { + List records = new ArrayList<>(); + for (Path file : files.filter(Files::isRegularFile) + .sorted(Comparator.comparing(path -> path.getFileName().toString())).toList()) { + records.add(FsCodec.decode(schema, FsOperations.readAll(file), stagedContent)); + } + return List.copyOf(records); + } catch (IOException exception) { + throw new IllegalStateException("list immutable records failed", exception); + } + } + private static void writeOnce(final Path target, final byte[] data, final String kind, final String safeId) { try { FsOperations.ensureDir(target.getParent()); diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java index 13d8b33..a41f2e8 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -64,6 +64,9 @@ import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.ca.CaKind; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaState; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.ca.IssuerGenerationState; import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialProfileBinding; @@ -112,7 +115,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore; final class FsCodec { /* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024; - /* package */ static final int CURRENT_CODEC_VERSION = 3; + /* package */ static final int CURRENT_CODEC_VERSION = 4; private static final int CODEC_MAGIC = 0x5A454346; private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES; @@ -127,6 +130,8 @@ final class FsCodec { private static final int DURABLE_CONTENT_VERSION = 1; private static final int TOP_PROFILE_VERSION = 11; private static final int TOP_ACTIVE_PROFILE_REF = 12; + private static final int TOP_ISSUER_GENERATION = 13; + private static final int TOP_ISSUER_CHAIN_PATH = 14; private static final int TYPE_STRING = 1; private static final int TYPE_BOOLEAN = 2; @@ -162,6 +167,7 @@ final class FsCodec { private static final int TYPE_PROFILE_REF = 72; private static final int TYPE_PROFILE_BINDING = 73; private static final int TYPE_DURABLE_CONTENT = 74; + private static final int TYPE_ISSUER_GENERATION_STATE_ENUM = 75; private static final int ATTRIBUTE_STRING = 1; private static final int ATTRIBUTE_BOOLEAN = 2; @@ -218,6 +224,19 @@ final class FsCodec { case 4 -> CaState.DISABLED; default -> throw unknownEnum("CaState", code); }); + private static final ValueSchema ISSUER_GENERATION_STATE = enumSchema( + TYPE_ISSUER_GENERATION_STATE_ENUM, value -> switch (value) { + case ACTIVE -> 1; + case RETIRED -> 2; + case COMPROMISED -> 3; + case DISABLED -> 4; + }, code -> switch (code) { + case 1 -> IssuerGenerationState.ACTIVE; + case 2 -> IssuerGenerationState.RETIRED; + case 3 -> IssuerGenerationState.COMPROMISED; + case 4 -> IssuerGenerationState.DISABLED; + default -> throw unknownEnum("IssuerGenerationState", code); + }); private static final ValueSchema CREDENTIAL_STATUS = enumSchema(TYPE_CREDENTIAL_STATUS_ENUM, value -> switch (value) { case ISSUED -> 1; @@ -307,8 +326,11 @@ final class FsCodec { (writer, value) -> writer.writeValue(STRING, value.value()), reader -> new SubjectRef(reader.readValue(STRING))); private static final ValueSchema ISSUER_REF = valueSchema(TYPE_ISSUER_REF, - (writer, value) -> writer.writeValue(PKI_ID, value.caId()), - reader -> new IssuerRef(reader.readValue(PKI_ID))); + (writer, value) -> { + writer.writeValue(PKI_ID, value.caId()); + writer.writeValue(PKI_ID, value.issuerId()); + writer.writeValue(PKI_ID, value.chainPathId()); + }, reader -> new IssuerRef(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID))); private static final ValueSchema FORMAT_ID = valueSchema(TYPE_FORMAT_ID, (writer, value) -> writer.writeValue(STRING, value.value()), reader -> new FormatId(reader.readValue(STRING))); @@ -377,13 +399,19 @@ final class FsCodec { "PROFILE_VERSION", valueSchema(109, FsCodec::writeProfileVersion, FsCodec::readProfileVersion)); /* package */ static final Schema ACTIVE_PROFILE_REF = topLevel(TOP_ACTIVE_PROFILE_REF, "ACTIVE_PROFILE_REF", PROFILE_REF); + /* package */ static final Schema ISSUER_GENERATION = topLevel(TOP_ISSUER_GENERATION, + "ISSUER_GENERATION", valueSchema(110, FsCodec::writeIssuerGeneration, FsCodec::readIssuerGeneration)); + /* package */ static final Schema ISSUER_CHAIN_PATH = topLevel(TOP_ISSUER_CHAIN_PATH, + "ISSUER_CHAIN_PATH", valueSchema(111, FsCodec::writeIssuerChainPath, FsCodec::readIssuerChainPath)); private static final Map> TOP_LEVEL_SCHEMAS = Map.ofEntries(Map.entry(TOP_CA_RECORD, CA_RECORD), Map.entry(TOP_CREDENTIAL, CREDENTIAL), Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST), Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), Map.entry(TOP_POLICY_TRACE, POLICY_TRACE), Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD), - Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF)); + Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF), + Map.entry(TOP_ISSUER_GENERATION, ISSUER_GENERATION), + Map.entry(TOP_ISSUER_CHAIN_PATH, ISSUER_CHAIN_PATH)); private FsCodec() { // utility @@ -616,12 +644,44 @@ final class FsCodec { writer.writeValue(CA_STATE, value.state()); writer.writeValue(KEY_REF, value.issuerKeyRef()); writer.writeValue(SUBJECT_REF, value.subjectRef()); - writer.writeValue(PKI_IDS, value.credentialIds()); + writer.writeValue(PKI_IDS, value.issuerIds()); + writer.writeValue(PKI_ID, value.currentIssuanceIssuerId()); + writer.writeValue(PKI_ID, value.issuanceChainPathId()); } private static CaRecord readCaRecord(Reader reader) throws IOException { return new CaRecord(reader.readValue(PKI_ID), reader.readValue(CA_KIND), reader.readValue(CA_STATE), - reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(PKI_IDS)); + reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(PKI_IDS), + reader.readValue(PKI_ID), reader.readValue(PKI_ID)); + } + + private static void writeIssuerGeneration(Writer writer, IssuerGeneration value) throws IOException { + writer.writeValue(PKI_ID, value.issuerId()); + writer.writeValue(PKI_ID, value.authorityId()); + writer.writeValue(PKI_ID, value.credentialId()); + writer.writeValue(KEY_REF, value.signingKeyRef()); + writer.writeValue(ISSUER_GENERATION_STATE, value.state()); + writer.writeValue(STRING, value.profilePolicyCommitment()); + writer.writeValue(STRING, value.x509BindingCommitment()); + } + + private static IssuerGeneration readIssuerGeneration(Reader reader) throws IOException { + return new IssuerGeneration(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID), + reader.readValue(KEY_REF), reader.readValue(ISSUER_GENERATION_STATE), reader.readValue(STRING), + reader.readValue(STRING)); + } + + private static void writeIssuerChainPath(Writer writer, IssuerChainPath value) throws IOException { + writer.writeValue(PKI_ID, value.pathId()); + writer.writeValue(PKI_ID, value.authorityId()); + writer.writeValue(PKI_ID, value.issuerId()); + writer.writeValue(PKI_IDS, value.orderedCredentialIds()); + writer.writeValue(STRING, value.pathCommitment()); + } + + private static IssuerChainPath readIssuerChainPath(Reader reader) throws IOException { + return new IssuerChainPath(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID), + reader.readValue(PKI_IDS), reader.readValue(STRING)); } private static void writeParsedRequest(Writer writer, ParsedCertificationRequest value) throws IOException { diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java index 44a1b28..7c4e0e7 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -125,6 +125,16 @@ final class FsPaths { return caDir(caId).resolve(HISTORY_DIR); } + /* default */ Path issuerGenerationPath(final PkiId issuerId) { + Objects.requireNonNull(issuerId, "issuerId"); + return root.resolve("issuer-generations").resolve(BY_ID).resolve(FsUtil.safeId(issuerId) + BINARY_EXTENSION); + } + + /* default */ Path issuerChainPath(final PkiId pathId) { + Objects.requireNonNull(pathId, "pathId"); + return root.resolve("issuer-chain-paths").resolve(BY_ID).resolve(FsUtil.safeId(pathId) + BINARY_EXTENSION); + } + // ------------------------------------------------------------------------- // Profiles (immutable versions plus one active pointer) // ------------------------------------------------------------------------- diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java index f3331b1..10b61a6 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -60,6 +60,8 @@ import zeroecho.core.io.CancellationSignal; import zeroecho.core.io.RepeatableContent; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.publication.PublicationCursor; @@ -219,12 +221,18 @@ final class FsSnapshotExporter { private SnapshotAuthority plan(Instant at) throws IOException { CredentialInventory inventory = inventoryCredentials(); List cas = selectCas(at, inventory.credentials()); + List generations = cas.stream().flatMap(ca -> ca.issuerIds().stream()) + .map(id -> source.getIssuerGeneration(id).orElseThrow( + () -> new SnapshotAuthorityFailure("Snapshot issuer generation is missing"))).toList(); + List paths = generations.stream() + .flatMap(generation -> source.listIssuerChainPaths(generation.issuerId()).stream()).toList(); List statuses = source.snapshotStatusObjects(); Set remintedContentIds = new HashSet<>(inventory.contentIds()); for (StatusObject status : statuses) { remintedContentIds.add(status.content().contentId()); } - return new SnapshotAuthority(cas, inventory.credentials(), statuses, remintedContentIds); + return new SnapshotAuthority(cas, generations, paths, inventory.credentials(), statuses, + remintedContentIds); } private CredentialInventory inventoryCredentials() throws IOException { @@ -277,7 +285,7 @@ final class FsSnapshotExporter { List selected = new ArrayList<>(); for (Path record : selectedRecords) { CaRecord ca = loadCa(record); - if (ca != null && credentials.keySet().containsAll(ca.credentialIds())) { + if (ca != null && validCa(ca, credentials)) { selected.add(ca); } else if (ca != null) { rejectCa(); @@ -286,6 +294,24 @@ final class FsSnapshotExporter { return List.copyOf(selected); } + private boolean validCa(CaRecord ca, Map credentials) { + try { + for (PkiId issuerId : ca.issuerIds()) { + IssuerGeneration generation = source.getIssuerGeneration(issuerId).orElseThrow(); + if (!ca.caId().equals(generation.authorityId()) + || !credentials.containsKey(generation.credentialId())) { + return false; + } + } + IssuerChainPath path = source.getIssuerChainPath(ca.issuanceChainPathId()).orElseThrow(); + return ca.caId().equals(path.authorityId()) + && ca.currentIssuanceIssuerId().equals(path.issuerId()) + && path.orderedCredentialIds().stream().allMatch(credentials::containsKey); + } catch (IllegalStateException | java.util.NoSuchElementException failure) { + return false; + } + } + private CaRecord loadCa(Path record) throws IOException { try { return FsCodec.decode(FsCodec.CA_RECORD, FsOperations.readAll(record), source.stagedContent()); @@ -448,8 +474,19 @@ final class FsSnapshotExporter { transferred.add(entry.getKey()); } } + for (IssuerGeneration generation : authority.generations()) { + if (transferred.contains(generation.credentialId())) { + target.putIssuerGeneration(generation); + } + } + for (IssuerChainPath path : authority.paths()) { + if (transferred.containsAll(path.orderedCredentialIds())) { + target.putIssuerChainPath(path); + } + } for (CaRecord ca : authority.cas()) { - if (transferred.containsAll(ca.credentialIds())) { + if (ca.issuerIds().stream().allMatch(id -> target.getIssuerGeneration(id).isPresent()) + && target.getIssuerChainPath(ca.issuanceChainPathId()).isPresent()) { persistCa(target, ca); } } @@ -789,10 +826,13 @@ final class FsSnapshotExporter { } } - private record SnapshotAuthority(List cas, Map credentials, + private record SnapshotAuthority(List cas, List generations, + List paths, Map credentials, List statuses, Set remintedContentIds) { private SnapshotAuthority { cas = List.copyOf(cas); + generations = List.copyOf(generations); + paths = List.copyOf(paths); credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials)); statuses = List.copyOf(statuses); remintedContentIds = Set.copyOf(remintedContentIds); diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java index f38e5c2..9426848 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -39,6 +39,8 @@ import java.util.Optional; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; @@ -105,8 +107,8 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable { *

* Implementations must store CA records atomically. Replacing an existing * record should be either fully visible or not visible at all. Every - * identifier in {@link CaRecord#credentialIds()} must resolve through - * {@link #getCredential(PkiId)} before the CA record is published. + * identifier in {@link CaRecord#issuerIds()} must resolve through + * {@link #getIssuerGeneration(PkiId)} before the CA record is published. *

* * @param record CA record (never {@code null}) @@ -139,6 +141,33 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable { */ List listCas(); + /** + * Returns a bounded canonical keyset page of CA records. + * + * @param afterCaId optional exclusive lower bound + * @param limit positive finite page size + * @return records ordered by canonical CA identity + */ + List listCasPage(Optional afterCaId, int limit); + + /** Persists one immutable canonical issuer-generation record. */ + void putIssuerGeneration(IssuerGeneration generation); + + /** Retrieves one canonical issuer-generation record. */ + Optional getIssuerGeneration(PkiId issuerId); + + /** Lists issuer generations owned by an exact logical authority. */ + List listIssuerGenerations(PkiId authorityId); + + /** Persists one immutable, already validated issuer chain path. */ + void putIssuerChainPath(IssuerChainPath path); + + /** Retrieves one immutable issuer chain path. */ + Optional getIssuerChainPath(PkiId pathId); + + /** Lists explicit paths for an exact issuer generation. */ + List listIssuerChainPaths(PkiId issuerId); + /** * Persists a credential record. * diff --git a/pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java b/pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java index 3502026..362df70 100644 --- a/pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java +++ b/pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java @@ -48,25 +48,25 @@ import zeroecho.pki.api.SubjectRef; final class CaRecordTest { @Test - void credentialIdentifiersAreOrderedImmutableAndUnique() { - System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique"); - PkiId first = new PkiId("credential-first"); - PkiId second = new PkiId("credential-second"); + void issuerIdentifiersAreOrderedImmutableAndUnique() { + System.out.println("issuerIdentifiersAreOrderedImmutableAndUnique"); + PkiId first = new PkiId("issuer-first"); + PkiId second = new PkiId("issuer-second"); List source = new ArrayList<>(List.of(first, second)); CaRecord record = record(source); source.clear(); - assertEquals(List.of(first, second), record.credentialIds()); + assertEquals(List.of(first, second), record.issuerIds()); assertThrows(UnsupportedOperationException.class, - () -> record.credentialIds().add(new PkiId("credential-third"))); + () -> record.issuerIds().add(new PkiId("issuer-third"))); assertThrows(IllegalArgumentException.class, () -> record(List.of(first, first))); assertThrows(IllegalArgumentException.class, () -> record(java.util.Arrays.asList(first, null))); - System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique...ok"); + System.out.println("issuerIdentifiersAreOrderedImmutableAndUnique...ok"); } - private static CaRecord record(List credentialIds) { + private static CaRecord record(List issuerIds) { return new CaRecord(new PkiId("ca-test"), CaKind.ROOT, CaState.ACTIVE, new KeyRef("key-test"), - new SubjectRef("CN=Test"), credentialIds); + new SubjectRef("CN=Test"), issuerIds, issuerIds.getFirst(), new PkiId("path-test")); } } diff --git a/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java b/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java index 851facb..f466de3 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java @@ -78,6 +78,8 @@ import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.Validity; import zeroecho.pki.api.ca.CaCreateCommand; import zeroecho.pki.api.ca.CaImportCommand; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.ca.IntermediateCreateCommand; @@ -140,7 +142,7 @@ final class CaProfileIssuanceEnforcementTest { assertEquals(holder.getSubject().toString(), intermediate.subjectRef().value()); assertEquals(intermediate.subjectRef(), credential.subjectRef()); assertEquals(intermediate.subjectRef(), additional.subjectRef()); - assertEquals(2, intermediate.credentialIds().size()); + assertEquals(2, intermediate.issuerIds().size()); } } @@ -225,7 +227,7 @@ final class CaProfileIssuanceEnforcementTest { .caCredential(runtime.caService().getCa(rootId), 0) .profileBinding(); assertEquals(1, issuerBinding.reference().profileVersion()); - assertEquals(1, runtime.caService().getCa(intermediateId).credentialIds().size()); + assertEquals(1, runtime.caService().getCa(intermediateId).issuerIds().size()); } } @@ -436,8 +438,18 @@ final class CaProfileIssuanceEnforcementTest { original.serialOrUniqueId(), original.publicKeyId(), new CaProfileBinding(wrongFormat), original.status(), original.content(), original.attributes()); runtime.store().putCredential(mutated); + IssuerGeneration originalGeneration = runtime.store() + .getIssuerGeneration(root.currentIssuanceIssuerId()).orElseThrow(); + IssuerGeneration generation = new IssuerGeneration( + IssuerGeneration.idFor(root.caId(), mutated.credentialId()), root.caId(), + mutated.credentialId(), originalGeneration.signingKeyRef(), originalGeneration.state(), + originalGeneration.profilePolicyCommitment(), originalGeneration.x509BindingCommitment()); + runtime.store().putIssuerGeneration(generation); + IssuerChainPath path = IssuerChainPath.create(root.caId(), generation.issuerId(), + List.of(mutated.credentialId())); + runtime.store().putIssuerChainPath(path); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(mutated.credentialId()))); + root.subjectRef(), List.of(generation.issuerId()), generation.issuerId(), path.pathId())); int signCount = runtime.submittedSignCount(); assertThrows(PkiException.class, @@ -521,7 +533,7 @@ final class CaProfileIssuanceEnforcementTest { new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, rootId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); assertEquals(signCount, runtime.submittedSignCount()); - assertEquals(1, runtime.caService().getCa(rootId).credentialIds().size()); + assertEquals(1, runtime.caService().getCa(rootId).issuerIds().size()); } } @@ -548,7 +560,7 @@ final class CaProfileIssuanceEnforcementTest { runtime.framework().formatId(), rootId, intermediateId, "intermediate-ca", Optional.of(invalid), new SimpleAttributeSet()))); assertEquals(signCount, runtime.submittedSignCount()); - assertEquals(1, runtime.caService().getCa(intermediateId).credentialIds().size()); + assertEquals(1, runtime.caService().getCa(intermediateId).issuerIds().size()); } } @@ -670,7 +682,7 @@ final class CaProfileIssuanceEnforcementTest { private static Credential onlyCredential(PkiTestRuntime runtime, PkiId caId) { CaRecord ca = runtime.caService().getCa(caId); - assertEquals(1, ca.credentialIds().size()); + assertEquals(1, ca.issuerIds().size()); return runtime.caCredential(ca, 0); } diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java index 52b20b0..706b2a5 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java @@ -123,16 +123,11 @@ public final class PkiCoreE2eTest { } @Test - void everyIssuerPathSkipsEarlierUnusableCredentialAndSelectsLaterUsable(@TempDir Path tempDir) throws Exception { + void explicitIssuerSelectionNeverFallsBackByCollectionOrder(@TempDir Path tempDir) throws Exception { KeyPair rootKey = genRsa(); - KeyPair intermediateKey = genRsa(); - KeyPair nextIntermediateKey = genRsa(); KeyPair leafKey = genRsa(); KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:matrix-root"); - KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-intermediate"); - KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-next"); - Map keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey, - nextIntermediateKeyRef, nextIntermediateKey); + Map keys = Map.of(rootKeyRef, rootKey); try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), @@ -141,8 +136,7 @@ public final class PkiCoreE2eTest { Credential unusable = copyWithId(usable, new PkiId("credential:matrix-unusable")); runtime.store().putCredential(unusable); CaRecord root = runtime.caService().getCa(rootCaId); - runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(unusable.credentialId(), usable.credentialId()))); + runtime.selectIssuerCredential(root, unusable, true); List resolved = new ArrayList<>(); EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> { @@ -152,37 +146,64 @@ public final class PkiCoreE2eTest { }, false); CountingIssuerBackend backend = new CountingIssuerBackend(runtime.issuerBackend()); IssuanceService issuance = runtime.issuanceService(backend, resolver); - CaService caService = runtime.caService(backend, resolver); - StatusObjectService statusService = runtime.statusObjectService(resolver); ParsedCertificationRequest leafRequest = runtime.certificationRequestService() .parse(new CertificationRequest(runtime.framework().formatId(), new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Matrix Leaf").getEncoded()))); + assertThrows(PkiException.class, () -> issuance.issueEndEntity( + new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()))); + assertEquals(List.of(unusable.credentialId()), List.copyOf(resolved)); + resolved.clear(); + + runtime.caService().selectIssuancePath(rootCaId, root.currentIssuanceIssuerId(), + root.issuanceChainPathId(), "restore explicit test selection"); issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty())); - assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); - resolved.clear(); - - PkiId intermediateCaId = caService.createIntermediate(new IntermediateCreateCommand( - runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Matrix Intermediate"), - "intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes())); - assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); - resolved.clear(); - - caService.issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), - rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), emptyAttributes())); - assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); - resolved.clear(); - - runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(usable.credentialId(), unusable.credentialId()))); - statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL, - runtime.framework().formatId(), emptyAttributes())); - assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); + assertEquals(List.of(usable.credentialId()), List.copyOf(resolved)); assertEquals(1, backend.endEntityCalls.get()); - assertEquals(2, backend.intermediateCalls.get()); } } + @Test + void crossSignedIssuerPathsRemainExplicitAndDoNotChangeIssuanceSelection(@TempDir Path tempDir) + throws Exception { + System.out.println("crossSignedIssuerPathsRemainExplicitAndDoNotChangeIssuanceSelection"); + KeyPair rootKey = genRsa(); + KeyPair intermediateKey = genRsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:cross-root"); + KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:cross-intermediate"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { + PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Cross Root"), "root-ca", Optional.of(rootKeyRef), emptyAttributes())); + PkiId intermediateId = runtime.caService().createIntermediate(new IntermediateCreateCommand( + runtime.framework().formatId(), rootId, new SubjectRef("CN=Cross Intermediate"), + "intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes())); + CaRecord originalRoot = runtime.caService().getCa(rootId); + CaRecord intermediate = runtime.caService().getCa(intermediateId); + PkiId originalIssuancePath = intermediate.issuanceChainPathId(); + + Credential rootCredential = runtime.caCredential(originalRoot, 0); + Credential alternateRootCredential = copyWithId(rootCredential, + new PkiId("credential:cross-root-alternate")); + runtime.store().putCredential(alternateRootCredential); + CaRecord rotatedRoot = runtime.selectIssuerCredential(originalRoot, alternateRootCredential, true); + PkiId alternateRootPath = rotatedRoot.issuanceChainPathId(); + + zeroecho.pki.api.ca.IssuerChainPath alternate = runtime.caService().registerIssuerChainPath( + intermediateId, intermediate.currentIssuanceIssuerId(), alternateRootPath); + assertEquals(2, runtime.caService().listIssuerChainPaths( + intermediate.currentIssuanceIssuerId()).size()); + assertFalse(originalIssuancePath.equals(alternate.pathId())); + assertEquals(originalIssuancePath, + runtime.caService().getCa(intermediateId).issuanceChainPathId()); + assertEquals(alternateRootCredential.credentialId(), + alternate.orderedCredentialIds().getLast()); + System.out.println("...issuerId=" + intermediate.currentIssuanceIssuerId().value() + + " paths=2 selected=" + originalIssuancePath.value()); + } + System.out.println("...ok"); + } + @Test void permanentlyRevokedIssuerIsRejectedByEveryReachableTrustPath(@TempDir Path tempDir) throws Exception { KeyPair rootKey = genRsa(); @@ -202,7 +223,8 @@ public final class PkiCoreE2eTest { .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, new SubjectRef("CN=H6 Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes())); - PkiId rootCredentialId = runtime.caService().getCa(rootCaId).credentialIds().get(0); + PkiId rootCredentialId = runtime.store().getIssuerGeneration( + runtime.caService().getCa(rootCaId).currentIssuanceIssuerId()).orElseThrow().credentialId(); runtime.revocationService().revokePermanently(new RevocationCommand.RevokePermanently(rootCredentialId, RevocationReason.KEY_COMPROMISE, emptyAttributes())); int submissionsBeforeRejections = runtime.submittedSignCount(); @@ -231,7 +253,7 @@ public final class PkiCoreE2eTest { assertEquals(submissionsBeforeRejections, runtime.submittedSignCount()); assertTrue(runtime.store().getCredential(rootCredentialId).isPresent()); - assertTrue(runtime.caService().getCa(intermediateCaId).credentialIds().size() == 1); + assertTrue(runtime.caService().getCa(intermediateCaId).issuerIds().size() == 1); } } @@ -280,6 +302,10 @@ public final class PkiCoreE2eTest { .issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty())); assertNotNull(bundle); + assertEquals(1, bundle.supportingObjects().size()); + CredentialBundle rebuilt = issSvc.buildBundle(new BundleCommand( + bundle.credential().credentialId(), Optional.empty(), Optional.empty())); + assertEquals(bundle.supportingObjects(), rebuilt.supportingObjects()); System.out.println("...issuedCredentialId=" + bundle.credential().credentialId().value()); X509CertificateHolder eeCert = new X509CertificateHolder(runtime.credentialBytes(bundle.credential())); @@ -333,7 +359,7 @@ public final class PkiCoreE2eTest { int signCount = runtime.submittedSignCount(); int caCount = runtime.store().listCas().size(); int statusCount = runtime.store().listStatusObjects(rootCaId).size(); - int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).credentialIds().size(); + int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).issuerIds().size(); assertThrows(PkiException.class, () -> issuance .issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()))); @@ -356,7 +382,7 @@ public final class PkiCoreE2eTest { assertEquals(caCount, runtime.store().listCas().size()); assertEquals(statusCount, runtime.store().listStatusObjects(rootCaId).size()); assertEquals(intermediateCredentialCount, - runtime.caService().getCa(intermediateCaId).credentialIds().size()); + runtime.caService().getCa(intermediateCaId).issuerIds().size()); assertTrue(runtime.store().getCredential(rootCredential.credentialId()).isPresent()); assertFalse(runtime.auditSink().snapshot().toString().contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL")); } diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java index 920d923..e2d9e42 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java @@ -538,7 +538,7 @@ final class PkiProofGateE2eTest { runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); assertEquals(7, runtime.submittedSignCount()); - assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size()); assertTrue(runtime.store().listWorkflowStates().isEmpty()); } @@ -796,8 +796,7 @@ final class PkiProofGateE2eTest { original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), original.profileBinding(), CredentialStatus.REVOKED, original.content(), original.attributes()); runtime.store().putCredential(revoked); - runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(revoked.credentialId()))); + runtime.selectIssuerCredential(root, revoked, true); int before = runtime.submittedSignCount(); assertThrows(PkiException.class, () -> runtime.issuanceService() .issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty()))); @@ -810,14 +809,13 @@ final class PkiProofGateE2eTest { original.serialOrUniqueId(), original.publicKeyId(), original.profileBinding(), CredentialStatus.ISSUED, original.content(), original.attributes()); runtime.store().putCredential(expired); - runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(expired.credentialId()))); + runtime.selectIssuerCredential(root, expired, true); assertThrows(PkiException.class, () -> runtime.issuanceService() .issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty()))); assertEquals(before, runtime.submittedSignCount()); - runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(original.credentialId()))); + runtime.caService().selectIssuancePath(root.caId(), root.currentIssuanceIssuerId(), + root.issuanceChainPathId(), "restore explicit test selection"); ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet()); AttributeSet hostileAttributes = new AttributeSet() { @Override @@ -917,7 +915,7 @@ final class PkiProofGateE2eTest { Optional.empty(), new SimpleAttributeSet())), mutation.name()); assertTrue(rejected.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH"), mutation.name()); - assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), mutation.name()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size(), mutation.name()); if (produced.get() != null) { assertTrue(runtime.store().getCredential(produced.get().credentialId()).isEmpty(), mutation.name()); } @@ -942,7 +940,7 @@ final class PkiProofGateE2eTest { () -> wrongSubjectService.issueIntermediateCertificate( new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); - assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size()); CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { @Override @@ -967,7 +965,7 @@ final class PkiProofGateE2eTest { () -> invalidSignatureService.issueIntermediateCertificate( new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); - assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size()); for (IntermediateExtensionVariant variant : IntermediateExtensionVariant.values()) { CaService maliciousExtensionService = runtime @@ -977,7 +975,7 @@ final class PkiProofGateE2eTest { runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())), variant.name()); - assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), variant.name()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size(), variant.name()); } AtomicReference rawCredential = new AtomicReference<>(); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java index 9ccf09a..f920b09 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -51,6 +51,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.math.BigInteger; import java.time.Duration; import java.time.Instant; import java.util.Comparator; @@ -68,6 +71,10 @@ import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import zeroecho.core.io.CancellationSignal; import zeroecho.core.io.RepeatableContent; @@ -90,6 +97,9 @@ import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.ca.CaKind; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaState; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.ca.IssuerGenerationState; import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialStatus; @@ -557,7 +567,7 @@ public final class FilesystemPkiStoreTest { store.putCa(ca1); CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(), - ca1.credentialIds()); + ca1.issuerIds(), ca1.currentIssuanceIssuerId(), ca1.issuanceChainPathId()); store.putCa(ca2); Optional loaded = store.getCa(ca1.caId()); @@ -571,28 +581,42 @@ public final class FilesystemPkiStoreTest { System.out.println("caHistoryCreatesCurrentAndHistory...ok"); } + @Test + void caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding() throws Exception { + System.out.println("caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding"); + Path root = tmp.resolve("store-ca-keyset-page"); + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + CaRecord first = TestObjects.minimalCaRecord(store, "ca:z", CaState.ACTIVE); + CaRecord second = TestObjects.minimalCaRecord(store, "ca:zz", CaState.ACTIVE); + store.putCa(first); + store.putCa(second); + assertEquals(List.of(first.caId()), store.listCasPage(Optional.empty(), 1).stream() + .map(CaRecord::caId).toList()); + assertEquals(List.of(second.caId()), store.listCasPage(Optional.of(first.caId()), 1).stream() + .map(CaRecord::caId).toList()); + System.out.println("...after=" + first.caId() + " next=" + second.caId()); + } + System.out.println("caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding...ok"); + } + @Test void caReferencesRequireValidStandaloneCredentialsAndPreserveOrder() throws Exception { System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder"); Path root = tmp.resolve("store-ca-authority"); try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { - Credential first = TestObjects.minimalCredential(store, "SERIAL-FIRST", "profile-ca"); - Credential second = TestObjects.minimalCredential(store, "SERIAL-SECOND", "profile-ca"); - store.putCredential(first); - store.putCredential(second); - CaRecord ordered = new CaRecord(new PkiId("ca-ordered"), CaKind.ROOT, CaState.ACTIVE, - new KeyRef("key-ordered"), new SubjectRef("CN=Ordered"), - List.of(second.credentialId(), first.credentialId())); + CaRecord ordered = TestObjects.minimalCaRecord(store, "ca-ordered", CaState.ACTIVE); store.putCa(ordered); - assertEquals(List.of(second.credentialId(), first.credentialId()), - store.getCa(ordered.caId()).orElseThrow().credentialIds()); + assertEquals(ordered.issuerIds(), store.getCa(ordered.caId()).orElseThrow().issuerIds()); + PkiId missingIssuer = new PkiId("issuer-missing"); CaRecord missing = new CaRecord(new PkiId("ca-missing"), CaKind.ROOT, CaState.ACTIVE, new KeyRef("key-missing"), new SubjectRef("CN=Missing"), - List.of(new PkiId("credential-missing"))); + List.of(missingIssuer), missingIssuer, new PkiId("path-missing")); assertThrows(IllegalStateException.class, () -> store.putCa(missing)); - Files.write(root.resolve("staged-content").resolve(first.content().contentId() + ".content"), + Credential selected = store.getCredential(store.getIssuerGeneration( + ordered.currentIssuanceIssuerId()).orElseThrow().credentialId()).orElseThrow(); + Files.write(root.resolve("staged-content").resolve(selected.content().contentId() + ".content"), new byte[] { 9, 9, 9 }); assertThrows(IllegalStateException.class, () -> store.getCa(ordered.caId())); assertThrows(IllegalStateException.class, store::listCas); @@ -613,22 +637,20 @@ public final class FilesystemPkiStoreTest { } @Test - void snapshotReconstructsSharedCredentialOnceWithNewTargetReference() throws Exception { - System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference"); + void snapshotReconstructsIssuerGenerationsWithNewTargetReferences() throws Exception { + System.out.println("snapshotReconstructsIssuerGenerationsWithNewTargetReferences"); Path root = tmp.resolve("store-snapshot-authority"); Path snapshot = tmp.resolve("snapshot-authority"); FsPkiStoreOptions options = nonStrictSnapshotOptions(); PkiId credentialId; String sourceContentId; try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) { - Credential shared = TestObjects.minimalCredential(source, "SERIAL-SHARED", "profile-ca"); - source.putCredential(shared); - credentialId = shared.credentialId(); - sourceContentId = shared.content().contentId(); - source.putCa(new CaRecord(new PkiId("ca-shared-one"), CaKind.ROOT, CaState.ACTIVE, - new KeyRef("key-shared-one"), new SubjectRef("CN=Shared One"), List.of(credentialId))); - source.putCa(new CaRecord(new PkiId("ca-shared-two"), CaKind.ROOT, CaState.ACTIVE, - new KeyRef("key-shared-two"), new SubjectRef("CN=Shared Two"), List.of(credentialId))); + CaRecord first = TestObjects.minimalCaRecord(source, "ca-shared-one", CaState.ACTIVE); + CaRecord second = TestObjects.minimalCaRecord(source, "ca-shared-two", CaState.ACTIVE); + source.putCa(first); + source.putCa(second); + credentialId = source.getIssuerGeneration(first.currentIssuanceIssuerId()).orElseThrow().credentialId(); + sourceContentId = source.getCredential(credentialId).orElseThrow().content().contentId(); source.exportSnapshot(snapshot, Instant.now()); } @@ -636,16 +658,17 @@ public final class FilesystemPkiStoreTest { assertEquals(2, restored.listCas().size()); Credential credential = restored.getCredential(credentialId).orElseThrow(); assertFalse(sourceContentId.equals(credential.content().contentId())); - assertEquals(List.of(credentialId), - restored.getCa(new PkiId("ca-shared-one")).orElseThrow().credentialIds()); + CaRecord restoredAuthority = restored.getCa(new PkiId("ca-shared-one")).orElseThrow(); + assertEquals(credentialId, restored.getIssuerGeneration( + restoredAuthority.currentIssuanceIssuerId()).orElseThrow().credentialId()); } try (java.util.stream.Stream records = Files.list(snapshot.resolve("credentials").resolve("by-id"))) { - assertEquals(1, records.filter(Files::isRegularFile).count()); + assertEquals(2, records.filter(Files::isRegularFile).count()); } assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".content"))); assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".meta"))); assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".owners"))); - System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference...ok"); + System.out.println("snapshotReconstructsIssuerGenerationsWithNewTargetReferences...ok"); } @Test @@ -672,7 +695,8 @@ public final class FilesystemPkiStoreTest { Credential standalone = restored.getCredential(standaloneId).orElseThrow(); assertFalse(standaloneSourceContentId.equals(standalone.content().contentId())); CaRecord ca = restored.getCa(caId).orElseThrow(); - assertTrue(restored.getCredential(ca.credentialIds().get(0)).isPresent()); + assertTrue(restored.getCredential(restored.getIssuerGeneration( + ca.currentIssuanceIssuerId()).orElseThrow().credentialId()).isPresent()); } System.out.println("snapshotPreservesStandaloneCredentialWithNewReferenceAndResolvableCa...ok"); } @@ -755,8 +779,8 @@ public final class FilesystemPkiStoreTest { } @Test - void snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa() throws Exception { - System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa"); + void snapshotFailsClosedForMissingIssuerAuthorityInEveryMode() throws Exception { + System.out.println("snapshotFailsClosedForMissingIssuerAuthorityInEveryMode"); Path root = tmp.resolve("store-snapshot-inconsistent"); Path strictSnapshot = tmp.resolve("snapshot-inconsistent-strict"); Path nonStrictSnapshot = tmp.resolve("snapshot-inconsistent-nonstrict"); @@ -766,20 +790,19 @@ public final class FilesystemPkiStoreTest { CaRecord inconsistent = TestObjects.minimalCaRecord(source, "ca-inconsistent", CaState.ACTIVE); source.putCa(valid); source.putCa(inconsistent); - Files.delete(new FsPaths(root).credentialPath(inconsistent.credentialIds().get(0))); + Files.delete(new FsPaths(root).credentialPath(source.getIssuerGeneration( + inconsistent.currentIssuanceIssuerId()).orElseThrow().credentialId())); FsPkiStoreOptions strict = strictSnapshotOptions(); assertThrows(IllegalStateException.class, () -> new FsSnapshotExporter(strict).exportSnapshot(source, strictSnapshot, Instant.now())); assertFalse(Files.exists(strictSnapshot)); - source.exportSnapshot(nonStrictSnapshot, Instant.now()); + assertThrows(IllegalStateException.class, () -> source.exportSnapshot(nonStrictSnapshot, Instant.now())); + assertFalse(Files.exists(nonStrictSnapshot)); } - try (FilesystemPkiStore restored = new FilesystemPkiStore(nonStrictSnapshot, nonStrict)) { - assertEquals(List.of("ca-valid"), restored.listCas().stream() - .map(ca -> ca.caId().value()).toList()); - } - System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa...ok"); + System.out.println("...missing-issuer-authority=rejected"); + System.out.println("snapshotFailsClosedForMissingIssuerAuthorityInEveryMode...ok"); } @Test @@ -1283,14 +1306,46 @@ public final class FilesystemPkiStoreTest { static CaRecord minimalCaRecord(PkiStore store, String caId, CaState state) throws IOException { PkiId id = new PkiId(caId); - KeyRef issuerKeyRef = new KeyRef("issuer-key-" + caId); SubjectRef subjectRef = new SubjectRef("CN=" + caId); - - Credential cred = minimalCredential(store, "CA-" + caId, "profile-ca"); - store.putCredential(cred); + PkiId credentialId = new PkiId("cred-ca-" + caId); + PkiId issuerId = IssuerGeneration.idFor(id, credentialId); + IssuerChainPath path = IssuerChainPath.create(id, issuerId, List.of(credentialId)); + Credential credential = caCredential(store, id, credentialId, issuerId, path.pathId(), subjectRef); + store.putCredential(credential); + String commitment = "0".repeat(64); + store.putIssuerGeneration(new IssuerGeneration(issuerId, id, credentialId, issuerKeyRef, + IssuerGenerationState.ACTIVE, commitment, commitment)); + store.putIssuerChainPath(path); return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, - List.of(cred.credentialId())); + List.of(issuerId), issuerId, path.pathId()); + } + + private static Credential caCredential(PkiStore store, PkiId authorityId, PkiId credentialId, + PkiId issuerId, PkiId pathId, SubjectRef subject) throws IOException { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + X500Name name = new X500Name(subject.value()); + Instant notBefore = Instant.parse("2020-01-01T00:00:00Z"); + Instant notAfter = Instant.parse("2030-01-01T00:00:00Z"); + X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name, BigInteger.ONE, + java.util.Date.from(notBefore), java.util.Date.from(notAfter), name, keyPair.getPublic()); + byte[] der = builder.build(new JcaContentSignerBuilder("SHA256withRSA") + .build(keyPair.getPrivate())).getEncoded(); + zeroecho.pki.api.content.DurableContentReference content = + zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER, der); + return new Credential(credentialId, new FormatId("fmt-x509"), + new IssuerRef(authorityId, issuerId, pathId), subject, + new Validity(notBefore, notAfter), "CA-" + authorityId.value(), + new PkiId("pk-" + authorityId.value()), + new CaProfileBinding(new CertificateProfileRef("profile-ca", 1, new byte[32])), + CredentialStatus.ISSUED, content, emptyAttributes()); + } catch (java.security.GeneralSecurityException | org.bouncycastle.operator.OperatorCreationException + exception) { + throw new IOException("CA fixture construction failed", exception); + } } static CertificateProfile minimalProfile(String profileId) { diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java index e85db0f..fde9f96 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java @@ -249,15 +249,16 @@ final class FsCodecTest { @Test void caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag() { System.out.println("caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag"); - PkiId first = new PkiId("credential-first"); - PkiId second = new PkiId("credential-second"); + PkiId first = new PkiId("issuer-first"); + PkiId second = new PkiId("issuer-second"); CaRecord original = new CaRecord(new PkiId("ca-codec"), CaKind.ROOT, CaState.ACTIVE, - new KeyRef("key-codec"), new SubjectRef("CN=Codec"), List.of(first, second)); + new KeyRef("key-codec"), new SubjectRef("CN=Codec"), List.of(first, second), first, + new PkiId("path-codec")); byte[] encoded = FsCodec.encode(FsCodec.CA_RECORD, original); CaRecord decoded = FsCodec.decode(FsCodec.CA_RECORD, encoded); assertEquals(original, decoded); - assertEquals(List.of(first, second), decoded.credentialIds()); + assertEquals(List.of(first, second), decoded.issuerIds()); byte[] embeddedValueTag = encoded.clone(); int firstIdentifier = indexOf(embeddedValueTag, first.value().getBytes(StandardCharsets.UTF_8)); diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java index f765f9b..c2a2445 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java +++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java @@ -209,10 +209,36 @@ public final class PkiTestRuntime implements AutoCloseable { } public Credential caCredential(zeroecho.pki.api.ca.CaRecord ca, int index) { - PkiId credentialId = ca.credentialIds().get(index); + PkiId credentialId = store.getIssuerGeneration(ca.issuerIds().get(index)).orElseThrow().credentialId(); return store.getCredential(credentialId).orElseThrow(); } + public zeroecho.pki.api.ca.CaRecord selectIssuerCredential(zeroecho.pki.api.ca.CaRecord authority, + Credential credential, boolean retainExisting) { + zeroecho.pki.api.ca.IssuerGeneration previous = store + .getIssuerGeneration(authority.currentIssuanceIssuerId()).orElseThrow(); + zeroecho.pki.api.ca.IssuerGeneration generation = new zeroecho.pki.api.ca.IssuerGeneration( + zeroecho.pki.api.ca.IssuerGeneration.idFor(authority.caId(), credential.credentialId()), + authority.caId(), credential.credentialId(), previous.signingKeyRef(), previous.state(), + previous.profilePolicyCommitment(), previous.x509BindingCommitment()); + store.putIssuerGeneration(generation); + zeroecho.pki.api.ca.IssuerChainPath oldPath = store + .getIssuerChainPath(authority.issuanceChainPathId()).orElseThrow(); + java.util.List credentials = new java.util.ArrayList<>(oldPath.orderedCredentialIds()); + credentials.set(0, credential.credentialId()); + zeroecho.pki.api.ca.IssuerChainPath path = zeroecho.pki.api.ca.IssuerChainPath.create(authority.caId(), + generation.issuerId(), credentials); + store.putIssuerChainPath(path); + java.util.List issuers = new java.util.ArrayList<>(); + if (retainExisting) issuers.addAll(authority.issuerIds()); + issuers.add(generation.issuerId()); + zeroecho.pki.api.ca.CaRecord updated = new zeroecho.pki.api.ca.CaRecord(authority.caId(), authority.kind(), + authority.state(), authority.issuerKeyRef(), authority.subjectRef(), issuers, + generation.issuerId(), path.pathId()); + store.putCa(updated); + return updated; + } + private record UntrustedReference(String storeId, String contentId, Encoding encoding, long length, String sha256, DurableContentReference.Lifecycle lifecycle) implements DurableContentReference { }