feat(pki-server): add public PKI repository API

Add the disclosure-controlled public certificate, chain, CRL and status
repository with capability-based unlisted access, bounded streaming,
conditional caching and isolated public execution resources.

Introduce authoritative issuer generations and explicit chain paths so
issuance bundles and stable public chain routes never rely on inferred
certificate ordering or runtime path guessing.
This commit is contained in:
2026-08-05 01:48:52 +02:00
parent 7328f075dd
commit c3bd3a33e9
58 changed files with 3950 additions and 311 deletions

View File

@@ -459,6 +459,11 @@ class PkiCliTest {
return (operation, cancellation) -> outcome(operation); return (operation, cancellation) -> outcome(operation);
} }
@Override
public zeroecho.pki.application.PkiRepository repository() {
throw new UnsupportedOperationException();
}
@Override @Override
public void close() throws Exception { public void close() throws Exception {
closes.incrementAndGet(); closes.incrementAndGet();

View File

@@ -1,5 +1,5 @@
{ {
"version": 2, "version": 3,
"serverName": "zeroecho-admin", "serverName": "zeroecho-admin",
"realm": { "realm": {
"realmId": "production", "realmId": "production",
@@ -74,5 +74,45 @@
"gracefulShutdownMillis": 30000, "gracefulShutdownMillis": 30000,
"forcedShutdownMillis": 10000 "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": {} "runtime": {}
} }

View File

@@ -1,5 +1,5 @@
{ {
"version": 2, "version": 3,
"serverName": "zeroecho-admin", "serverName": "zeroecho-admin",
"realm": { "realm": {
"realmId": "production", "realmId": "production",
@@ -74,5 +74,6 @@
"gracefulShutdownMillis": 30000, "gracefulShutdownMillis": 30000,
"forcedShutdownMillis": 10000 "forcedShutdownMillis": 10000
}, },
"publicListener": {"enabled": false},
"runtime": {} "runtime": {}
} }

View File

@@ -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 <one-time-issued-capability>
```
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.

View File

@@ -1,5 +1,5 @@
{ {
"version": 2, "version": 3,
"serverName": "zeroecho-admin-nginx", "serverName": "zeroecho-admin-nginx",
"realm": { "realm": {
"realmId": "production", "realmId": "production",
@@ -40,5 +40,6 @@
"maximumForwardedChainBytes": 524288 "maximumForwardedChainBytes": 524288
}, },
"execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000}, "execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000},
"publicListener": {"enabled":false},
"runtime": {} "runtime": {}
} }

View File

@@ -1,5 +1,5 @@
{ {
"version": 2, "version": 3,
"serverName": "zeroecho-admin-proxy", "serverName": "zeroecho-admin-proxy",
"realm": { "realm": {
"realmId": "production", "realmId": "production",
@@ -41,5 +41,31 @@
"maximumForwardedChainBytes": 524288 "maximumForwardedChainBytes": 524288
}, },
"execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000}, "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": {} "runtime": {}
} }

View File

@@ -49,7 +49,8 @@ import zeroecho.pki.api.PkiId;
/** Durable disclosure authority and capability-based retrieval decision service. */ /** Durable disclosure authority and capability-based retrieval decision service. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition", @SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition",
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" }) "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel",
"PMD.CyclomaticComplexity" })
public final class DisclosureService { public final class DisclosureService {
/** Durable one-time delivery classification without retaining the raw token. */ /** Durable one-time delivery classification without retaining the raw token. */
public enum DeliveryState { public enum DeliveryState {
@@ -267,18 +268,56 @@ public final class DisclosureService {
return switch (record.policy()) { return switch (record.policy()) {
case PUBLIC -> Decision.ALLOWED; case PUBLIC -> Decision.ALLOWED;
case PUBLIC_UNLISTED -> capabilityToken.filter(token -> validCapability(record.objectId(), token)) 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) case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled)
.filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC) .filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC)
.map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED); .map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED);
case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission
? Decision.ALLOWED : Decision.DENIED; ? Decision.ALLOWED : Decision.DENIED;
case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED; case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED;
case NOT_PUBLISHED -> explicitAdministrativePermission || ownerRelationship case NOT_PUBLISHED -> Decision.DENIED;
? Decision.ALLOWED : 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<SecurityPrincipal> principal,
boolean ownerRelationship, boolean explicitAdministrativePermission, Optional<String> 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. */ /** Issues 256 random capability bits and persists only their commitment. */
public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt, public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt,
String actorPrincipalId) { String actorPrincipalId) {
@@ -335,6 +374,32 @@ public final class DisclosureService {
return false; 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) { private byte[] capabilityCommitment(PkiId objectId, Instant expiry, byte[] token) {
try { try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); MessageDigest digest = MessageDigest.getInstance("SHA-256");

View File

@@ -140,7 +140,15 @@ public final class OperationSecurityDescriptors {
control(ServerControlOperation.RevokeCapability.NAME, Permission.Action.DISCLOSURE_CAPABILITY_REVOKE, Permission.ResourceType.CAPABILITY, true, false), 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.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.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. */ /** 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.RevokeCapability value -> "capability=" + atom(value.capabilityId());
case ServerControlOperation.InspectAuditView value -> "object=" + atom(value.objectId().value()) case ServerControlOperation.InspectAuditView value -> "object=" + atom(value.objectId().value())
+ ";view=" + value.view().name(); + ";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());
}; };
} }
} }

View File

@@ -70,7 +70,7 @@ public final class Permission {
AUDIT_READ_REDACTED(120), AUDIT_READ_FULL(121), AUDIT_READ_PII(122), AUDIT_EXPORT(123), 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), AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142),
DISCLOSURE_READ(160), DISCLOSURE_CHANGE(161), DISCLOSURE_CAPABILITY_ISSUE(162), 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; 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), 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), 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), 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; private final int code;
ResourceType(int code) { this.code = code; } ResourceType(int code) { this.code = code; }
/** @return stable code */ public int code() { return code; } /** @return stable code */ public int code() { return code; }

View File

@@ -38,6 +38,7 @@ import java.security.SecureRandom;
import java.time.Clock; import java.time.Clock;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
@@ -45,6 +46,7 @@ import javax.net.ssl.SSLContext;
import zeroecho.pki.application.PkiSessionRuntimeDependencies; import zeroecho.pki.application.PkiSessionRuntimeDependencies;
import zeroecho.pki.server.http.AdministrativeAuthenticator; import zeroecho.pki.server.http.AdministrativeAuthenticator;
import zeroecho.pki.server.http.PkiHttpsTransport; import zeroecho.pki.server.http.PkiHttpsTransport;
import zeroecho.pki.server.http.PublicRepositoryTransport;
import zeroecho.pki.server.spi.PkiServerAuthenticator; import zeroecho.pki.server.spi.PkiServerAuthenticator;
/** /**
@@ -64,15 +66,21 @@ public final class PkiHttpsServer implements AutoCloseable {
private final ServerRealmContext realm; private final ServerRealmContext realm;
private final PkiServerAuthenticator authenticator; private final PkiServerAuthenticator authenticator;
private final PkiHttpsTransport transport; private final PkiHttpsTransport transport;
private final Optional<PkiServerAuthenticator> publicAuthenticator;
private final Optional<PublicRepositoryTransport> publicTransport;
private final AtomicReference<State> state; private final AtomicReference<State> state;
private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm, private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm,
PkiServerAuthenticator authenticator, PkiHttpsTransport transport, PkiServerAuthenticator authenticator, PkiHttpsTransport transport,
Optional<PkiServerAuthenticator> publicAuthenticator,
Optional<PublicRepositoryTransport> publicTransport,
AtomicReference<State> state) { AtomicReference<State> state) {
this.configuration = configuration; this.configuration = configuration;
this.realm = realm; this.realm = realm;
this.authenticator = authenticator; this.authenticator = authenticator;
this.transport = transport; this.transport = transport;
this.publicAuthenticator = publicAuthenticator;
this.publicTransport = publicTransport;
this.state = state; this.state = state;
} }
@@ -101,6 +109,8 @@ public final class PkiHttpsServer implements AutoCloseable {
ServerRealmContext realm = null; ServerRealmContext realm = null;
PkiServerAuthenticator authenticator = null; PkiServerAuthenticator authenticator = null;
PkiHttpsTransport transport = null; PkiHttpsTransport transport = null;
PkiServerAuthenticator publicAuthenticator = null;
PublicRepositoryTransport publicTransport = null;
try { try {
SSLContext tls = PkiHttpsTransport.resolveTls(exact, loader); SSLContext tls = PkiHttpsTransport.resolveTls(exact, loader);
realm = ServerRealmContext.open(exact.realm(), runtimeDependencies, clock, random); realm = ServerRealmContext.open(exact.realm(), runtimeDependencies, clock, random);
@@ -119,11 +129,23 @@ public final class PkiHttpsServer implements AutoCloseable {
ServerRealmContext sharedRealm = realm; ServerRealmContext sharedRealm = realm;
transport = PkiHttpsTransport.startResolved(exact, sharedRealm, authenticator, clock, random, tls, transport = PkiHttpsTransport.startResolved(exact, sharedRealm, authenticator, clock, random, tls,
() -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN); () -> 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); state.set(State.READY);
realm.auditTransport("SERVER_READY", "system", Map.of("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) { } catch (RuntimeException | Error primary) {
closePartial(transport, realm, authenticator, state, primary); closePartial(publicTransport, publicAuthenticator, transport, realm, authenticator, state, primary);
throw primary; throw primary;
} }
} }
@@ -139,6 +161,12 @@ public final class PkiHttpsServer implements AutoCloseable {
return transport.address(); return transport.address();
} }
/** @return actual public listener address when the repository listener is enabled */
public Optional<InetSocketAddress> 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 */ /** @return the one lifecycle-owned realm context */
public ServerRealmContext realm() { public ServerRealmContext realm() {
if (state.get() != State.READY) throw new IllegalStateException("HTTPS server is not ready"); 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; primary = failure;
} }
transport.quiesce(); 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 { try {
transport.shutdown(configuration.execution().gracefulShutdown()); transport.shutdown(configuration.execution().gracefulShutdown());
} catch (Throwable failure) { } catch (Throwable failure) {
primary = suppress(primary, failure); primary = suppress(primary, failure);
} }
if (publicAuthenticator.isPresent()) primary = close(publicAuthenticator.orElseThrow(), primary);
primary = close(authenticator, primary); primary = close(authenticator, primary);
primary = close(realm, primary); primary = close(realm, primary);
state.set(State.TERMINATED); state.set(State.TERMINATED);
rethrow(primary); 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> state, Throwable primary) { PkiServerAuthenticator authenticator, AtomicReference<State> state, Throwable primary) {
primary = close(publicTransport, primary);
primary = close(publicAuthenticator, primary);
primary = close(transport, primary); primary = close(transport, primary);
primary = close(authenticator, primary); primary = close(authenticator, primary);
close(realm, primary); close(realm, primary);
state.set(State.TERMINATED); 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) { private static Throwable close(AutoCloseable resource, Throwable primary) {
if (resource == null) return primary; if (resource == null) return primary;
try { try {

View File

@@ -58,13 +58,15 @@ import zeroecho.pki.spi.ProviderConfig;
* @param authentication strict client-certificate mappings * @param authentication strict client-certificate mappings
* @param execution bounded execution and shutdown policy * @param execution bounded execution and shutdown policy
* @param runtime process-local capability references * @param runtime process-local capability references
* @param publicListener optional separately bounded public repository listener
*/ */
@SuppressWarnings("PMD") @SuppressWarnings("PMD")
public record PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm, 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> publicListener) {
/** Current server configuration schema. */ /** 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. */ /** Validates all security-sensitive fields before resource allocation. */
public PkiServerConfiguration { public PkiServerConfiguration {
@@ -75,9 +77,27 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
Objects.requireNonNull(authentication, "authentication"); Objects.requireNonNull(authentication, "authentication");
Objects.requireNonNull(execution, "execution"); Objects.requireNonNull(execution, "execution");
Objects.requireNonNull(runtime, "runtime"); Objects.requireNonNull(runtime, "runtime");
publicListener = Objects.requireNonNull(publicListener, "publicListener");
if (!listener.clientCertificateRequired()) { if (!listener.clientCertificateRequired()) {
throw new IllegalArgumentException("Administrative HTTPS requires client certificates"); 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<ProviderConfig> 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. * Process-local capability references.
* *
@@ -356,4 +423,11 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
throw new IllegalArgumentException(name + " is invalid"); 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");
}
}
} }

View File

@@ -85,11 +85,12 @@ public final class PkiServerConfigurationCodec {
/** Decodes one bounded strict configuration document. */ /** Decodes one bounded strict configuration document. */
public static PkiServerConfiguration decode(byte[] document) { public static PkiServerConfiguration decode(byte[] document) {
Fields root = Fields.of(StrictJson.parse(document, MAXIMUM_CONFIGURATION_BYTES)); 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"), PkiServerConfiguration configuration = new PkiServerConfiguration(root.integer("version"),
root.text("serverName"), realm(root.object("realm")), listener(root.object("listener")), root.text("serverName"), realm(root.object("realm")), listener(root.object("listener")),
authentication(root.object("authentication")), execution(root.object("execution")), authentication(root.object("authentication")), execution(root.object("execution")),
runtime(root.object("runtime"))); runtime(root.object("runtime")), publicListener(root.object("publicListener")));
root.complete(); root.complete();
return configuration; return configuration;
} }
@@ -271,6 +272,31 @@ public final class PkiServerConfigurationCodec {
return result; return result;
} }
private static Optional<PkiServerConfiguration.PublicListener> 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<ProviderConfig> 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) { private static PkiServerConfiguration.RuntimeCapabilities runtime(Fields value) {
value.allowed(Set.of("keyUnlockEnvironmentVariable")); value.allowed(Set.of("keyUnlockEnvironmentVariable"));
PkiServerConfiguration.RuntimeCapabilities result = new PkiServerConfiguration.RuntimeCapabilities( PkiServerConfiguration.RuntimeCapabilities result = new PkiServerConfiguration.RuntimeCapabilities(

View File

@@ -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<PkiId> 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<PkiId> 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<SecurityPrincipal> principal, Optional<String> 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<Authority> authorities(Optional<PkiId> after, int limit) {
openCheck.run();
if (limit <= 0 || limit > 1_000) {
throw new IllegalArgumentException("Public authority limit is invalid");
}
List<Authority> result = new ArrayList<>(limit);
Optional<PkiId> cursor = Objects.requireNonNull(after, "after");
while (result.size() < limit) {
List<CaRecord> 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<DisclosureService.Record> 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<Permission.Grant> 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) { }
}

View File

@@ -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<PkiId> issuerId, Optional<PkiId> 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<Record> 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<String> 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<String> 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<PkiId> issuerId,
Optional<PkiId> pathId, String targetCommitment, Optional<String> expected, String actor) {
Instant now = clock.instant();
String aliasId = idFor(realmId, authorityId, type);
Optional<Record> 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<PkiId> issuer, Optional<PkiId> 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<PkiId> issuer, Optional<PkiId> 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");
}
}
}

View File

@@ -56,7 +56,9 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
ServerControlOperation.InspectBreakGlass, ServerControlOperation.ListBreakGlass, ServerControlOperation.InspectBreakGlass, ServerControlOperation.ListBreakGlass,
ServerControlOperation.RevokeBreakGlass, ServerControlOperation.InspectDisclosure, ServerControlOperation.RevokeBreakGlass, ServerControlOperation.InspectDisclosure,
ServerControlOperation.SetDisclosure, ServerControlOperation.IssueCapability, ServerControlOperation.SetDisclosure, ServerControlOperation.IssueCapability,
ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView { ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView,
ServerControlOperation.InspectRepositoryAlias, ServerControlOperation.ListRepositoryAliases,
ServerControlOperation.SetRepositoryAlias, ServerControlOperation.RemoveRepositoryAlias {
/** @return stable operation identity */ /** @return stable operation identity */
String name(); String name();
@@ -271,6 +273,41 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
case METADATA_FULL, CONTENT_FULL -> FULL; case PII_FULL -> PII; }; } 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<PkiId> issuerId, Optional<String> 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) { private static void page(int offset, int limit) {
if (offset < 0 || limit <= 0 || limit > 256) throw invalid(); if (offset < 0 || limit <= 0 || limit > 256) throw invalid();
} }

View File

@@ -60,6 +60,7 @@ public final class ServerControlOperationExecutor {
private final BreakGlassService breakGlass; private final BreakGlassService breakGlass;
private final DisclosureService disclosure; private final DisclosureService disclosure;
private final AuditorViews auditorViews; private final AuditorViews auditorViews;
private final Optional<RepositoryAliasService> repositoryAliases;
private final OperationSecurityDescriptors descriptors; private final OperationSecurityDescriptors descriptors;
private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies; private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies;
@@ -68,6 +69,7 @@ public final class ServerControlOperationExecutor {
ServerControlStore store, RoleTemplateCatalog roles, AuthorizationEngine authorization, ServerControlStore store, RoleTemplateCatalog roles, AuthorizationEngine authorization,
ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure, ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
AuditorViews auditorViews, AuditorViews auditorViews,
Optional<RepositoryAliasService> repositoryAliases,
OperationSecurityDescriptors descriptors, OperationSecurityDescriptors descriptors,
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies) { Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies) {
this.realmId = Objects.requireNonNull(realmId, "realmId"); this.realmId = Objects.requireNonNull(realmId, "realmId");
@@ -79,6 +81,7 @@ public final class ServerControlOperationExecutor {
this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass"); this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass");
this.disclosure = Objects.requireNonNull(disclosure, "disclosure"); this.disclosure = Objects.requireNonNull(disclosure, "disclosure");
this.auditorViews = Objects.requireNonNull(auditorViews, "auditorViews"); this.auditorViews = Objects.requireNonNull(auditorViews, "auditorViews");
this.repositoryAliases = Objects.requireNonNull(repositoryAliases, "repositoryAliases");
this.descriptors = Objects.requireNonNull(descriptors, "descriptors"); this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies")); this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
} }
@@ -165,6 +168,29 @@ public final class ServerControlOperationExecutor {
case ServerControlOperation.RevokeCapability value -> ordinary(operation, case ServerControlOperation.RevokeCapability value -> ordinary(operation,
capability(disclosure.revokeCapability(value.capabilityId(), actor))); capability(disclosure.revokeCapability(value.capabilityId(), actor)));
case ServerControlOperation.InspectAuditView value -> ordinary(operation, auditView(value, 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()), return object("principalId", text(value.principalId()), "type", text(value.type().name()),
"displayName", text(value.displayName()), "enabled", bool(value.enabled())); "displayName", text(value.displayName()), "enabled", bool(value.enabled()));
} }
private static PkiOperationValue alias(RepositoryAliasService.Record value) {
Map<String, PkiOperationValue> 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) { private static PkiOperationValue template(RoleTemplateCatalog.Template value) {
List<PkiOperationValue> actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code)) List<PkiOperationValue> actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code))
.map(item -> (PkiOperationValue) text(item.name())).toList(); .map(item -> (PkiOperationValue) text(item.name())).toList();

View File

@@ -79,7 +79,7 @@ import zeroecho.pki.spi.store.TransactionalMetadataStore;
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel", "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel",
"PMD.ControlStatementBraces", "PMD.PreserveStackTrace", "PMD.CloseResource", "PMD.ControlStatementBraces", "PMD.PreserveStackTrace", "PMD.CloseResource",
"PMD.UseEnumCollections", "PMD.CommentRequired", "PMD.UncommentedEmptyMethodBody", "PMD.UseEnumCollections", "PMD.CommentRequired", "PMD.UncommentedEmptyMethodBody",
"PMD.TooManyMethods" }) "PMD.TooManyMethods", "PMD.ExcessivePublicCount" })
public final class ServerControlStore implements AutoCloseable { public final class ServerControlStore implements AutoCloseable {
/** Stable namespace for the realm record. */ public static final String REALM = "io.zeroecho.server.realm"; /** 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"; /** 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 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 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 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 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_RECORD_BYTES = 1_048_576;
private static final int MAXIMUM_STRING_BYTES = 16_384; private static final int MAXIMUM_STRING_BYTES = 16_384;
private static final int MAXIMUM_COLLECTION = 4_096; 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_BREAK_GLASS = 6;
private static final int KIND_DISCLOSURE = 7; private static final int KIND_DISCLOSURE = 7;
private static final int KIND_CAPABILITY = 8; private static final int KIND_CAPABILITY = 8;
private static final int KIND_REPOSITORY_ALIAS = 9;
/** /**
* Durable realm-control identity and commitments. * Durable realm-control identity and commitments.
@@ -308,6 +311,11 @@ public final class ServerControlStore implements AutoCloseable {
.orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable")); .orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable"));
} }
/** Finds one disclosure record without converting absence into an error. */
public synchronized Optional<DisclosureService.Record> findDisclosure(PkiId objectId) {
return read(DISCLOSURE, objectId.value(), KIND_DISCLOSURE, ServerControlStore::readDisclosure);
}
/** Returns a bounded stable page of disclosure records. */ /** Returns a bounded stable page of disclosure records. */
public synchronized Page<DisclosureService.Record> disclosures(int offset, int limit) { public synchronized Page<DisclosureService.Record> disclosures(int offset, int limit) {
return scanPage(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure, offset, 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(); .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<RepositoryAliasService.Record> 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<String> 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<RepositoryAliasService.Record> 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 * Strictly decodes and cross-checks every authoritative control record from
* stable metadata snapshots before the realm is exposed. * 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(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass);
scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure); scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure);
scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability); scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability);
scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias);
} }
/** Validates durable relationships against the active strict role catalog. */ /** 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)) { : scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability)) {
requirePrincipalReference(principals, capability.issuerPrincipalId()); 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. */ /** 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 BreakGlassService.Record item -> item.breakGlassId();
case DisclosureService.Record item -> item.objectId().value(); case DisclosureService.Record item -> item.objectId().value();
case DisclosureService.Capability item -> item.capabilityId(); case DisclosureService.Capability item -> item.capabilityId();
case RepositoryAliasService.Record item -> item.aliasId();
default -> throw new IllegalArgumentException("Unsupported control record type"); 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()); 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 { private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException {
writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId()); writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId());
writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId()); writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId());

View File

@@ -163,6 +163,7 @@ public final class ServerOperationGateway {
public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control, public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control,
RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals, RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals,
BreakGlassService breakGlass, DisclosureService disclosure, AuditorViews auditorViews, BreakGlassService breakGlass, DisclosureService disclosure, AuditorViews auditorViews,
Optional<RepositoryAliasService> repositoryAliases,
OperationSecurityDescriptors descriptors, PkiOperationExecutor executor, OperationSecurityDescriptors descriptors, PkiOperationExecutor executor,
PkiResourceScopeResolver resourceScopes, PkiResourceScopeResolver resourceScopes,
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies, Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies,
@@ -177,7 +178,8 @@ public final class ServerOperationGateway {
this.descriptors = Objects.requireNonNull(descriptors, "descriptors"); this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
this.executor = Objects.requireNonNull(executor, "executor"); this.executor = Objects.requireNonNull(executor, "executor");
this.controlExecutor = new ServerControlOperationExecutor(realmId, exposure, control, roles, 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.resourceScopes = Objects.requireNonNull(resourceScopes, "resourceScopes");
this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies")); this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
this.audit = new SafeAudit(clock, auditSink); this.audit = new SafeAudit(clock, auditSink);
@@ -194,7 +196,8 @@ public final class ServerOperationGateway {
this(realmId, exposure, control, roles, authorization, approvals, breakGlass, this(realmId, exposure, control, roles, authorization, approvals, breakGlass,
new DisclosureService(realmId, control, DisclosureService.Defaults.recommended(), clock, new DisclosureService(realmId, control, DisclosureService.Defaults.recommended(), clock,
new java.security.SecureRandom(), auditSink), new AuditorViews(clock, auditSink), 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);
} }
/** /**

View File

@@ -76,6 +76,8 @@ public final class ServerRealmContext implements AutoCloseable {
private final ApprovalService approvals; private final ApprovalService approvals;
private final BreakGlassService breakGlass; private final BreakGlassService breakGlass;
private final DisclosureService disclosure; private final DisclosureService disclosure;
private final RepositoryAliasService repositoryAliases;
private final PublicRepositoryGateway publicRepository;
private final AuditorViews auditorViews; private final AuditorViews auditorViews;
private final ServerOperationGateway gateway; private final ServerOperationGateway gateway;
private final SafeAudit audit; private final SafeAudit audit;
@@ -84,6 +86,7 @@ public final class ServerRealmContext implements AutoCloseable {
private ServerRealmContext(ServerRealmConfiguration configuration, ServerControlStore control, private ServerRealmContext(ServerRealmConfiguration configuration, ServerControlStore control,
PkiSession session, RoleTemplateCatalog roles, AuthorizationEngine authorization, PkiSession session, RoleTemplateCatalog roles, AuthorizationEngine authorization,
ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure, ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
RepositoryAliasService repositoryAliases,
AuditorViews auditorViews, AuditSink auditSink, Clock clock) { AuditorViews auditorViews, AuditSink auditSink, Clock clock) {
this.configuration = configuration; this.configuration = configuration;
this.control = control; this.control = control;
@@ -93,10 +96,15 @@ public final class ServerRealmContext implements AutoCloseable {
this.approvals = approvals; this.approvals = approvals;
this.breakGlass = breakGlass; this.breakGlass = breakGlass;
this.disclosure = disclosure; 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.auditorViews = auditorViews;
this.audit = new SafeAudit(clock, auditSink); this.audit = new SafeAudit(clock, auditSink);
this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(), this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(),
control, roles, authorization, approvals, breakGlass, disclosure, auditorViews, control, roles, authorization, approvals, breakGlass, disclosure, auditorViews,
Optional.of(repositoryAliases),
new OperationSecurityDescriptors(), new OperationSecurityDescriptors(),
session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink, session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink,
this::requireOpen); this::requireOpen);
@@ -143,9 +151,11 @@ public final class ServerRealmContext implements AutoCloseable {
BreakGlassService breakGlass = new BreakGlassService(control, clock, audit); BreakGlassService breakGlass = new BreakGlassService(control, clock, audit);
DisclosureService disclosure = new DisclosureService(exact.realmId(), control, DisclosureService disclosure = new DisclosureService(exact.realmId(), control,
exact.disclosureDefaults(), clock, random, audit); exact.disclosureDefaults(), clock, random, audit);
RepositoryAliasService aliases = new RepositoryAliasService(exact.realmId(), control,
session.repository(), disclosure, clock, audit);
AuditorViews views = new AuditorViews(clock, audit); AuditorViews views = new AuditorViews(clock, audit);
ServerRealmContext result = new ServerRealmContext(exact, control, session, roles, authorization, 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())); result.audit.record("REALM_OPEN", "system", Optional.empty(), Map.of("realm", exact.realmId().value()));
return result; return result;
} catch (RuntimeException | Error primary) { } catch (RuntimeException | Error primary) {
@@ -168,6 +178,10 @@ public final class ServerRealmContext implements AutoCloseable {
public BreakGlassService breakGlass() { requireOpen(); return breakGlass; } public BreakGlassService breakGlass() { requireOpen(); return breakGlass; }
/** @return durable disclosure decision service */ /** @return durable disclosure decision service */
public DisclosureService disclosure() { requireOpen(); return disclosure; } 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 */ /** @return explicit auditor projection service */
public AuditorViews auditorViews() { requireOpen(); return auditorViews; } public AuditorViews auditorViews() { requireOpen(); return auditorViews; }
/** @return authorized typed-operation gateway */ /** @return authorized typed-operation gateway */

View File

@@ -122,6 +122,35 @@ public final class AdministrativeAuthenticator implements PkiServerAuthenticator
return proxy(context); 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<String> 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) { private PkiServerAuthenticationResult direct(PkiServerAuthenticationContext context) {
if (ForwardedClientCertificateParser.containsForwardedIdentity(context.requestHeaders())) { if (ForwardedClientCertificateParser.containsForwardedIdentity(context.requestHeaders())) {
return rejected(AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(), return rejected(AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(),

View File

@@ -56,6 +56,7 @@ import zeroecho.pki.server.ApprovalService;
import zeroecho.pki.server.DisclosureService; import zeroecho.pki.server.DisclosureService;
import zeroecho.pki.server.Permission; import zeroecho.pki.server.Permission;
import zeroecho.pki.server.RealmId; import zeroecho.pki.server.RealmId;
import zeroecho.pki.server.RepositoryAliasService;
import zeroecho.pki.server.RoleTemplateCatalog; import zeroecho.pki.server.RoleTemplateCatalog;
import zeroecho.pki.server.SecurityPrincipal; import zeroecho.pki.server.SecurityPrincipal;
import zeroecho.pki.server.ServerControlOperation; import zeroecho.pki.server.ServerControlOperation;
@@ -366,6 +367,30 @@ final class HttpOperationCodec {
yield new ServerControlOperation.InspectAuditView(fields.pkiId("objectId"), view, yield new ServerControlOperation.InspectAuditView(fields.pkiId("objectId"), view,
fields.optionalText("reasonReference")); 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"); 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.EvaluateAuthorization value -> value.resource().scope();
case ServerControlOperation.RequestApproval value -> value.targetResource().scope(); case ServerControlOperation.RequestApproval value -> value.targetResource().scope();
case ServerControlOperation.CreateBreakGlass value -> value.grant().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()); 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.SetDisclosure value -> Optional.of(value.objectId());
case ServerControlOperation.IssueCapability value -> Optional.of(value.objectId()); case ServerControlOperation.IssueCapability value -> Optional.of(value.objectId());
case ServerControlOperation.InspectAuditView value -> Optional.of(value.objectId()); case ServerControlOperation.InspectAuditView value -> Optional.of(value.objectId());
case ServerControlOperation.SetRepositoryAlias value -> Optional.of(value.targetObjectId());
default -> Optional.empty(); default -> Optional.empty();
}; };
} }
private static Permission.Scope authorityScope(RealmId realmId, Optional<PkiId> 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) { private static boolean realmWide(PkiOperation operation) {
return operation instanceof PkiOperation.ListAuthorities return operation instanceof PkiOperation.ListAuthorities
|| operation instanceof PkiOperation.CreateAuthority || operation instanceof PkiOperation.CreateAuthority

View File

@@ -84,12 +84,12 @@ final class HttpResponses {
byte[] token = sensitive.consumeToken(); byte[] token = sensitive.consumeToken();
try { try {
MapBuilder result = new MapBuilder(sensitive.safeResult().fields()); 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() String capabilityId = ((PkiOperationValue.Text) sensitive.safeResult().fields()
.get("capabilityId")).value(); .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)); return new Response(encoded.statusCode(), encoded.body(), true, java.util.Optional.of(capabilityId));
} finally { } finally {
java.util.Arrays.fill(token, (byte) 0); java.util.Arrays.fill(token, (byte) 0);

View File

@@ -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<String> 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<Void> 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<PkiOperationValue> entries = realm.publicRepository().authorities(page.after(), page.limit()).stream()
.map(PublicRepositoryHttpHandler::authorityValue).map(PkiOperationValue.class::cast).toList();
Map<String, PkiOperationValue> 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<PkiId> 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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<String, List<String>> headers = authenticationHeaders(exchange.getRequestHeaders());
boolean forwarded = ForwardedClientCertificateParser.containsForwardedIdentity(headers);
AdministrativeAuthenticationMode mode = configuration.authentication().mode();
if (mode == AdministrativeAuthenticationMode.DIRECT_MTLS && forwarded) return Identity.rejected();
Optional<PkiServerAuthenticationContext> 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<PkiServerAuthenticationContext> tlsContext(HttpExchange exchange, String requestId,
Map<String, List<String>> headers) {
if (!(exchange instanceof HttpsExchange https)) return Optional.empty();
try {
Certificate[] peer = https.getSSLSession().getPeerCertificates();
List<X509Certificate> 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<String> values = headers.get("Authorization");
if (headers.containsKey("Cookie")) throw new AuthenticationFailure();
Optional<String> 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<String, List<String>> authenticationHeaders(Headers headers) {
java.util.Set<String> 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<String, List<String>> result = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> 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<String, List<String>> 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<String> 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<String> 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<PkiId> after, int limit) { }
private record Identity(boolean accepted, Optional<String> transportPrincipal,
Optional<SecurityPrincipal> 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()); }
}
}

View File

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

View File

@@ -54,6 +54,8 @@ import zeroecho.pki.server.PkiServerConfiguration;
final class ServerRuntime implements AutoCloseable { final class ServerRuntime implements AutoCloseable {
static final String TRANSPORT_PREFIX = "zeroecho-pki-https-"; static final String TRANSPORT_PREFIX = "zeroecho-pki-https-";
static final String OPERATION_PREFIX = "zeroecho-pki-operation-"; 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"; static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown";
private final ThreadPoolExecutor transport; private final ThreadPoolExecutor transport;
@@ -65,11 +67,15 @@ final class ServerRuntime implements AutoCloseable {
private final AtomicBoolean accepting = new AtomicBoolean(true); private final AtomicBoolean accepting = new AtomicBoolean(true);
ServerRuntime(PkiServerConfiguration.Execution configuration) { ServerRuntime(PkiServerConfiguration.Execution configuration) {
this(configuration, false);
}
ServerRuntime(PkiServerConfiguration.Execution configuration, boolean publicLane) {
this.configuration = configuration; this.configuration = configuration;
transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(), transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(),
new NamedThreadFactory(TRANSPORT_PREFIX)); new NamedThreadFactory(publicLane ? PUBLIC_TRANSPORT_PREFIX : TRANSPORT_PREFIX));
operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(), operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(),
new NamedThreadFactory(OPERATION_PREFIX)); new NamedThreadFactory(publicLane ? PUBLIC_STREAM_PREFIX : OPERATION_PREFIX));
admitted = new Semaphore(configuration.maximumAdmittedRequests(), true); admitted = new Semaphore(configuration.maximumAdmittedRequests(), true);
} }
@@ -132,8 +138,10 @@ final class ServerRuntime implements AutoCloseable {
quiesce(); quiesce();
transport.shutdown(); transport.shutdown();
operations.shutdown(); operations.shutdown();
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX); boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX)
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_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) { if (!operationWorker) {
await(operations, configuration.gracefulShutdown()); await(operations, configuration.gracefulShutdown());
} }

View File

@@ -126,6 +126,18 @@ public final class HttpServerTestSupport {
return configuration(directory, authentication); 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) private static PkiServerConfiguration.Authentication directAuthentication(X509Certificate client)
throws Exception { throws Exception {
PkiServerConfiguration.ClientCertificateMapping mapping = mapping("administrator-map", "administrator", PkiServerConfiguration.ClientCertificateMapping mapping = mapping("administrator-map", "administrator",

View File

@@ -43,6 +43,7 @@ import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.io.OutputStream;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Duration; import java.time.Duration;
import java.util.HexFormat; import java.util.HexFormat;
@@ -56,6 +57,28 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.application.PkiSessionRuntimeDependencies; 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.MutualTlsAuthenticator;
import zeroecho.pki.server.http.TestTlsProvider; import zeroecho.pki.server.http.TestTlsProvider;
import zeroecho.pki.server.spi.PkiServerAuthenticationContext; import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
@@ -79,6 +102,33 @@ class PkiHttpsServerTest {
"{\"version\":1,\"version\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8))); "{\"version\":1,\"version\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Execution(1, 1, 1, 1, assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Execution(1, 1, 1, 1,
1, Duration.ofSeconds(2), Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(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("...strict-security-fields=true");
System.out.println("...ok"); System.out.println("...ok");
} }
@@ -184,6 +234,124 @@ class PkiHttpsServerTest {
System.out.println("...ok"); 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<String> 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<byte[]> 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<Void> 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<Void> 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<String> 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<byte[]> 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 @Test
void packagedEntryPointProvidesHelpAndSafeValidationFailure() { void packagedEntryPointProvidesHelpAndSafeValidationFailure() {
System.out.println("packagedEntryPointProvidesHelpAndSafeValidationFailure"); System.out.println("packagedEntryPointProvidesHelpAndSafeValidationFailure");
@@ -321,6 +489,40 @@ class PkiHttpsServerTest {
System.out.println("...ok"); 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 { private void seed(PkiServerConfiguration configuration) throws Exception {
seed(configuration, true); seed(configuration, true);
} }
@@ -394,4 +596,8 @@ class PkiHttpsServerTest {
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString()); .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) { }
} }

View File

@@ -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<CaRecord> authority(PkiId id) {
return authority.caId().equals(id) ? Optional.of(authority) : Optional.empty();
}
@Override public List<CaRecord> authorities(Optional<PkiId> after, int limit) { return List.of(authority); }
@Override public Optional<IssuerGeneration> issuer(PkiId id) {
return issuer.issuerId().equals(id) ? Optional.of(issuer) : Optional.empty();
}
@Override public Optional<IssuerChainPath> chainPath(PkiId id) {
return path.pathId().equals(id) ? Optional.of(path) : Optional.empty();
}
@Override public Optional<Credential> credential(PkiId id) { return Optional.empty(); }
@Override public Optional<StatusObject> 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; }
};
}
}
}

View File

@@ -63,10 +63,10 @@ class ServerControlOperationExecutorTest {
void catalogContainsOneExplicitPairOfOperationFamilies() { void catalogContainsOneExplicitPairOfOperationFamilies() {
System.out.println("catalogContainsOneExplicitPairOfOperationFamilies"); System.out.println("catalogContainsOneExplicitPairOfOperationFamilies");
OperationSecurityDescriptors catalog = new OperationSecurityDescriptors(); OperationSecurityDescriptors catalog = new OperationSecurityDescriptors();
assertEquals(50, catalog.descriptors().size()); assertEquals(54, catalog.descriptors().size());
assertEquals(16, catalog.descriptors().values().stream() assertEquals(16, catalog.descriptors().values().stream()
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); .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) .filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION)
.count()); .count());
assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION, assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION,
@@ -211,7 +211,7 @@ class ServerControlOperationExecutorTest {
new AuthorizationEngine(ServerTestSupport.CLOCK), new AuthorizationEngine(ServerTestSupport.CLOCK),
new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit()),
new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), disclosure, 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)); Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, policy));
} }

View File

@@ -184,7 +184,7 @@ class ServerOperationGatewayTest {
void descriptorRegistryRejectsUnknownAndDuplicateOperations() { void descriptorRegistryRejectsUnknownAndDuplicateOperations() {
System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations"); System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations");
OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors(); OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors();
assertEquals(50, descriptors.descriptors().size()); assertEquals(54, descriptors.descriptors().size());
assertEquals(16, descriptors.descriptors().values().stream() assertEquals(16, descriptors.descriptors().values().stream()
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration())); assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration()));

View File

@@ -44,6 +44,8 @@ import zeroecho.pki.api.ca.CaRolloverCommand;
import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand; import zeroecho.pki.api.ca.IntermediateCreateCommand;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
/** /**
@@ -179,4 +181,27 @@ public interface CaService {
* @throws PkiException if listing fails * @throws PkiException if listing fails
*/ */
List<CaRecord> listCas(CaQuery query); List<CaRecord> 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<IssuerChainPath> 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);
} }

View File

@@ -34,21 +34,38 @@
package zeroecho.pki.api; 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 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. * Creates an issuer reference.
* *
* @param caId CA identifier * @throws IllegalArgumentException if either identifier is {@code null}
* @throws IllegalArgumentException if {@code caId} is null
*/ */
public IssuerRef { public IssuerRef {
if (caId == null) { if (caId == null) {
throw new IllegalArgumentException("caId must not be 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");
}
} }
} }

View File

@@ -33,17 +33,14 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api.ca; package zeroecho.pki.api.ca;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.SubjectRef;
/** /**
* Represents a CA entity and the ordered identifiers of its issued CA * Represents one logical CA authority and its explicit issuer generations.
* credentials.
* *
* <p> * <p>
* A CA entity may have multiple CA credentials to support: * 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 * @param issuerKeyRef key reference used for issuing operations (private key
* reference) * reference)
* @param subjectRef normalized subject reference * @param subjectRef normalized subject reference
* @param credentialIds ordered identifiers of the credentials currently * @param issuerIds canonical issuer-generation identifiers owned by the authority
* associated with the entity (historical and active); * @param currentIssuanceIssuerId exact generation selected for new issuance
* duplicates and {@code null} elements are rejected * @param issuanceChainPathId exact chain path selected for issuance bundles
*/ */
public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyRef, SubjectRef subjectRef, public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyRef, SubjectRef subjectRef,
List<PkiId> credentialIds) { List<PkiId> issuerIds, PkiId currentIssuanceIssuerId, PkiId issuanceChainPathId) {
/** /**
* Creates a CA record. * Creates a CA record.
@@ -90,18 +87,19 @@ public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyR
if (subjectRef == null) { if (subjectRef == null) {
throw new IllegalArgumentException("subjectRef must not be null"); throw new IllegalArgumentException("subjectRef must not be null");
} }
if (credentialIds == null) { if (issuerIds == null || issuerIds.isEmpty()) {
throw new IllegalArgumentException("credentialIds must not be null"); throw new IllegalArgumentException("issuerIds must not be null/empty");
} }
Set<PkiId> uniqueCredentialIds = new HashSet<>(credentialIds.size()); if (issuerIds.stream().anyMatch(java.util.Objects::isNull)
for (PkiId credentialId : credentialIds) { || issuerIds.size() != new java.util.HashSet<>(issuerIds).size()) {
if (credentialId == null) { throw new IllegalArgumentException("issuerIds must contain unique non-null identifiers");
throw new IllegalArgumentException("credentialIds must not contain null");
}
if (!uniqueCredentialIds.add(credentialId)) {
throw new IllegalArgumentException("credentialIds must not contain duplicates");
}
} }
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);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -386,7 +386,8 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
Map<String, PkiOperationValue> fields = fields(); Map<String, PkiOperationValue> fields = fields();
fields.put("credentialId", text(credential.credentialId().value())); fields.put("credentialId", text(credential.credentialId().value()));
fields.put(FORMAT_ID, text(credential.formatId().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("publicKeyId", text(credential.publicKeyId().value()));
fields.put("profileId", text(profile.profileId())); fields.put("profileId", text(profile.profileId()));
fields.put("profileVersion", integer(profile.profileVersion())); fields.put("profileVersion", integer(profile.profileVersion()));
@@ -623,7 +624,8 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
fields.put("caId", text(record.caId().value())); fields.put("caId", text(record.caId().value()));
fields.put("kind", text(record.kind().name())); fields.put("kind", text(record.kind().name()));
fields.put("state", text(record.state().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; return fields;
} }

View File

@@ -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<CaRecord> authority(PkiId authorityId) {
requireOpen.run();
return store.getCa(Objects.requireNonNull(authorityId, "authorityId"));
}
@Override public List<CaRecord> authorities(Optional<PkiId> 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<IssuerGeneration> issuer(PkiId issuerId) {
requireOpen.run();
return store.getIssuerGeneration(Objects.requireNonNull(issuerId, "issuerId"));
}
@Override public Optional<IssuerChainPath> chainPath(PkiId pathId) {
requireOpen.run();
return store.getIssuerChainPath(Objects.requireNonNull(pathId, "pathId"));
}
@Override public Optional<Credential> credential(PkiId credentialId) {
requireOpen.run();
return store.getCredential(Objects.requireNonNull(credentialId, "credentialId"));
}
@Override public Optional<StatusObject> 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);
}
}
}

View File

@@ -116,6 +116,7 @@ final class DefaultPkiSession implements PkiSession {
private final X509AlgorithmBindingRegistry algorithmBindings; private final X509AlgorithmBindingRegistry algorithmBindings;
private final PkiResourceScopeResolver resourceScopes; private final PkiResourceScopeResolver resourceScopes;
private final PkiOperationExecutor operations; private final PkiOperationExecutor operations;
private final PkiRepository repository;
private final AtomicBoolean closed = new AtomicBoolean(); private final AtomicBoolean closed = new AtomicBoolean();
private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit, private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit,
@@ -134,10 +135,17 @@ final class DefaultPkiSession implements PkiSession {
this.signatureWorkflow = graph.signatureWorkflow(); this.signatureWorkflow = graph.signatureWorkflow();
this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings"); this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings");
this.resourceScopes = new DefaultPkiResourceScopeResolver(store, this::requireOpen); this.resourceScopes = new DefaultPkiResourceScopeResolver(store, this::requireOpen);
this.repository = new DefaultPkiRepository(store, this::requireOpen);
this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities, this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities,
requests, issuance, statusObjects, publications, algorithmBindings, this::requireOpen); requests, issuance, statusObjects, publications, algorithmBindings, this::requireOpen);
} }
@Override
public PkiRepository repository() {
requireOpen();
return repository;
}
/* default */ static PkiSession open(PkiSessionConfiguration configuration) { /* default */ static PkiSession open(PkiSessionConfiguration configuration) {
return open(configuration, runtimeDependencies(configuration), Clock.systemUTC(), ProductionBootstrap.INSTANCE); return open(configuration, runtimeDependencies(configuration), Clock.systemUTC(), ProductionBootstrap.INSTANCE);
} }

View File

@@ -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<CaRecord> authority(PkiId authorityId);
/** Returns a finite canonical authority page after an optional exclusive key. */
List<CaRecord> authorities(Optional<PkiId> afterAuthorityId, int limit);
/** Returns an exact issuer generation. */
Optional<IssuerGeneration> issuer(PkiId issuerId);
/** Returns an exact immutable issuer chain path. */
Optional<IssuerChainPath> chainPath(PkiId pathId);
/** Returns an exact credential metadata record. */
Optional<Credential> credential(PkiId credentialId);
/** Returns an exact status-object metadata record. */
Optional<StatusObject> statusObject(PkiId statusObjectId);
/** Opens validated immutable certificate content. */
PkiRepositoryContent openCredential(PkiId credentialId);
/** Opens validated immutable status-object content. */
PkiRepositoryContent openStatusObject(PkiId statusObjectId);
}

View File

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

View File

@@ -135,6 +135,9 @@ public interface PkiSession extends AutoCloseable {
/** @return shared typed operation executor owned by this session */ /** @return shared typed operation executor owned by this session */
PkiOperationExecutor operations(); PkiOperationExecutor operations();
/** @return read-only authoritative repository facade owned by this session */
PkiRepository repository();
/** /**
* Closes services and backend resources in reverse construction order. * Closes services and backend resources in reverse construction order.
* Repeated calls are harmless; primary and suppressed failures are preserved. * Repeated calls are harmless; primary and suppressed failures are preserved.

View File

@@ -376,10 +376,13 @@ public final class DefaultCaService implements CaService {
PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16)); 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())); 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()), validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, CredentialContent.stage(store, certDer), CredentialStatus.ISSUED, CredentialContent.stage(store, certDer),
SimpleAttributeSet.builder().build()); SimpleAttributeSet.builder().build());
@@ -387,8 +390,10 @@ public final class DefaultCaService implements CaService {
requireCaCertificateMatches(credential, credential, request, caId, CREATE_ROOT_REJECTED, BACKEND_CRED_MISMATCH); requireCaCertificateMatches(credential, credential, request, caId, CREATE_ROOT_REJECTED, BACKEND_CRED_MISMATCH);
store.putCredential(credential); 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(), 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); store.putCa(ca);
return caId; return caId;
} }
@@ -448,6 +453,8 @@ public final class DefaultCaService implements CaService {
PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16)); 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; byte[] spkiDer;
try { try {
@@ -464,7 +471,8 @@ public final class DefaultCaService implements CaService {
ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA, ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime, command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime,
Optional.empty(), serial, authority); 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()), validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, command.existingCaCredential(), CredentialStatus.ISSUED, command.existingCaCredential(),
SimpleAttributeSet.builder().build()); SimpleAttributeSet.builder().build());
@@ -473,8 +481,10 @@ public final class DefaultCaService implements CaService {
ROOT_CREDENTIAL_INVALID); ROOT_CREDENTIAL_INVALID);
requireValidImportedRoot(command, holder); requireValidImportedRoot(command, holder);
store.putCredential(credential); 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(), 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); store.putCa(ca);
return caId; return caId;
} }
@@ -526,15 +536,14 @@ public final class DefaultCaService implements CaService {
CaRecord issuer = getCa(command.issuerCaId()); CaRecord issuer = getCa(command.issuerCaId());
ensureActive(issuer, "issuer"); ensureActive(issuer, "issuer");
if (issuer.credentialIds().isEmpty()) {
throw new PkiException("Issuer CA has no credentials");
}
if (!framework.formatId().equals(command.formatId())) { if (!framework.formatId().equals(command.formatId())) {
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.empty(), "FORMAT_UNSUPPORTED"); throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.empty(), "FORMAT_UNSUPPORTED");
} }
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(), Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation)); 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, requireHistoricalCaProfile(issuerCredential,
issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA : CertificateProfileKind.INTERMEDIATE_CA); 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(), requireCaBinding(backendCredential, issue.profileReference(), CREATE_INT_REJECTED, command.formatId(),
Optional.of(caId)); Optional.of(caId));
Credential cred; Credential rawCredential;
try { try {
cred = CredentialSnapshots.copy(backendCredential); rawCredential = CredentialSnapshots.copy(backendCredential);
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId), throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId),
BACKEND_CRED_MISMATCH); 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); 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(), 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); store.putCa(subject);
return caId; return caId;
} }
@@ -631,6 +650,8 @@ public final class DefaultCaService implements CaService {
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(), Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation)); 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, requireHistoricalCaProfile(issuerCredential,
issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA : CertificateProfileKind.INTERMEDIATE_CA); 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(), requireCaBinding(backendCredential, gated.profileReference(), ISSUE_INT_REJECTED, command.formatId(),
Optional.of(subject.caId())); Optional.of(subject.caId()));
Credential cred; Credential rawCredential;
try { try {
cred = CredentialSnapshots.copy(backendCredential); rawCredential = CredentialSnapshots.copy(backendCredential);
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()), throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()),
BACKEND_CRED_MISMATCH); BACKEND_CRED_MISMATCH);
} }
requireCaCertificateMatches(cred, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED, requireCaCertificateMatches(rawCredential, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED,
BACKEND_CRED_MISMATCH); BACKEND_CRED_MISMATCH);
Credential cred = IssuerAuthorities.withIssuer(rawCredential,
new IssuerRef(issuer.caId(), parentGeneration.issuerId(), parentPath.pathId()));
store.putCredential(cred); 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<PkiId> updated = new ArrayList<>(subject.credentialIds()); List<PkiId> updated = new ArrayList<>(subject.issuerIds());
updated.add(cred.credentialId()); updated.add(subjectGeneration.issuerId());
CaRecord updatedCa = new CaRecord(subject.caId(), subject.kind(), subject.state(), subject.issuerKeyRef(), 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); store.putCa(updatedCa);
return cred; return cred;
} }
@@ -758,7 +788,8 @@ public final class DefaultCaService implements CaService {
} }
CaRecord updated = new CaRecord(existing.caId(), existing.kind(), state, existing.issuerKeyRef(), 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); store.putCa(updated);
if (LOG.isLoggable(Level.INFO)) { if (LOG.isLoggable(Level.INFO)) {
@@ -810,17 +841,67 @@ public final class DefaultCaService implements CaService {
return false; return false;
} }
if (query.formatId().isPresent()) { if (query.formatId().isPresent()) {
if (r.credentialIds().isEmpty()) { Credential current = IssuerAuthorities.currentCredential(store, r);
return false; return query.formatId().get().equals(current.formatId());
}
PkiId lastId = r.credentialIds().get(r.credentialIds().size() - 1);
Credential last = requireCredential(lastId);
return query.formatId().get().equals(last.formatId());
} }
return true; return true;
}).toList(); }).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<zeroecho.pki.api.ca.IssuerChainPath> 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) { private static void ensureActive(CaRecord ca, String role) {
if (ca.state() != CaState.ACTIVE) { if (ca.state() != CaState.ACTIVE) {
throw new PkiException("CA not ACTIVE: " + role); 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, private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use,
EffectiveCredentialStatusResolver.Evaluation evaluation) { EffectiveCredentialStatusResolver.Evaluation evaluation) {
Credential lastRejected = null; Credential credential = IssuerAuthorities.currentCredential(store, issuer);
EffectiveCredentialStatus lastStatus = null; if (!formatId.equals(credential.formatId())) {
for (PkiId credentialId : issuer.credentialIds()) { throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
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;
} }
if (lastRejected != null) { EffectiveCredentialStatus status = evaluation.resolve(credential);
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use, if (status != EffectiveCredentialStatus.USABLE) {
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus); 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 requireCredential(PkiId credentialId) {
return store.getCredential(credentialId)
.orElseThrow(() -> new PkiException("CA credential not found"));
} }
private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) { 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 return framework.formatId().equals(credential.formatId()) && credential.content().encoding() == Encoding.DER
&& credential.status() == CredentialStatus.ISSUED && credential.status() == CredentialStatus.ISSUED
&& credential.subjectRef().equals(request.subjectRef()) && credential.subjectRef().equals(request.subjectRef())
&& credential.issuerRef() && credential.issuerRef().caId()
.equals(new IssuerRef(request.certificateType() == CertificateProfileKind.ROOT_CA ? subjectCaId .equals(request.certificateType() == CertificateProfileKind.ROOT_CA ? subjectCaId
: request.issuerCaId())); : request.issuerCaId());
} }
private static boolean matchesCaCertificateIdentity(X509CertificateHolder holder, private static boolean matchesCaCertificateIdentity(X509CertificateHolder holder,

View File

@@ -234,10 +234,6 @@ public final class DefaultIssuanceService implements IssuanceService {
if (issuer.state() != CaState.ACTIVE) { if (issuer.state() != CaState.ACTIVE) {
throw new PkiException("Issuer CA not ACTIVE"); throw new PkiException("Issuer CA not ACTIVE");
} }
if (issuer.credentialIds().isEmpty()) {
throw new PkiException("Issuer CA has no credentials");
}
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command); VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
ActiveCertificateProfile active; ActiveCertificateProfile active;
try { try {
@@ -257,6 +253,8 @@ public final class DefaultIssuanceService implements IssuanceService {
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(), Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(),
CredentialUse.END_ENTITY_ISSUER, statusEvaluation)); 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; ValidatedCertificateRequest validated;
try { try {
validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred, 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"); throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH");
} }
requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request()); requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request());
store.putCredential(bundle.credential()); Credential exactCredential = IssuerAuthorities.withIssuer(bundle.credential(),
return bundle; 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(issuer, "issuer");
Objects.requireNonNull(formatId, "formatId"); Objects.requireNonNull(formatId, "formatId");
Credential lastRejected = null; Credential credential = IssuerAuthorities.currentCredential(store, issuer);
EffectiveCredentialStatus lastStatus = null; if (!formatId.equals(credential.formatId())) {
for (PkiId credentialId : issuer.credentialIds()) { throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
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;
} }
if (lastRejected != null) { EffectiveCredentialStatus status = evaluation.resolve(credential);
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use, if (status != EffectiveCredentialStatus.USABLE) {
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus); 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) { private Credential requireIssuerCredential(PkiId credentialId) {
@@ -459,7 +444,7 @@ public final class DefaultIssuanceService implements IssuanceService {
validated.profileReference()); validated.profileReference());
if (!framework.formatId().equals(credential.formatId()) || credential.content().encoding() != Encoding.DER if (!framework.formatId().equals(credential.formatId()) || credential.content().encoding() != Encoding.DER
|| !credential.subjectRef().equals(validated.subjectRef()) || !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) { || credential.status() != CredentialStatus.ISSUED) {
throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH"); throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
} }
@@ -585,16 +570,15 @@ public final class DefaultIssuanceService implements IssuanceService {
* credential. * credential.
* *
* <p> * <p>
* This implementation currently returns a minimal bundle containing only the * This implementation returns the resolved leaf credential with the exact
* resolved leaf credential and an empty chain. Chain discovery, issuer path * immutable chain path selected and persisted when the credential was issued.
* construction, and publication-aware bundle assembly are intentionally left to * It never infers a path from collection order, dates, filenames, or the public
* higher layers. * repository's independently managed current-chain alias.
* </p> * </p>
* *
* @param command bundle construction command identifying the leaf credential; * @param command bundle construction command identifying the leaf credential;
* must not be {@code null} * must not be {@code null}
* @return minimal credential bundle containing the resolved leaf credential and * @return credential bundle containing the resolved leaf and its exact issuance path
* no chain elements
* @throws NullPointerException if {@code command} is {@code null} * @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the requested credential does not exist in * @throws PkiException if the requested credential does not exist in
* the store or is not currently usable * the store or is not currently usable
@@ -619,8 +603,18 @@ public final class DefaultIssuanceService implements IssuanceService {
throw new PkiException( throw new PkiException(
"Credential trust rejected: code=" + StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE); "Credential trust rejected: code=" + StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE);
} }
// Minimal bundle: leaf only. Chain selection and publication are higher-layer zeroecho.pki.api.ca.IssuerChainPath path = store.getIssuerChainPath(leaf.issuerRef().chainPathId())
// concerns. .orElseThrow(() -> new PkiException("Credential issuance chain path not found"));
return new CredentialBundle(leaf, List.of()); 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<zeroecho.pki.api.content.DurableContentReference> pathContent(
zeroecho.pki.api.ca.IssuerChainPath path) {
return path.orderedCredentialIds().stream().map(this::requireIssuerCredential)
.map(Credential::content).toList();
} }
} }

View File

@@ -214,9 +214,6 @@ public final class DefaultStatusObjectService implements StatusObjectService {
if (ca.state() != CaState.ACTIVE) { if (ca.state() != CaState.ACTIVE) {
throw new PkiException("Issuer CA not 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(); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation); Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation);
@@ -488,35 +485,17 @@ public final class DefaultStatusObjectService implements StatusObjectService {
private Credential selectIssuerCredential(CaRecord ca, StatusObjectGenerateCommand command, private Credential selectIssuerCredential(CaRecord ca, StatusObjectGenerateCommand command,
EffectiveCredentialStatusResolver.Evaluation evaluation) { EffectiveCredentialStatusResolver.Evaluation evaluation) {
Credential lastRejected = null; Credential credential = IssuerAuthorities.currentCredential(store, ca);
EffectiveCredentialStatus lastStatus = null; if (!command.formatId().equals(credential.formatId())) {
List<PkiId> credentialIds = ca.credentialIds(); throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
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;
} }
if (lastRejected != null) { EffectiveCredentialStatus status = evaluation.resolve(credential);
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, if (status != EffectiveCredentialStatus.USABLE) {
CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus); 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;
} }
/** /**

View File

@@ -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<PkiId> credentials = new java.util.ArrayList<>();
credentials.add(credentialId);
credentials.addAll(parentPath.orderedCredentialIds());
return IssuerChainPath.create(authorityId, issuerId, credentials);
}
}

View File

@@ -73,10 +73,16 @@ import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Stream; 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.EncodedObject;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord; 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.credential.Credential;
import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.orch.WorkflowStateRecord;
@@ -167,12 +173,12 @@ import zeroecho.pki.spi.store.RevocationHistory;
*/ */
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods", @SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods",
"PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl", "PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl",
"PMD.PreserveStackTrace" }) "PMD.PreserveStackTrace", "PMD.NcssCount" })
public final class FilesystemPkiStore implements PkiStore, Closeable { public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); 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_RECORD_NAMESPACE = "io.zeroecho.pki.signing-record";
private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner"; 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"; 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(); .toList();
} }
@Override
public List<CaRecord> listCasPage(Optional<PkiId> 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<CaRecord> selected = new ArrayList<>(limit);
try (java.nio.file.DirectoryStream<Path> 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<IssuerGeneration> getIssuerGeneration(PkiId issuerId) {
requireStoreUsable();
Objects.requireNonNull(issuerId, "issuerId");
return readOptional(paths.issuerGenerationPath(issuerId), FsCodec.ISSUER_GENERATION)
.map(this::validateIssuerGeneration);
}
@Override
public List<IssuerGeneration> 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<IssuerChainPath> getIssuerChainPath(PkiId pathId) {
requireStoreUsable();
Objects.requireNonNull(pathId, "pathId");
return readOptional(paths.issuerChainPath(pathId), FsCodec.ISSUER_CHAIN_PATH)
.map(this::validateIssuerChainPath);
}
@Override
public List<IssuerChainPath> 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 @Override
public void putCredential(final Credential credential) { public void putCredential(final Credential credential) {
requireStoreUsable(); requireStoreUsable();
@@ -603,15 +704,112 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
} }
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private CaRecord validateCaCredentialReferences(CaRecord record) { private CaRecord validateCaCredentialReferences(CaRecord record) {
for (PkiId credentialId : record.credentialIds()) { for (PkiId issuerId : record.issuerIds()) {
if (getCredential(credentialId).isEmpty()) { IssuerGeneration generation = getIssuerGeneration(issuerId)
throw new IllegalStateException("CA credential reference is missing"); .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; 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<Credential> 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<X509CertificateHolder> 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 @Override
public void putRequest(final ParsedCertificationRequest request) { public void putRequest(final ParsedCertificationRequest request) {
requireStoreUsable(); requireStoreUsable();
@@ -2650,6 +2848,22 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
} }
private <T> List<T> listBinaryRecords(final Path byIdDir, final FsCodec.Schema<T> schema) {
if (!Files.isDirectory(byIdDir)) {
return List.of();
}
try (Stream<Path> files = Files.list(byIdDir)) {
List<T> 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) { private static void writeOnce(final Path target, final byte[] data, final String kind, final String safeId) {
try { try {
FsOperations.ensureDir(target.getParent()); FsOperations.ensureDir(target.getParent());

View File

@@ -64,6 +64,9 @@ import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.ca.CaKind; import zeroecho.pki.api.ca.CaKind;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState; 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.CaProfileBinding;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialProfileBinding; import zeroecho.pki.api.credential.CredentialProfileBinding;
@@ -112,7 +115,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
final class FsCodec { final class FsCodec {
/* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024; /* 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 CODEC_MAGIC = 0x5A454346;
private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES; 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 DURABLE_CONTENT_VERSION = 1;
private static final int TOP_PROFILE_VERSION = 11; private static final int TOP_PROFILE_VERSION = 11;
private static final int TOP_ACTIVE_PROFILE_REF = 12; 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_STRING = 1;
private static final int TYPE_BOOLEAN = 2; 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_REF = 72;
private static final int TYPE_PROFILE_BINDING = 73; private static final int TYPE_PROFILE_BINDING = 73;
private static final int TYPE_DURABLE_CONTENT = 74; 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_STRING = 1;
private static final int ATTRIBUTE_BOOLEAN = 2; private static final int ATTRIBUTE_BOOLEAN = 2;
@@ -218,6 +224,19 @@ final class FsCodec {
case 4 -> CaState.DISABLED; case 4 -> CaState.DISABLED;
default -> throw unknownEnum("CaState", code); default -> throw unknownEnum("CaState", code);
}); });
private static final ValueSchema<IssuerGenerationState> 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<CredentialStatus> CREDENTIAL_STATUS = enumSchema(TYPE_CREDENTIAL_STATUS_ENUM, private static final ValueSchema<CredentialStatus> CREDENTIAL_STATUS = enumSchema(TYPE_CREDENTIAL_STATUS_ENUM,
value -> switch (value) { value -> switch (value) {
case ISSUED -> 1; case ISSUED -> 1;
@@ -307,8 +326,11 @@ final class FsCodec {
(writer, value) -> writer.writeValue(STRING, value.value()), (writer, value) -> writer.writeValue(STRING, value.value()),
reader -> new SubjectRef(reader.readValue(STRING))); reader -> new SubjectRef(reader.readValue(STRING)));
private static final ValueSchema<IssuerRef> ISSUER_REF = valueSchema(TYPE_ISSUER_REF, private static final ValueSchema<IssuerRef> ISSUER_REF = valueSchema(TYPE_ISSUER_REF,
(writer, value) -> writer.writeValue(PKI_ID, value.caId()), (writer, value) -> {
reader -> new IssuerRef(reader.readValue(PKI_ID))); 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<FormatId> FORMAT_ID = valueSchema(TYPE_FORMAT_ID, private static final ValueSchema<FormatId> FORMAT_ID = valueSchema(TYPE_FORMAT_ID,
(writer, value) -> writer.writeValue(STRING, value.value()), (writer, value) -> writer.writeValue(STRING, value.value()),
reader -> new FormatId(reader.readValue(STRING))); reader -> new FormatId(reader.readValue(STRING)));
@@ -377,13 +399,19 @@ final class FsCodec {
"PROFILE_VERSION", valueSchema(109, FsCodec::writeProfileVersion, FsCodec::readProfileVersion)); "PROFILE_VERSION", valueSchema(109, FsCodec::writeProfileVersion, FsCodec::readProfileVersion));
/* package */ static final Schema<CertificateProfileRef> ACTIVE_PROFILE_REF = topLevel(TOP_ACTIVE_PROFILE_REF, /* package */ static final Schema<CertificateProfileRef> ACTIVE_PROFILE_REF = topLevel(TOP_ACTIVE_PROFILE_REF,
"ACTIVE_PROFILE_REF", PROFILE_REF); "ACTIVE_PROFILE_REF", PROFILE_REF);
/* package */ static final Schema<IssuerGeneration> ISSUER_GENERATION = topLevel(TOP_ISSUER_GENERATION,
"ISSUER_GENERATION", valueSchema(110, FsCodec::writeIssuerGeneration, FsCodec::readIssuerGeneration));
/* package */ static final Schema<IssuerChainPath> ISSUER_CHAIN_PATH = topLevel(TOP_ISSUER_CHAIN_PATH,
"ISSUER_CHAIN_PATH", valueSchema(111, FsCodec::writeIssuerChainPath, FsCodec::readIssuerChainPath));
private static final Map<Integer, Schema<?>> TOP_LEVEL_SCHEMAS = Map.ofEntries(Map.entry(TOP_CA_RECORD, CA_RECORD), private static final Map<Integer, Schema<?>> 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_CREDENTIAL, CREDENTIAL), Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST),
Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT),
Map.entry(TOP_POLICY_TRACE, POLICY_TRACE), 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_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() { private FsCodec() {
// utility // utility
@@ -616,12 +644,44 @@ final class FsCodec {
writer.writeValue(CA_STATE, value.state()); writer.writeValue(CA_STATE, value.state());
writer.writeValue(KEY_REF, value.issuerKeyRef()); writer.writeValue(KEY_REF, value.issuerKeyRef());
writer.writeValue(SUBJECT_REF, value.subjectRef()); 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 { private static CaRecord readCaRecord(Reader reader) throws IOException {
return new CaRecord(reader.readValue(PKI_ID), reader.readValue(CA_KIND), reader.readValue(CA_STATE), 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 { private static void writeParsedRequest(Writer writer, ParsedCertificationRequest value) throws IOException {

View File

@@ -125,6 +125,16 @@ final class FsPaths {
return caDir(caId).resolve(HISTORY_DIR); 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) // Profiles (immutable versions plus one active pointer)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -60,6 +60,8 @@ import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord; 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.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.publication.PublicationCursor; import zeroecho.pki.api.publication.PublicationCursor;
@@ -219,12 +221,18 @@ final class FsSnapshotExporter {
private SnapshotAuthority plan(Instant at) throws IOException { private SnapshotAuthority plan(Instant at) throws IOException {
CredentialInventory inventory = inventoryCredentials(); CredentialInventory inventory = inventoryCredentials();
List<CaRecord> cas = selectCas(at, inventory.credentials()); List<CaRecord> cas = selectCas(at, inventory.credentials());
List<IssuerGeneration> generations = cas.stream().flatMap(ca -> ca.issuerIds().stream())
.map(id -> source.getIssuerGeneration(id).orElseThrow(
() -> new SnapshotAuthorityFailure("Snapshot issuer generation is missing"))).toList();
List<IssuerChainPath> paths = generations.stream()
.flatMap(generation -> source.listIssuerChainPaths(generation.issuerId()).stream()).toList();
List<StatusObject> statuses = source.snapshotStatusObjects(); List<StatusObject> statuses = source.snapshotStatusObjects();
Set<String> remintedContentIds = new HashSet<>(inventory.contentIds()); Set<String> remintedContentIds = new HashSet<>(inventory.contentIds());
for (StatusObject status : statuses) { for (StatusObject status : statuses) {
remintedContentIds.add(status.content().contentId()); 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 { private CredentialInventory inventoryCredentials() throws IOException {
@@ -277,7 +285,7 @@ final class FsSnapshotExporter {
List<CaRecord> selected = new ArrayList<>(); List<CaRecord> selected = new ArrayList<>();
for (Path record : selectedRecords) { for (Path record : selectedRecords) {
CaRecord ca = loadCa(record); CaRecord ca = loadCa(record);
if (ca != null && credentials.keySet().containsAll(ca.credentialIds())) { if (ca != null && validCa(ca, credentials)) {
selected.add(ca); selected.add(ca);
} else if (ca != null) { } else if (ca != null) {
rejectCa(); rejectCa();
@@ -286,6 +294,24 @@ final class FsSnapshotExporter {
return List.copyOf(selected); return List.copyOf(selected);
} }
private boolean validCa(CaRecord ca, Map<PkiId, Credential> 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 { private CaRecord loadCa(Path record) throws IOException {
try { try {
return FsCodec.decode(FsCodec.CA_RECORD, FsOperations.readAll(record), source.stagedContent()); return FsCodec.decode(FsCodec.CA_RECORD, FsOperations.readAll(record), source.stagedContent());
@@ -448,8 +474,19 @@ final class FsSnapshotExporter {
transferred.add(entry.getKey()); 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()) { 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); persistCa(target, ca);
} }
} }
@@ -789,10 +826,13 @@ final class FsSnapshotExporter {
} }
} }
private record SnapshotAuthority(List<CaRecord> cas, Map<PkiId, Credential> credentials, private record SnapshotAuthority(List<CaRecord> cas, List<IssuerGeneration> generations,
List<IssuerChainPath> paths, Map<PkiId, Credential> credentials,
List<StatusObject> statuses, Set<String> remintedContentIds) { List<StatusObject> statuses, Set<String> remintedContentIds) {
private SnapshotAuthority { private SnapshotAuthority {
cas = List.copyOf(cas); cas = List.copyOf(cas);
generations = List.copyOf(generations);
paths = List.copyOf(paths);
credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials)); credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials));
statuses = List.copyOf(statuses); statuses = List.copyOf(statuses);
remintedContentIds = Set.copyOf(remintedContentIds); remintedContentIds = Set.copyOf(remintedContentIds);

View File

@@ -39,6 +39,8 @@ import java.util.Optional;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord; 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.credential.Credential;
import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.policy.PolicyTrace;
@@ -105,8 +107,8 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
* <p> * <p>
* Implementations must store CA records atomically. Replacing an existing * Implementations must store CA records atomically. Replacing an existing
* record should be either fully visible or not visible at all. Every * record should be either fully visible or not visible at all. Every
* identifier in {@link CaRecord#credentialIds()} must resolve through * identifier in {@link CaRecord#issuerIds()} must resolve through
* {@link #getCredential(PkiId)} before the CA record is published. * {@link #getIssuerGeneration(PkiId)} before the CA record is published.
* </p> * </p>
* *
* @param record CA record (never {@code null}) * @param record CA record (never {@code null})
@@ -139,6 +141,33 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
*/ */
List<CaRecord> listCas(); List<CaRecord> 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<CaRecord> listCasPage(Optional<PkiId> afterCaId, int limit);
/** Persists one immutable canonical issuer-generation record. */
void putIssuerGeneration(IssuerGeneration generation);
/** Retrieves one canonical issuer-generation record. */
Optional<IssuerGeneration> getIssuerGeneration(PkiId issuerId);
/** Lists issuer generations owned by an exact logical authority. */
List<IssuerGeneration> listIssuerGenerations(PkiId authorityId);
/** Persists one immutable, already validated issuer chain path. */
void putIssuerChainPath(IssuerChainPath path);
/** Retrieves one immutable issuer chain path. */
Optional<IssuerChainPath> getIssuerChainPath(PkiId pathId);
/** Lists explicit paths for an exact issuer generation. */
List<IssuerChainPath> listIssuerChainPaths(PkiId issuerId);
/** /**
* Persists a credential record. * Persists a credential record.
* *

View File

@@ -48,25 +48,25 @@ import zeroecho.pki.api.SubjectRef;
final class CaRecordTest { final class CaRecordTest {
@Test @Test
void credentialIdentifiersAreOrderedImmutableAndUnique() { void issuerIdentifiersAreOrderedImmutableAndUnique() {
System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique"); System.out.println("issuerIdentifiersAreOrderedImmutableAndUnique");
PkiId first = new PkiId("credential-first"); PkiId first = new PkiId("issuer-first");
PkiId second = new PkiId("credential-second"); PkiId second = new PkiId("issuer-second");
List<PkiId> source = new ArrayList<>(List.of(first, second)); List<PkiId> source = new ArrayList<>(List.of(first, second));
CaRecord record = record(source); CaRecord record = record(source);
source.clear(); source.clear();
assertEquals(List.of(first, second), record.credentialIds()); assertEquals(List.of(first, second), record.issuerIds());
assertThrows(UnsupportedOperationException.class, 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(List.of(first, first)));
assertThrows(IllegalArgumentException.class, () -> record(java.util.Arrays.asList(first, null))); 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<PkiId> credentialIds) { private static CaRecord record(List<PkiId> issuerIds) {
return new CaRecord(new PkiId("ca-test"), CaKind.ROOT, CaState.ACTIVE, new KeyRef("key-test"), 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"));
} }
} }

View File

@@ -78,6 +78,8 @@ import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity; import zeroecho.pki.api.Validity;
import zeroecho.pki.api.ca.CaCreateCommand; import zeroecho.pki.api.ca.CaCreateCommand;
import zeroecho.pki.api.ca.CaImportCommand; 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.CaRecord;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand; import zeroecho.pki.api.ca.IntermediateCreateCommand;
@@ -140,7 +142,7 @@ final class CaProfileIssuanceEnforcementTest {
assertEquals(holder.getSubject().toString(), intermediate.subjectRef().value()); assertEquals(holder.getSubject().toString(), intermediate.subjectRef().value());
assertEquals(intermediate.subjectRef(), credential.subjectRef()); assertEquals(intermediate.subjectRef(), credential.subjectRef());
assertEquals(intermediate.subjectRef(), additional.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) .caCredential(runtime.caService().getCa(rootId), 0)
.profileBinding(); .profileBinding();
assertEquals(1, issuerBinding.reference().profileVersion()); 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.serialOrUniqueId(), original.publicKeyId(), new CaProfileBinding(wrongFormat),
original.status(), original.content(), original.attributes()); original.status(), original.content(), original.attributes());
runtime.store().putCredential(mutated); 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(), 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(); int signCount = runtime.submittedSignCount();
assertThrows(PkiException.class, assertThrows(PkiException.class,
@@ -521,7 +533,7 @@ final class CaProfileIssuanceEnforcementTest {
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, rootId, new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, rootId,
"intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); "intermediate-ca", Optional.empty(), new SimpleAttributeSet())));
assertEquals(signCount, runtime.submittedSignCount()); 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", runtime.framework().formatId(), rootId, intermediateId, "intermediate-ca",
Optional.of(invalid), new SimpleAttributeSet()))); Optional.of(invalid), new SimpleAttributeSet())));
assertEquals(signCount, runtime.submittedSignCount()); 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) { private static Credential onlyCredential(PkiTestRuntime runtime, PkiId caId) {
CaRecord ca = runtime.caService().getCa(caId); CaRecord ca = runtime.caService().getCa(caId);
assertEquals(1, ca.credentialIds().size()); assertEquals(1, ca.issuerIds().size());
return runtime.caCredential(ca, 0); return runtime.caCredential(ca, 0);
} }

View File

@@ -123,16 +123,11 @@ public final class PkiCoreE2eTest {
} }
@Test @Test
void everyIssuerPathSkipsEarlierUnusableCredentialAndSelectsLaterUsable(@TempDir Path tempDir) throws Exception { void explicitIssuerSelectionNeverFallsBackByCollectionOrder(@TempDir Path tempDir) throws Exception {
KeyPair rootKey = genRsa(); KeyPair rootKey = genRsa();
KeyPair intermediateKey = genRsa();
KeyPair nextIntermediateKey = genRsa();
KeyPair leafKey = genRsa(); KeyPair leafKey = genRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:matrix-root"); KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:matrix-root");
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-intermediate"); Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey);
KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-next");
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey,
nextIntermediateKeyRef, nextIntermediateKey);
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), 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")); Credential unusable = copyWithId(usable, new PkiId("credential:matrix-unusable"));
runtime.store().putCredential(unusable); runtime.store().putCredential(unusable);
CaRecord root = runtime.caService().getCa(rootCaId); CaRecord root = runtime.caService().getCa(rootCaId);
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), runtime.selectIssuerCredential(root, unusable, true);
root.subjectRef(), List.of(unusable.credentialId(), usable.credentialId())));
List<PkiId> resolved = new ArrayList<>(); List<PkiId> resolved = new ArrayList<>();
EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> { EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> {
@@ -152,37 +146,64 @@ public final class PkiCoreE2eTest {
}, false); }, false);
CountingIssuerBackend backend = new CountingIssuerBackend(runtime.issuerBackend()); CountingIssuerBackend backend = new CountingIssuerBackend(runtime.issuerBackend());
IssuanceService issuance = runtime.issuanceService(backend, resolver); IssuanceService issuance = runtime.issuanceService(backend, resolver);
CaService caService = runtime.caService(backend, resolver);
StatusObjectService statusService = runtime.statusObjectService(resolver);
ParsedCertificationRequest leafRequest = runtime.certificationRequestService() ParsedCertificationRequest leafRequest = runtime.certificationRequestService()
.parse(new CertificationRequest(runtime.framework().formatId(), .parse(new CertificationRequest(runtime.framework().formatId(),
new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Matrix Leaf").getEncoded()))); 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())); issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); assertEquals(List.of(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(1, backend.endEntityCalls.get()); 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 @Test
void permanentlyRevokedIssuerIsRejectedByEveryReachableTrustPath(@TempDir Path tempDir) throws Exception { void permanentlyRevokedIssuerIsRejectedByEveryReachableTrustPath(@TempDir Path tempDir) throws Exception {
KeyPair rootKey = genRsa(); KeyPair rootKey = genRsa();
@@ -202,7 +223,8 @@ public final class PkiCoreE2eTest {
.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
new SubjectRef("CN=H6 Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SubjectRef("CN=H6 Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef),
emptyAttributes())); 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, runtime.revocationService().revokePermanently(new RevocationCommand.RevokePermanently(rootCredentialId,
RevocationReason.KEY_COMPROMISE, emptyAttributes())); RevocationReason.KEY_COMPROMISE, emptyAttributes()));
int submissionsBeforeRejections = runtime.submittedSignCount(); int submissionsBeforeRejections = runtime.submittedSignCount();
@@ -231,7 +253,7 @@ public final class PkiCoreE2eTest {
assertEquals(submissionsBeforeRejections, runtime.submittedSignCount()); assertEquals(submissionsBeforeRejections, runtime.submittedSignCount());
assertTrue(runtime.store().getCredential(rootCredentialId).isPresent()); 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())); .issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty()));
assertNotNull(bundle); 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()); System.out.println("...issuedCredentialId=" + bundle.credential().credentialId().value());
X509CertificateHolder eeCert = new X509CertificateHolder(runtime.credentialBytes(bundle.credential())); X509CertificateHolder eeCert = new X509CertificateHolder(runtime.credentialBytes(bundle.credential()));
@@ -333,7 +359,7 @@ public final class PkiCoreE2eTest {
int signCount = runtime.submittedSignCount(); int signCount = runtime.submittedSignCount();
int caCount = runtime.store().listCas().size(); int caCount = runtime.store().listCas().size();
int statusCount = runtime.store().listStatusObjects(rootCaId).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 assertThrows(PkiException.class, () -> issuance
.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()))); .issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty())));
@@ -356,7 +382,7 @@ public final class PkiCoreE2eTest {
assertEquals(caCount, runtime.store().listCas().size()); assertEquals(caCount, runtime.store().listCas().size());
assertEquals(statusCount, runtime.store().listStatusObjects(rootCaId).size()); assertEquals(statusCount, runtime.store().listStatusObjects(rootCaId).size());
assertEquals(intermediateCredentialCount, assertEquals(intermediateCredentialCount,
runtime.caService().getCa(intermediateCaId).credentialIds().size()); runtime.caService().getCa(intermediateCaId).issuerIds().size());
assertTrue(runtime.store().getCredential(rootCredential.credentialId()).isPresent()); assertTrue(runtime.store().getCredential(rootCredential.credentialId()).isPresent());
assertFalse(runtime.auditSink().snapshot().toString().contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL")); assertFalse(runtime.auditSink().snapshot().toString().contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL"));
} }

View File

@@ -538,7 +538,7 @@ final class PkiProofGateE2eTest {
runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca",
Optional.empty(), new SimpleAttributeSet()))); Optional.empty(), new SimpleAttributeSet())));
assertEquals(7, runtime.submittedSignCount()); 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()); assertTrue(runtime.store().listWorkflowStates().isEmpty());
} }
@@ -796,8 +796,7 @@ final class PkiProofGateE2eTest {
original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(),
original.profileBinding(), CredentialStatus.REVOKED, original.content(), original.attributes()); original.profileBinding(), CredentialStatus.REVOKED, original.content(), original.attributes());
runtime.store().putCredential(revoked); runtime.store().putCredential(revoked);
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), runtime.selectIssuerCredential(root, revoked, true);
root.subjectRef(), List.of(revoked.credentialId())));
int before = runtime.submittedSignCount(); int before = runtime.submittedSignCount();
assertThrows(PkiException.class, () -> runtime.issuanceService() assertThrows(PkiException.class, () -> runtime.issuanceService()
.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty()))); .issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty())));
@@ -810,14 +809,13 @@ final class PkiProofGateE2eTest {
original.serialOrUniqueId(), original.publicKeyId(), original.profileBinding(), original.serialOrUniqueId(), original.publicKeyId(), original.profileBinding(),
CredentialStatus.ISSUED, original.content(), original.attributes()); CredentialStatus.ISSUED, original.content(), original.attributes());
runtime.store().putCredential(expired); runtime.store().putCredential(expired);
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), runtime.selectIssuerCredential(root, expired, true);
root.subjectRef(), List.of(expired.credentialId())));
assertThrows(PkiException.class, () -> runtime.issuanceService() assertThrows(PkiException.class, () -> runtime.issuanceService()
.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty()))); .issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty())));
assertEquals(before, runtime.submittedSignCount()); assertEquals(before, runtime.submittedSignCount());
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), runtime.caService().selectIssuancePath(root.caId(), root.currentIssuanceIssuerId(),
root.subjectRef(), List.of(original.credentialId()))); root.issuanceChainPathId(), "restore explicit test selection");
ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet()); ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet());
AttributeSet hostileAttributes = new AttributeSet() { AttributeSet hostileAttributes = new AttributeSet() {
@Override @Override
@@ -917,7 +915,7 @@ final class PkiProofGateE2eTest {
Optional.empty(), new SimpleAttributeSet())), Optional.empty(), new SimpleAttributeSet())),
mutation.name()); mutation.name());
assertTrue(rejected.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH"), 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) { if (produced.get() != null) {
assertTrue(runtime.store().getCredential(produced.get().credentialId()).isEmpty(), mutation.name()); assertTrue(runtime.store().getCredential(produced.get().credentialId()).isEmpty(), mutation.name());
} }
@@ -942,7 +940,7 @@ final class PkiProofGateE2eTest {
() -> wrongSubjectService.issueIntermediateCertificate( () -> wrongSubjectService.issueIntermediateCertificate(
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId, new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId,
"intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); "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() { CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() {
@Override @Override
@@ -967,7 +965,7 @@ final class PkiProofGateE2eTest {
() -> invalidSignatureService.issueIntermediateCertificate( () -> invalidSignatureService.issueIntermediateCertificate(
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId, new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId,
"intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); "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()) { for (IntermediateExtensionVariant variant : IntermediateExtensionVariant.values()) {
CaService maliciousExtensionService = runtime CaService maliciousExtensionService = runtime
@@ -977,7 +975,7 @@ final class PkiProofGateE2eTest {
runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca",
Optional.empty(), new SimpleAttributeSet())), Optional.empty(), new SimpleAttributeSet())),
variant.name()); variant.name());
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), variant.name()); assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size(), variant.name());
} }
AtomicReference<Credential> rawCredential = new AtomicReference<>(); AtomicReference<Credential> rawCredential = new AtomicReference<>();

View File

@@ -51,6 +51,9 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.math.BigInteger;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Comparator; import java.util.Comparator;
@@ -68,6 +71,10 @@ import java.util.stream.Collectors;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; 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.CancellationSignal;
import zeroecho.core.io.RepeatableContent; 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.CaKind;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState; 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.CaProfileBinding;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
@@ -557,7 +567,7 @@ public final class FilesystemPkiStoreTest {
store.putCa(ca1); store.putCa(ca1);
CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(), 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); store.putCa(ca2);
Optional<CaRecord> loaded = store.getCa(ca1.caId()); Optional<CaRecord> loaded = store.getCa(ca1.caId());
@@ -571,28 +581,42 @@ public final class FilesystemPkiStoreTest {
System.out.println("caHistoryCreatesCurrentAndHistory...ok"); 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 @Test
void caReferencesRequireValidStandaloneCredentialsAndPreserveOrder() throws Exception { void caReferencesRequireValidStandaloneCredentialsAndPreserveOrder() throws Exception {
System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder"); System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder");
Path root = tmp.resolve("store-ca-authority"); Path root = tmp.resolve("store-ca-authority");
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
Credential first = TestObjects.minimalCredential(store, "SERIAL-FIRST", "profile-ca"); CaRecord ordered = TestObjects.minimalCaRecord(store, "ca-ordered", CaState.ACTIVE);
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()));
store.putCa(ordered); store.putCa(ordered);
assertEquals(List.of(second.credentialId(), first.credentialId()), assertEquals(ordered.issuerIds(), store.getCa(ordered.caId()).orElseThrow().issuerIds());
store.getCa(ordered.caId()).orElseThrow().credentialIds()); PkiId missingIssuer = new PkiId("issuer-missing");
CaRecord missing = new CaRecord(new PkiId("ca-missing"), CaKind.ROOT, CaState.ACTIVE, CaRecord missing = new CaRecord(new PkiId("ca-missing"), CaKind.ROOT, CaState.ACTIVE,
new KeyRef("key-missing"), new SubjectRef("CN=Missing"), 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)); 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 }); new byte[] { 9, 9, 9 });
assertThrows(IllegalStateException.class, () -> store.getCa(ordered.caId())); assertThrows(IllegalStateException.class, () -> store.getCa(ordered.caId()));
assertThrows(IllegalStateException.class, store::listCas); assertThrows(IllegalStateException.class, store::listCas);
@@ -613,22 +637,20 @@ public final class FilesystemPkiStoreTest {
} }
@Test @Test
void snapshotReconstructsSharedCredentialOnceWithNewTargetReference() throws Exception { void snapshotReconstructsIssuerGenerationsWithNewTargetReferences() throws Exception {
System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference"); System.out.println("snapshotReconstructsIssuerGenerationsWithNewTargetReferences");
Path root = tmp.resolve("store-snapshot-authority"); Path root = tmp.resolve("store-snapshot-authority");
Path snapshot = tmp.resolve("snapshot-authority"); Path snapshot = tmp.resolve("snapshot-authority");
FsPkiStoreOptions options = nonStrictSnapshotOptions(); FsPkiStoreOptions options = nonStrictSnapshotOptions();
PkiId credentialId; PkiId credentialId;
String sourceContentId; String sourceContentId;
try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) {
Credential shared = TestObjects.minimalCredential(source, "SERIAL-SHARED", "profile-ca"); CaRecord first = TestObjects.minimalCaRecord(source, "ca-shared-one", CaState.ACTIVE);
source.putCredential(shared); CaRecord second = TestObjects.minimalCaRecord(source, "ca-shared-two", CaState.ACTIVE);
credentialId = shared.credentialId(); source.putCa(first);
sourceContentId = shared.content().contentId(); source.putCa(second);
source.putCa(new CaRecord(new PkiId("ca-shared-one"), CaKind.ROOT, CaState.ACTIVE, credentialId = source.getIssuerGeneration(first.currentIssuanceIssuerId()).orElseThrow().credentialId();
new KeyRef("key-shared-one"), new SubjectRef("CN=Shared One"), List.of(credentialId))); sourceContentId = source.getCredential(credentialId).orElseThrow().content().contentId();
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)));
source.exportSnapshot(snapshot, Instant.now()); source.exportSnapshot(snapshot, Instant.now());
} }
@@ -636,16 +658,17 @@ public final class FilesystemPkiStoreTest {
assertEquals(2, restored.listCas().size()); assertEquals(2, restored.listCas().size());
Credential credential = restored.getCredential(credentialId).orElseThrow(); Credential credential = restored.getCredential(credentialId).orElseThrow();
assertFalse(sourceContentId.equals(credential.content().contentId())); assertFalse(sourceContentId.equals(credential.content().contentId()));
assertEquals(List.of(credentialId), CaRecord restoredAuthority = restored.getCa(new PkiId("ca-shared-one")).orElseThrow();
restored.getCa(new PkiId("ca-shared-one")).orElseThrow().credentialIds()); assertEquals(credentialId, restored.getIssuerGeneration(
restoredAuthority.currentIssuanceIssuerId()).orElseThrow().credentialId());
} }
try (java.util.stream.Stream<Path> records = Files.list(snapshot.resolve("credentials").resolve("by-id"))) { try (java.util.stream.Stream<Path> 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 + ".content")));
assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".meta"))); assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".meta")));
assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".owners"))); assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".owners")));
System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference...ok"); System.out.println("snapshotReconstructsIssuerGenerationsWithNewTargetReferences...ok");
} }
@Test @Test
@@ -672,7 +695,8 @@ public final class FilesystemPkiStoreTest {
Credential standalone = restored.getCredential(standaloneId).orElseThrow(); Credential standalone = restored.getCredential(standaloneId).orElseThrow();
assertFalse(standaloneSourceContentId.equals(standalone.content().contentId())); assertFalse(standaloneSourceContentId.equals(standalone.content().contentId()));
CaRecord ca = restored.getCa(caId).orElseThrow(); 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"); System.out.println("snapshotPreservesStandaloneCredentialWithNewReferenceAndResolvableCa...ok");
} }
@@ -755,8 +779,8 @@ public final class FilesystemPkiStoreTest {
} }
@Test @Test
void snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa() throws Exception { void snapshotFailsClosedForMissingIssuerAuthorityInEveryMode() throws Exception {
System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa"); System.out.println("snapshotFailsClosedForMissingIssuerAuthorityInEveryMode");
Path root = tmp.resolve("store-snapshot-inconsistent"); Path root = tmp.resolve("store-snapshot-inconsistent");
Path strictSnapshot = tmp.resolve("snapshot-inconsistent-strict"); Path strictSnapshot = tmp.resolve("snapshot-inconsistent-strict");
Path nonStrictSnapshot = tmp.resolve("snapshot-inconsistent-nonstrict"); Path nonStrictSnapshot = tmp.resolve("snapshot-inconsistent-nonstrict");
@@ -766,20 +790,19 @@ public final class FilesystemPkiStoreTest {
CaRecord inconsistent = TestObjects.minimalCaRecord(source, "ca-inconsistent", CaState.ACTIVE); CaRecord inconsistent = TestObjects.minimalCaRecord(source, "ca-inconsistent", CaState.ACTIVE);
source.putCa(valid); source.putCa(valid);
source.putCa(inconsistent); 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(); FsPkiStoreOptions strict = strictSnapshotOptions();
assertThrows(IllegalStateException.class, assertThrows(IllegalStateException.class,
() -> new FsSnapshotExporter(strict).exportSnapshot(source, strictSnapshot, Instant.now())); () -> new FsSnapshotExporter(strict).exportSnapshot(source, strictSnapshot, Instant.now()));
assertFalse(Files.exists(strictSnapshot)); 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)) { System.out.println("...missing-issuer-authority=rejected");
assertEquals(List.of("ca-valid"), restored.listCas().stream() System.out.println("snapshotFailsClosedForMissingIssuerAuthorityInEveryMode...ok");
.map(ca -> ca.caId().value()).toList());
}
System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa...ok");
} }
@Test @Test
@@ -1283,14 +1306,46 @@ public final class FilesystemPkiStoreTest {
static CaRecord minimalCaRecord(PkiStore store, String caId, CaState state) throws IOException { static CaRecord minimalCaRecord(PkiStore store, String caId, CaState state) throws IOException {
PkiId id = new PkiId(caId); PkiId id = new PkiId(caId);
KeyRef issuerKeyRef = new KeyRef("issuer-key-" + caId); KeyRef issuerKeyRef = new KeyRef("issuer-key-" + caId);
SubjectRef subjectRef = new SubjectRef("CN=" + caId); SubjectRef subjectRef = new SubjectRef("CN=" + caId);
PkiId credentialId = new PkiId("cred-ca-" + caId);
Credential cred = minimalCredential(store, "CA-" + caId, "profile-ca"); PkiId issuerId = IssuerGeneration.idFor(id, credentialId);
store.putCredential(cred); 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, 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) { static CertificateProfile minimalProfile(String profileId) {

View File

@@ -249,15 +249,16 @@ final class FsCodecTest {
@Test @Test
void caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag() { void caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag() {
System.out.println("caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag"); System.out.println("caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag");
PkiId first = new PkiId("credential-first"); PkiId first = new PkiId("issuer-first");
PkiId second = new PkiId("credential-second"); PkiId second = new PkiId("issuer-second");
CaRecord original = new CaRecord(new PkiId("ca-codec"), CaKind.ROOT, CaState.ACTIVE, 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); byte[] encoded = FsCodec.encode(FsCodec.CA_RECORD, original);
CaRecord decoded = FsCodec.decode(FsCodec.CA_RECORD, encoded); CaRecord decoded = FsCodec.decode(FsCodec.CA_RECORD, encoded);
assertEquals(original, decoded); assertEquals(original, decoded);
assertEquals(List.of(first, second), decoded.credentialIds()); assertEquals(List.of(first, second), decoded.issuerIds());
byte[] embeddedValueTag = encoded.clone(); byte[] embeddedValueTag = encoded.clone();
int firstIdentifier = indexOf(embeddedValueTag, first.value().getBytes(StandardCharsets.UTF_8)); int firstIdentifier = indexOf(embeddedValueTag, first.value().getBytes(StandardCharsets.UTF_8));

View File

@@ -209,10 +209,36 @@ public final class PkiTestRuntime implements AutoCloseable {
} }
public Credential caCredential(zeroecho.pki.api.ca.CaRecord ca, int index) { 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(); 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<PkiId> 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<PkiId> 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, private record UntrustedReference(String storeId, String contentId, Encoding encoding, long length, String sha256,
DurableContentReference.Lifecycle lifecycle) implements DurableContentReference { DurableContentReference.Lifecycle lifecycle) implements DurableContentReference {
} }