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:
@@ -459,6 +459,11 @@ class PkiCliTest {
|
||||
return (operation, cancellation) -> outcome(operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public zeroecho.pki.application.PkiRepository repository() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
closes.incrementAndGet();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"serverName": "zeroecho-admin",
|
||||
"realm": {
|
||||
"realmId": "production",
|
||||
@@ -74,5 +74,45 @@
|
||||
"gracefulShutdownMillis": 30000,
|
||||
"forcedShutdownMillis": 10000
|
||||
},
|
||||
"publicListener": {
|
||||
"enabled": true,
|
||||
"address": "127.0.0.1",
|
||||
"port": 8444,
|
||||
"tlsProvider": {
|
||||
"id": "jsse-pkcs12",
|
||||
"properties": {
|
||||
"keyStore": "tls/public-server-identity.p12",
|
||||
"keyStorePasswordEnvironment": "ZEROECHO_PUBLIC_TLS_KEYSTORE_PASSWORD",
|
||||
"trustStore": "tls/repository-clients-trust.p12",
|
||||
"trustStorePasswordEnvironment": "ZEROECHO_PUBLIC_TLS_TRUSTSTORE_PASSWORD"
|
||||
}
|
||||
},
|
||||
"allowPlaintextLoopback": false,
|
||||
"authentication": {
|
||||
"mode": "DIRECT_MTLS",
|
||||
"directClientMappings": [{
|
||||
"mappingId": "repository-client",
|
||||
"principalId": "repository-client",
|
||||
"subjectPublicKeyInfoSha256": "3333333333333333333333333333333333333333333333333333333333333333"
|
||||
}]
|
||||
},
|
||||
"maximumHeaderBytes": 32768,
|
||||
"maximumBodyBytes": 1024,
|
||||
"execution": {
|
||||
"transportWorkers": 4,
|
||||
"transportQueueCapacity": 32,
|
||||
"operationWorkers": 4,
|
||||
"operationQueueCapacity": 32,
|
||||
"maximumAdmittedRequests": 32,
|
||||
"defaultDeadlineMillis": 30000,
|
||||
"maximumDeadlineMillis": 120000,
|
||||
"gracefulShutdownMillis": 30000,
|
||||
"forcedShutdownMillis": 10000
|
||||
},
|
||||
"maximumStreamDurationMillis": 120000,
|
||||
"publicImmutableCacheMillis": 86400000,
|
||||
"publicAliasCacheMillis": 300000,
|
||||
"authorityListExposed": true
|
||||
},
|
||||
"runtime": {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"serverName": "zeroecho-admin",
|
||||
"realm": {
|
||||
"realmId": "production",
|
||||
@@ -74,5 +74,6 @@
|
||||
"gracefulShutdownMillis": 30000,
|
||||
"forcedShutdownMillis": 10000
|
||||
},
|
||||
"publicListener": {"enabled": false},
|
||||
"runtime": {}
|
||||
}
|
||||
|
||||
93
docs/pki-server-public-repository.md
Normal file
93
docs/pki-server-public-repository.md
Normal 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.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"serverName": "zeroecho-admin-nginx",
|
||||
"realm": {
|
||||
"realmId": "production",
|
||||
@@ -40,5 +40,6 @@
|
||||
"maximumForwardedChainBytes": 524288
|
||||
},
|
||||
"execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000},
|
||||
"publicListener": {"enabled":false},
|
||||
"runtime": {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"serverName": "zeroecho-admin-proxy",
|
||||
"realm": {
|
||||
"realmId": "production",
|
||||
@@ -41,5 +41,31 @@
|
||||
"maximumForwardedChainBytes": 524288
|
||||
},
|
||||
"execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000},
|
||||
"publicListener": {
|
||||
"enabled":true,
|
||||
"address":"127.0.0.1",
|
||||
"port":8444,
|
||||
"tlsProvider":{"id":"jsse-pkcs12","properties":{"keyStore":"tls/public-server-identity.p12","keyStorePasswordEnvironment":"ZEROECHO_PUBLIC_TLS_KEYSTORE_PASSWORD","trustStore":"tls/proxy-transport-trust.p12","trustStorePasswordEnvironment":"ZEROECHO_PROXY_TRUSTSTORE_PASSWORD"}},
|
||||
"allowPlaintextLoopback":false,
|
||||
"authentication": {
|
||||
"mode":"TRUSTED_REVERSE_PROXY",
|
||||
"proxyTransportMappings":[{"mappingId":"public-proxy-transport","principalId":"trusted-proxy","certificateSha256":"1111111111111111111111111111111111111111111111111111111111111111"}],
|
||||
"forwardedClientMappings":[{"mappingId":"public-forwarded-client","principalId":"repository-client","subjectPublicKeyInfoSha256":"3333333333333333333333333333333333333333333333333333333333333333"}],
|
||||
"trustedProxyPrincipalIds":["trusted-proxy"],
|
||||
"forwardedCertificateFormat":"RFC9440",
|
||||
"forwardedCertificateHeaderName":"Client-Cert",
|
||||
"forwardedCertificateChainHeaderName":"Client-Cert-Chain",
|
||||
"administrativeClientTrust":{"id":"jsse-pkcs12-client-trust","properties":{"trustStore":"tls/repository-clients-trust.p12","trustStorePasswordEnvironment":"ZEROECHO_PUBLIC_CLIENT_TRUSTSTORE_PASSWORD"}},
|
||||
"maximumForwardedCertificateBytes":65536,
|
||||
"maximumForwardedChainBytes":524288
|
||||
},
|
||||
"maximumHeaderBytes":65536,
|
||||
"maximumBodyBytes":1024,
|
||||
"execution":{"transportWorkers":4,"transportQueueCapacity":32,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":32,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000},
|
||||
"maximumStreamDurationMillis":120000,
|
||||
"publicImmutableCacheMillis":86400000,
|
||||
"publicAliasCacheMillis":300000,
|
||||
"authorityListExposed":true
|
||||
},
|
||||
"runtime": {}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ import zeroecho.pki.api.PkiId;
|
||||
|
||||
/** Durable disclosure authority and capability-based retrieval decision service. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" })
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel",
|
||||
"PMD.CyclomaticComplexity" })
|
||||
public final class DisclosureService {
|
||||
/** Durable one-time delivery classification without retaining the raw token. */
|
||||
public enum DeliveryState {
|
||||
@@ -267,18 +268,56 @@ public final class DisclosureService {
|
||||
return switch (record.policy()) {
|
||||
case PUBLIC -> Decision.ALLOWED;
|
||||
case PUBLIC_UNLISTED -> capabilityToken.filter(token -> validCapability(record.objectId(), token))
|
||||
.isPresent() ? Decision.ALLOWED : Decision.DENIED;
|
||||
.isPresent() || principal.isPresent() && (ownerRelationship || explicitAdministrativePermission)
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled)
|
||||
.filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC)
|
||||
.map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED);
|
||||
case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED;
|
||||
case NOT_PUBLISHED -> explicitAdministrativePermission || ownerRelationship
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case NOT_PUBLISHED -> Decision.DENIED;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates public repository retrieval using a point-addressable bearer
|
||||
* credential. Capability and principal authority are not combined.
|
||||
*/
|
||||
public synchronized Decision decideRepository(PkiId objectId, Optional<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. */
|
||||
public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt,
|
||||
String actorPrincipalId) {
|
||||
@@ -335,6 +374,32 @@ public final class DisclosureService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean validBearer(PkiId objectId, String bearer) {
|
||||
if (bearer == null || !bearer.matches("cap-[0-9a-f]{32}\\.[A-Za-z0-9_-]{43}")) return false;
|
||||
int separator = bearer.indexOf('.');
|
||||
DisclosureService.Capability capability;
|
||||
try {
|
||||
capability = store.requireCapability(bearer.substring(0, separator));
|
||||
} catch (IllegalArgumentException unavailable) {
|
||||
return false;
|
||||
}
|
||||
byte[] token;
|
||||
try {
|
||||
token = java.util.Base64.getUrlDecoder().decode(bearer.substring(separator + 1));
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return token.length == 32 && !capability.revoked() && capability.realmId().equals(realmId)
|
||||
&& capability.objectId().equals(objectId) && "RETRIEVE".equals(capability.action())
|
||||
&& clock.instant().isBefore(capability.expiresAt())
|
||||
&& MessageDigest.isEqual(capability.tokenCommitment(),
|
||||
capabilityCommitment(objectId, capability.expiresAt(), token));
|
||||
} finally {
|
||||
java.util.Arrays.fill(token, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] capabilityCommitment(PkiId objectId, Instant expiry, byte[] token) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -140,7 +140,15 @@ public final class OperationSecurityDescriptors {
|
||||
control(ServerControlOperation.RevokeCapability.NAME, Permission.Action.DISCLOSURE_CAPABILITY_REVOKE, Permission.ResourceType.CAPABILITY, true, false),
|
||||
control(ServerControlOperation.InspectAuditView.REDACTED, Permission.Action.AUDIT_READ_REDACTED, Permission.ResourceType.AUDIT, false, false),
|
||||
control(ServerControlOperation.InspectAuditView.FULL, Permission.Action.AUDIT_READ_FULL, Permission.ResourceType.AUDIT, false, false),
|
||||
control(ServerControlOperation.InspectAuditView.PII, Permission.Action.AUDIT_READ_PII, Permission.ResourceType.AUDIT, false, false)));
|
||||
control(ServerControlOperation.InspectAuditView.PII, Permission.Action.AUDIT_READ_PII, Permission.ResourceType.AUDIT, false, false),
|
||||
control(ServerControlOperation.InspectRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_READ,
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, false, false),
|
||||
control(ServerControlOperation.ListRepositoryAliases.NAME, Permission.Action.REPOSITORY_ALIAS_READ,
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, false, false),
|
||||
control(ServerControlOperation.SetRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_MANAGE,
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, true, true),
|
||||
control(ServerControlOperation.RemoveRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_MANAGE,
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, true, true)));
|
||||
}
|
||||
|
||||
/** Creates a registry and rejects duplicate operation identities. */
|
||||
@@ -360,6 +368,18 @@ public final class OperationSecurityDescriptors {
|
||||
case ServerControlOperation.RevokeCapability value -> "capability=" + atom(value.capabilityId());
|
||||
case ServerControlOperation.InspectAuditView value -> "object=" + atom(value.objectId().value())
|
||||
+ ";view=" + value.view().name();
|
||||
case ServerControlOperation.InspectRepositoryAlias value -> "authority="
|
||||
+ atom(value.authorityId().value()) + ";type=" + value.type().name();
|
||||
case ServerControlOperation.ListRepositoryAliases value -> "offset=" + value.offset()
|
||||
+ ";limit=" + value.limit();
|
||||
case ServerControlOperation.SetRepositoryAlias value -> "authority="
|
||||
+ atom(value.authorityId().value()) + ";type=" + value.type().name() + ";target="
|
||||
+ atom(value.targetObjectId().value()) + ";issuer="
|
||||
+ atom(value.issuerId().map(zeroecho.pki.api.PkiId::value).orElse("")) + ";expected="
|
||||
+ atom(value.expectedCurrentCommitment().orElse(""));
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> "authority="
|
||||
+ atom(value.authorityId().value()) + ";type=" + value.type().name() + ";expected="
|
||||
+ atom(value.expectedCurrentCommitment());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ public final class Permission {
|
||||
AUDIT_READ_REDACTED(120), AUDIT_READ_FULL(121), AUDIT_READ_PII(122), AUDIT_EXPORT(123),
|
||||
AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142),
|
||||
DISCLOSURE_READ(160), DISCLOSURE_CHANGE(161), DISCLOSURE_CAPABILITY_ISSUE(162),
|
||||
DISCLOSURE_CAPABILITY_REVOKE(163);
|
||||
DISCLOSURE_CAPABILITY_REVOKE(163), REPOSITORY_ALIAS_READ(164), REPOSITORY_ALIAS_MANAGE(165);
|
||||
|
||||
private final int code;
|
||||
|
||||
@@ -112,7 +112,7 @@ public final class Permission {
|
||||
REALM(1), SERVER_CONFIGURATION(2), PRINCIPAL(3), ROLE(4), GRANT(5), AUTHORITY(10),
|
||||
ISSUER(11), PROFILE(12), POLICY(13), X509_BINDING(14), REQUEST(20), CERTIFICATE(21),
|
||||
REVOCATION(22), STATUS_OBJECT(23), PUBLICATION(24), AUDIT(30), BACKUP(31), RESTORE(32),
|
||||
DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36);
|
||||
DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36), REPOSITORY_ALIAS(37);
|
||||
private final int code;
|
||||
ResourceType(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
|
||||
@@ -38,6 +38,7 @@ import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
@@ -45,6 +46,7 @@ import javax.net.ssl.SSLContext;
|
||||
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
import zeroecho.pki.server.http.AdministrativeAuthenticator;
|
||||
import zeroecho.pki.server.http.PkiHttpsTransport;
|
||||
import zeroecho.pki.server.http.PublicRepositoryTransport;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticator;
|
||||
|
||||
/**
|
||||
@@ -64,15 +66,21 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
private final ServerRealmContext realm;
|
||||
private final PkiServerAuthenticator authenticator;
|
||||
private final PkiHttpsTransport transport;
|
||||
private final Optional<PkiServerAuthenticator> publicAuthenticator;
|
||||
private final Optional<PublicRepositoryTransport> publicTransport;
|
||||
private final AtomicReference<State> state;
|
||||
|
||||
private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, PkiHttpsTransport transport,
|
||||
Optional<PkiServerAuthenticator> publicAuthenticator,
|
||||
Optional<PublicRepositoryTransport> publicTransport,
|
||||
AtomicReference<State> state) {
|
||||
this.configuration = configuration;
|
||||
this.realm = realm;
|
||||
this.authenticator = authenticator;
|
||||
this.transport = transport;
|
||||
this.publicAuthenticator = publicAuthenticator;
|
||||
this.publicTransport = publicTransport;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
@@ -101,6 +109,8 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
ServerRealmContext realm = null;
|
||||
PkiServerAuthenticator authenticator = null;
|
||||
PkiHttpsTransport transport = null;
|
||||
PkiServerAuthenticator publicAuthenticator = null;
|
||||
PublicRepositoryTransport publicTransport = null;
|
||||
try {
|
||||
SSLContext tls = PkiHttpsTransport.resolveTls(exact, loader);
|
||||
realm = ServerRealmContext.open(exact.realm(), runtimeDependencies, clock, random);
|
||||
@@ -119,11 +129,23 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
ServerRealmContext sharedRealm = realm;
|
||||
transport = PkiHttpsTransport.startResolved(exact, sharedRealm, authenticator, clock, random, tls,
|
||||
() -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN);
|
||||
if (exact.publicListener().isPresent()) {
|
||||
PkiServerConfiguration.PublicListener publicConfiguration = exact.publicListener().orElseThrow();
|
||||
validateTrustedProxies(publicConfiguration.authentication(), realm);
|
||||
AdministrativeAuthenticator resolvedPublicAuthenticator = AdministrativeAuthenticator.create(
|
||||
publicConfiguration.authentication(), realm::principal,
|
||||
realm.gateway()::authorizeForwardedIdentity, clock, loader);
|
||||
publicAuthenticator = resolvedPublicAuthenticator;
|
||||
publicTransport = PublicRepositoryTransport.start(publicConfiguration, sharedRealm,
|
||||
resolvedPublicAuthenticator, clock, random, loader,
|
||||
() -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN);
|
||||
}
|
||||
state.set(State.READY);
|
||||
realm.auditTransport("SERVER_READY", "system", Map.of("state", "READY"));
|
||||
return new PkiHttpsServer(exact, realm, authenticator, transport, state);
|
||||
return new PkiHttpsServer(exact, realm, authenticator, transport,
|
||||
Optional.ofNullable(publicAuthenticator), Optional.ofNullable(publicTransport), state);
|
||||
} catch (RuntimeException | Error primary) {
|
||||
closePartial(transport, realm, authenticator, state, primary);
|
||||
closePartial(publicTransport, publicAuthenticator, transport, realm, authenticator, state, primary);
|
||||
throw primary;
|
||||
}
|
||||
}
|
||||
@@ -139,6 +161,12 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
return transport.address();
|
||||
}
|
||||
|
||||
/** @return actual public listener address when the repository listener is enabled */
|
||||
public Optional<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 */
|
||||
public ServerRealmContext realm() {
|
||||
if (state.get() != State.READY) throw new IllegalStateException("HTTPS server is not ready");
|
||||
@@ -174,25 +202,50 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
primary = failure;
|
||||
}
|
||||
transport.quiesce();
|
||||
publicTransport.ifPresent(PublicRepositoryTransport::quiesce);
|
||||
try {
|
||||
if (publicTransport.isPresent()) {
|
||||
publicTransport.orElseThrow().shutdown(configuration.publicListener().orElseThrow()
|
||||
.execution().gracefulShutdown());
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
primary = suppress(primary, failure);
|
||||
}
|
||||
try {
|
||||
transport.shutdown(configuration.execution().gracefulShutdown());
|
||||
} catch (Throwable failure) {
|
||||
primary = suppress(primary, failure);
|
||||
}
|
||||
if (publicAuthenticator.isPresent()) primary = close(publicAuthenticator.orElseThrow(), primary);
|
||||
primary = close(authenticator, primary);
|
||||
primary = close(realm, primary);
|
||||
state.set(State.TERMINATED);
|
||||
rethrow(primary);
|
||||
}
|
||||
|
||||
private static void closePartial(PkiHttpsTransport transport, ServerRealmContext realm,
|
||||
private static void closePartial(PublicRepositoryTransport publicTransport,
|
||||
PkiServerAuthenticator publicAuthenticator, PkiHttpsTransport transport, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, AtomicReference<State> state, Throwable primary) {
|
||||
primary = close(publicTransport, primary);
|
||||
primary = close(publicAuthenticator, primary);
|
||||
primary = close(transport, primary);
|
||||
primary = close(authenticator, primary);
|
||||
close(realm, primary);
|
||||
state.set(State.TERMINATED);
|
||||
}
|
||||
|
||||
private static void validateTrustedProxies(PkiServerConfiguration.Authentication authentication,
|
||||
ServerRealmContext realm) {
|
||||
if (authentication.mode() != AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY) return;
|
||||
for (String principalId : authentication.trustedProxyPrincipalIds()) {
|
||||
SecurityPrincipal principal = realm.principal(principalId);
|
||||
if (!principal.enabled() || principal.type() != SecurityPrincipal.Type.SERVICE) {
|
||||
throw new IllegalArgumentException("Trusted proxy principal is unavailable");
|
||||
}
|
||||
realm.gateway().validateForwardingPrincipal(principalId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable close(AutoCloseable resource, Throwable primary) {
|
||||
if (resource == null) return primary;
|
||||
try {
|
||||
|
||||
@@ -58,13 +58,15 @@ import zeroecho.pki.spi.ProviderConfig;
|
||||
* @param authentication strict client-certificate mappings
|
||||
* @param execution bounded execution and shutdown policy
|
||||
* @param runtime process-local capability references
|
||||
* @param publicListener optional separately bounded public repository listener
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public record PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm,
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) {
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime,
|
||||
Optional<PublicListener> publicListener) {
|
||||
|
||||
/** Current server configuration schema. */
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
public static final int CURRENT_VERSION = 3;
|
||||
|
||||
/** Validates all security-sensitive fields before resource allocation. */
|
||||
public PkiServerConfiguration {
|
||||
@@ -75,9 +77,27 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
Objects.requireNonNull(authentication, "authentication");
|
||||
Objects.requireNonNull(execution, "execution");
|
||||
Objects.requireNonNull(runtime, "runtime");
|
||||
publicListener = Objects.requireNonNull(publicListener, "publicListener");
|
||||
if (!listener.clientCertificateRequired()) {
|
||||
throw new IllegalArgumentException("Administrative HTTPS requires client certificates");
|
||||
}
|
||||
publicListener.ifPresent(publicConfiguration -> {
|
||||
if (listener.port() != 0
|
||||
&& listener.port() == publicConfiguration.port()) {
|
||||
boolean addressOverlap = listener.address().equals(publicConfiguration.address())
|
||||
|| listener.address().isAnyLocalAddress()
|
||||
|| publicConfiguration.address().isAnyLocalAddress();
|
||||
if (addressOverlap) {
|
||||
throw new IllegalArgumentException("Administrative and public listeners conflict");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates a configuration with the public repository listener disabled. */
|
||||
public PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm,
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) {
|
||||
this(version, serverName, realm, listener, authentication, execution, runtime, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,6 +348,53 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Separately bounded public repository listener configuration.
|
||||
*
|
||||
* @param address canonical bind address
|
||||
* @param port TCP port, including zero for an ephemeral test listener
|
||||
* @param tlsProvider optional explicit TLS provider; absent only for explicitly permitted loopback development
|
||||
* @param allowPlaintextLoopback whether the loopback-only development exception is selected
|
||||
* @param authentication direct or trusted-proxy optional-client authentication configuration
|
||||
* @param maximumHeaderBytes bounded public request-header bytes
|
||||
* @param maximumBodyBytes bounded public request-body bytes; repository methods still reject bodies
|
||||
* @param execution isolated public transport and stream execution bounds
|
||||
* @param maximumStreamDuration finite stream deadline
|
||||
* @param publicImmutableCache immutable-object public cache duration
|
||||
* @param publicAliasCache stable-alias public cache duration
|
||||
* @param authorityListExposed whether safe public authority metadata routes are enabled
|
||||
*/
|
||||
public record PublicListener(InetAddress address, int port, Optional<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.
|
||||
*
|
||||
@@ -356,4 +423,11 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static void nonNegative(Duration value, Duration maximum, String name) {
|
||||
Objects.requireNonNull(value, name);
|
||||
if (value.isNegative() || value.compareTo(maximum) > 0) {
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,12 @@ public final class PkiServerConfigurationCodec {
|
||||
/** Decodes one bounded strict configuration document. */
|
||||
public static PkiServerConfiguration decode(byte[] document) {
|
||||
Fields root = Fields.of(StrictJson.parse(document, MAXIMUM_CONFIGURATION_BYTES));
|
||||
root.exact("version", "serverName", "realm", "listener", "authentication", "execution", "runtime");
|
||||
root.allowed(Set.of("version", "serverName", "realm", "listener", "authentication", "execution",
|
||||
"runtime", "publicListener"));
|
||||
PkiServerConfiguration configuration = new PkiServerConfiguration(root.integer("version"),
|
||||
root.text("serverName"), realm(root.object("realm")), listener(root.object("listener")),
|
||||
authentication(root.object("authentication")), execution(root.object("execution")),
|
||||
runtime(root.object("runtime")));
|
||||
runtime(root.object("runtime")), publicListener(root.object("publicListener")));
|
||||
root.complete();
|
||||
return configuration;
|
||||
}
|
||||
@@ -271,6 +272,31 @@ public final class PkiServerConfigurationCodec {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Optional<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) {
|
||||
value.allowed(Set.of("keyUnlockEnvironmentVariable"));
|
||||
PkiServerConfiguration.RuntimeCapabilities result = new PkiServerConfiguration.RuntimeCapabilities(
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,9 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
|
||||
ServerControlOperation.InspectBreakGlass, ServerControlOperation.ListBreakGlass,
|
||||
ServerControlOperation.RevokeBreakGlass, ServerControlOperation.InspectDisclosure,
|
||||
ServerControlOperation.SetDisclosure, ServerControlOperation.IssueCapability,
|
||||
ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView {
|
||||
ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView,
|
||||
ServerControlOperation.InspectRepositoryAlias, ServerControlOperation.ListRepositoryAliases,
|
||||
ServerControlOperation.SetRepositoryAlias, ServerControlOperation.RemoveRepositoryAlias {
|
||||
|
||||
/** @return stable operation identity */
|
||||
String name();
|
||||
@@ -271,6 +273,41 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
|
||||
case METADATA_FULL, CONTENT_FULL -> FULL; case PII_FULL -> PII; }; }
|
||||
}
|
||||
|
||||
/** Exact repository alias inspection. */
|
||||
record InspectRepositoryAlias(PkiId authorityId, RepositoryAliasService.Type type)
|
||||
implements ServerControlOperation {
|
||||
public static final String NAME = "repository.alias.inspect";
|
||||
public InspectRepositoryAlias { Objects.requireNonNull(authorityId); Objects.requireNonNull(type); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
/** Bounded repository alias page. */
|
||||
record ListRepositoryAliases(int offset, int limit) implements ServerControlOperation {
|
||||
public static final String NAME = "repository.alias.list";
|
||||
public ListRepositoryAliases { page(offset, limit); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
/** Exact CRL or chain alias publication with expected-current protection. */
|
||||
record SetRepositoryAlias(PkiId authorityId, RepositoryAliasService.Type type, PkiId targetObjectId,
|
||||
Optional<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) {
|
||||
if (offset < 0 || limit <= 0 || limit > 256) throw invalid();
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ public final class ServerControlOperationExecutor {
|
||||
private final BreakGlassService breakGlass;
|
||||
private final DisclosureService disclosure;
|
||||
private final AuditorViews auditorViews;
|
||||
private final Optional<RepositoryAliasService> repositoryAliases;
|
||||
private final OperationSecurityDescriptors descriptors;
|
||||
private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies;
|
||||
|
||||
@@ -68,6 +69,7 @@ public final class ServerControlOperationExecutor {
|
||||
ServerControlStore store, RoleTemplateCatalog roles, AuthorizationEngine authorization,
|
||||
ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
|
||||
AuditorViews auditorViews,
|
||||
Optional<RepositoryAliasService> repositoryAliases,
|
||||
OperationSecurityDescriptors descriptors,
|
||||
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies) {
|
||||
this.realmId = Objects.requireNonNull(realmId, "realmId");
|
||||
@@ -79,6 +81,7 @@ public final class ServerControlOperationExecutor {
|
||||
this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass");
|
||||
this.disclosure = Objects.requireNonNull(disclosure, "disclosure");
|
||||
this.auditorViews = Objects.requireNonNull(auditorViews, "auditorViews");
|
||||
this.repositoryAliases = Objects.requireNonNull(repositoryAliases, "repositoryAliases");
|
||||
this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
|
||||
this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
|
||||
}
|
||||
@@ -165,6 +168,29 @@ public final class ServerControlOperationExecutor {
|
||||
case ServerControlOperation.RevokeCapability value -> ordinary(operation,
|
||||
capability(disclosure.revokeCapability(value.capabilityId(), actor)));
|
||||
case ServerControlOperation.InspectAuditView value -> ordinary(operation, auditView(value, actor));
|
||||
case ServerControlOperation.InspectRepositoryAlias value -> ordinary(operation,
|
||||
alias(aliases().require(value.authorityId(), value.type())));
|
||||
case ServerControlOperation.ListRepositoryAliases value -> ordinary(operation,
|
||||
page(aliases().list(value.offset(), value.limit()), ServerControlOperationExecutor::alias));
|
||||
case ServerControlOperation.SetRepositoryAlias value -> ordinary(operation,
|
||||
alias(publishAlias(value, actor)));
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> ordinary(operation,
|
||||
alias(aliases().remove(value.authorityId(), value.type(), value.expectedCurrentCommitment(), actor)));
|
||||
};
|
||||
}
|
||||
|
||||
private RepositoryAliasService aliases() {
|
||||
return repositoryAliases.orElseThrow(() -> new IllegalStateException("Repository aliases are unavailable"));
|
||||
}
|
||||
|
||||
private RepositoryAliasService.Record publishAlias(ServerControlOperation.SetRepositoryAlias value,
|
||||
String actor) {
|
||||
return switch (value.type()) {
|
||||
case CURRENT_CRL -> aliases().setCurrentCrl(value.authorityId(), value.targetObjectId(),
|
||||
value.expectedCurrentCommitment(), actor);
|
||||
case CURRENT_CHAIN -> aliases().setCurrentChain(value.authorityId(),
|
||||
value.issuerId().orElseThrow(() -> new IllegalArgumentException("issuerId is required")),
|
||||
value.targetObjectId(), value.expectedCurrentCommitment(), actor);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,6 +317,19 @@ public final class ServerControlOperationExecutor {
|
||||
return object("principalId", text(value.principalId()), "type", text(value.type().name()),
|
||||
"displayName", text(value.displayName()), "enabled", bool(value.enabled()));
|
||||
}
|
||||
|
||||
private static PkiOperationValue alias(RepositoryAliasService.Record value) {
|
||||
Map<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) {
|
||||
List<PkiOperationValue> actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code))
|
||||
.map(item -> (PkiOperationValue) text(item.name())).toList();
|
||||
|
||||
@@ -79,7 +79,7 @@ import zeroecho.pki.spi.store.TransactionalMetadataStore;
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel",
|
||||
"PMD.ControlStatementBraces", "PMD.PreserveStackTrace", "PMD.CloseResource",
|
||||
"PMD.UseEnumCollections", "PMD.CommentRequired", "PMD.UncommentedEmptyMethodBody",
|
||||
"PMD.TooManyMethods" })
|
||||
"PMD.TooManyMethods", "PMD.ExcessivePublicCount" })
|
||||
public final class ServerControlStore implements AutoCloseable {
|
||||
/** Stable namespace for the realm record. */ public static final String REALM = "io.zeroecho.server.realm";
|
||||
/** Stable namespace for principals. */ public static final String PRINCIPAL = "io.zeroecho.server.principal";
|
||||
@@ -89,9 +89,11 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
/** Stable namespace for break-glass records. */ public static final String BREAK_GLASS = "io.zeroecho.server.break-glass";
|
||||
/** Stable namespace for disclosure records. */ public static final String DISCLOSURE = "io.zeroecho.server.disclosure";
|
||||
/** Stable namespace for capability commitments. */ public static final String CAPABILITY = "io.zeroecho.server.capability";
|
||||
/** Stable namespace for non-authoritative public repository aliases. */
|
||||
public static final String REPOSITORY_ALIAS = "io.zeroecho.server.repository-alias";
|
||||
|
||||
private static final int MAGIC = 0x5a455331;
|
||||
private static final int SCHEMA = 2;
|
||||
private static final int SCHEMA = 3;
|
||||
private static final int MAXIMUM_RECORD_BYTES = 1_048_576;
|
||||
private static final int MAXIMUM_STRING_BYTES = 16_384;
|
||||
private static final int MAXIMUM_COLLECTION = 4_096;
|
||||
@@ -103,6 +105,7 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
private static final int KIND_BREAK_GLASS = 6;
|
||||
private static final int KIND_DISCLOSURE = 7;
|
||||
private static final int KIND_CAPABILITY = 8;
|
||||
private static final int KIND_REPOSITORY_ALIAS = 9;
|
||||
|
||||
/**
|
||||
* Durable realm-control identity and commitments.
|
||||
@@ -308,6 +311,11 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable"));
|
||||
}
|
||||
|
||||
/** Finds one disclosure record without converting absence into an error. */
|
||||
public synchronized Optional<DisclosureService.Record> findDisclosure(PkiId objectId) {
|
||||
return read(DISCLOSURE, objectId.value(), KIND_DISCLOSURE, ServerControlStore::readDisclosure);
|
||||
}
|
||||
|
||||
/** Returns a bounded stable page of disclosure records. */
|
||||
public synchronized Page<DisclosureService.Record> disclosures(int offset, int limit) {
|
||||
return scanPage(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure, offset, limit);
|
||||
@@ -346,6 +354,43 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
.filter(item -> item.objectId().equals(objectId)).toList();
|
||||
}
|
||||
|
||||
/** Creates one exact repository alias record. */
|
||||
public synchronized void createRepositoryAlias(RepositoryAliasService.Record value) {
|
||||
create(REPOSITORY_ALIAS, value.aliasId(), output -> writeRepositoryAlias(output, value));
|
||||
}
|
||||
|
||||
/** Finds one exact repository alias record. */
|
||||
public synchronized Optional<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
|
||||
* stable metadata snapshots before the realm is exposed.
|
||||
@@ -359,6 +404,7 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass);
|
||||
scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure);
|
||||
scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability);
|
||||
scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias);
|
||||
}
|
||||
|
||||
/** Validates durable relationships against the active strict role catalog. */
|
||||
@@ -392,6 +438,10 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
: scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability)) {
|
||||
requirePrincipalReference(principals, capability.issuerPrincipalId());
|
||||
}
|
||||
for (RepositoryAliasService.Record alias
|
||||
: scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias)) {
|
||||
requirePrincipalReference(principals, alias.actorPrincipalId());
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens no sidecar authority and closes the sole metadata authority idempotently. */
|
||||
@@ -555,6 +605,7 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
case BreakGlassService.Record item -> item.breakGlassId();
|
||||
case DisclosureService.Record item -> item.objectId().value();
|
||||
case DisclosureService.Capability item -> item.capabilityId();
|
||||
case RepositoryAliasService.Record item -> item.aliasId();
|
||||
default -> throw new IllegalArgumentException("Unsupported control record type");
|
||||
};
|
||||
}
|
||||
@@ -704,6 +755,32 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
DisclosureService.DeliveryState.fromCode(in.readInt()), in.readBoolean());
|
||||
}
|
||||
|
||||
private static void writeRepositoryAlias(DataOutputStream out, RepositoryAliasService.Record value)
|
||||
throws IOException {
|
||||
out.writeInt(KIND_REPOSITORY_ALIAS);
|
||||
writeString(out, value.aliasId());
|
||||
writeString(out, value.realmId().value());
|
||||
writeString(out, value.authorityId().value());
|
||||
out.writeInt(value.type().code());
|
||||
writeString(out, value.targetObjectId().value());
|
||||
writeOptionalPki(out, value.issuerId());
|
||||
writeOptionalPki(out, value.pathId());
|
||||
writeString(out, value.targetCommitment());
|
||||
writeInstant(out, value.createdAt());
|
||||
writeInstant(out, value.updatedAt());
|
||||
writeString(out, value.actorPrincipalId());
|
||||
out.writeInt(value.state().code());
|
||||
writeString(out, value.recordCommitment());
|
||||
}
|
||||
|
||||
private static RepositoryAliasService.Record readRepositoryAlias(DataInputStream in) throws IOException {
|
||||
return new RepositoryAliasService.Record(readString(in), new RealmId(readString(in)),
|
||||
new PkiId(readString(in)), RepositoryAliasService.Type.fromCode(in.readInt()),
|
||||
new PkiId(readString(in)), readOptionalPki(in), readOptionalPki(in), readString(in),
|
||||
readInstant(in), readInstant(in), readString(in), RepositoryAliasService.State.fromCode(in.readInt()),
|
||||
readString(in));
|
||||
}
|
||||
|
||||
private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException {
|
||||
writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId());
|
||||
writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId());
|
||||
|
||||
@@ -163,6 +163,7 @@ public final class ServerOperationGateway {
|
||||
public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control,
|
||||
RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals,
|
||||
BreakGlassService breakGlass, DisclosureService disclosure, AuditorViews auditorViews,
|
||||
Optional<RepositoryAliasService> repositoryAliases,
|
||||
OperationSecurityDescriptors descriptors, PkiOperationExecutor executor,
|
||||
PkiResourceScopeResolver resourceScopes,
|
||||
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies,
|
||||
@@ -177,7 +178,8 @@ public final class ServerOperationGateway {
|
||||
this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
|
||||
this.executor = Objects.requireNonNull(executor, "executor");
|
||||
this.controlExecutor = new ServerControlOperationExecutor(realmId, exposure, control, roles,
|
||||
authorization, approvals, breakGlass, disclosure, auditorViews, descriptors, approvalPolicies);
|
||||
authorization, approvals, breakGlass, disclosure, auditorViews, repositoryAliases, descriptors,
|
||||
approvalPolicies);
|
||||
this.resourceScopes = Objects.requireNonNull(resourceScopes, "resourceScopes");
|
||||
this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
@@ -194,7 +196,8 @@ public final class ServerOperationGateway {
|
||||
this(realmId, exposure, control, roles, authorization, approvals, breakGlass,
|
||||
new DisclosureService(realmId, control, DisclosureService.Defaults.recommended(), clock,
|
||||
new java.security.SecureRandom(), auditSink), new AuditorViews(clock, auditSink),
|
||||
descriptors, executor, resourceScopes, approvalPolicies, clock, auditSink, openCheck);
|
||||
Optional.empty(), descriptors, executor, resourceScopes, approvalPolicies, clock, auditSink,
|
||||
openCheck);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,6 +76,8 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
private final ApprovalService approvals;
|
||||
private final BreakGlassService breakGlass;
|
||||
private final DisclosureService disclosure;
|
||||
private final RepositoryAliasService repositoryAliases;
|
||||
private final PublicRepositoryGateway publicRepository;
|
||||
private final AuditorViews auditorViews;
|
||||
private final ServerOperationGateway gateway;
|
||||
private final SafeAudit audit;
|
||||
@@ -84,6 +86,7 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
private ServerRealmContext(ServerRealmConfiguration configuration, ServerControlStore control,
|
||||
PkiSession session, RoleTemplateCatalog roles, AuthorizationEngine authorization,
|
||||
ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
|
||||
RepositoryAliasService repositoryAliases,
|
||||
AuditorViews auditorViews, AuditSink auditSink, Clock clock) {
|
||||
this.configuration = configuration;
|
||||
this.control = control;
|
||||
@@ -93,10 +96,15 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
this.approvals = approvals;
|
||||
this.breakGlass = breakGlass;
|
||||
this.disclosure = disclosure;
|
||||
this.repositoryAliases = repositoryAliases;
|
||||
this.publicRepository = new PublicRepositoryGateway(configuration.realmId(), configuration.authorityExposure(),
|
||||
session.repository(), control, roles, authorization, breakGlass, disclosure, repositoryAliases,
|
||||
this::requireOpen);
|
||||
this.auditorViews = auditorViews;
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(),
|
||||
control, roles, authorization, approvals, breakGlass, disclosure, auditorViews,
|
||||
Optional.of(repositoryAliases),
|
||||
new OperationSecurityDescriptors(),
|
||||
session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink,
|
||||
this::requireOpen);
|
||||
@@ -143,9 +151,11 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
BreakGlassService breakGlass = new BreakGlassService(control, clock, audit);
|
||||
DisclosureService disclosure = new DisclosureService(exact.realmId(), control,
|
||||
exact.disclosureDefaults(), clock, random, audit);
|
||||
RepositoryAliasService aliases = new RepositoryAliasService(exact.realmId(), control,
|
||||
session.repository(), disclosure, clock, audit);
|
||||
AuditorViews views = new AuditorViews(clock, audit);
|
||||
ServerRealmContext result = new ServerRealmContext(exact, control, session, roles, authorization,
|
||||
approvals, breakGlass, disclosure, views, audit, clock);
|
||||
approvals, breakGlass, disclosure, aliases, views, audit, clock);
|
||||
result.audit.record("REALM_OPEN", "system", Optional.empty(), Map.of("realm", exact.realmId().value()));
|
||||
return result;
|
||||
} catch (RuntimeException | Error primary) {
|
||||
@@ -168,6 +178,10 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
public BreakGlassService breakGlass() { requireOpen(); return breakGlass; }
|
||||
/** @return durable disclosure decision service */
|
||||
public DisclosureService disclosure() { requireOpen(); return disclosure; }
|
||||
/** @return durable non-authoritative public repository alias service */
|
||||
public RepositoryAliasService repositoryAliases() { requireOpen(); return repositoryAliases; }
|
||||
/** @return read-only disclosed public repository gateway */
|
||||
public PublicRepositoryGateway publicRepository() { requireOpen(); return publicRepository; }
|
||||
/** @return explicit auditor projection service */
|
||||
public AuditorViews auditorViews() { requireOpen(); return auditorViews; }
|
||||
/** @return authorized typed-operation gateway */
|
||||
|
||||
@@ -122,6 +122,35 @@ public final class AdministrativeAuthenticator implements PkiServerAuthenticator
|
||||
return proxy(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates only the backend transport for an anonymous public request.
|
||||
* This method is valid solely in trusted-proxy mode and never manufactures an
|
||||
* end-client principal.
|
||||
*
|
||||
* @param context backend TLS peer context without operation data
|
||||
* @return authorized trusted-proxy principal, or empty on any safe failure
|
||||
*/
|
||||
public Optional<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) {
|
||||
if (ForwardedClientCertificateParser.containsForwardedIdentity(context.requestHeaders())) {
|
||||
return rejected(AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(),
|
||||
|
||||
@@ -56,6 +56,7 @@ import zeroecho.pki.server.ApprovalService;
|
||||
import zeroecho.pki.server.DisclosureService;
|
||||
import zeroecho.pki.server.Permission;
|
||||
import zeroecho.pki.server.RealmId;
|
||||
import zeroecho.pki.server.RepositoryAliasService;
|
||||
import zeroecho.pki.server.RoleTemplateCatalog;
|
||||
import zeroecho.pki.server.SecurityPrincipal;
|
||||
import zeroecho.pki.server.ServerControlOperation;
|
||||
@@ -366,6 +367,30 @@ final class HttpOperationCodec {
|
||||
yield new ServerControlOperation.InspectAuditView(fields.pkiId("objectId"), view,
|
||||
fields.optionalText("reasonReference"));
|
||||
}
|
||||
case ServerControlOperation.InspectRepositoryAlias.NAME -> {
|
||||
fields.exact("authorityId", "type");
|
||||
yield new ServerControlOperation.InspectRepositoryAlias(fields.pkiId("authorityId"),
|
||||
RepositoryAliasService.Type.valueOf(fields.text("type")));
|
||||
}
|
||||
case ServerControlOperation.ListRepositoryAliases.NAME -> {
|
||||
fields.exact("offset", "limit");
|
||||
yield new ServerControlOperation.ListRepositoryAliases(fields.integer("offset"),
|
||||
fields.integer("limit"));
|
||||
}
|
||||
case ServerControlOperation.SetRepositoryAlias.NAME -> {
|
||||
fields.allowed(Set.of("authorityId", "type", "targetObjectId", "issuerId",
|
||||
"expectedCurrentCommitment"));
|
||||
yield new ServerControlOperation.SetRepositoryAlias(fields.pkiId("authorityId"),
|
||||
RepositoryAliasService.Type.valueOf(fields.text("type")),
|
||||
fields.pkiId("targetObjectId"), fields.optionalText("issuerId").map(PkiId::new),
|
||||
fields.optionalText("expectedCurrentCommitment"));
|
||||
}
|
||||
case ServerControlOperation.RemoveRepositoryAlias.NAME -> {
|
||||
fields.exact("authorityId", "type", "expectedCurrentCommitment");
|
||||
yield new ServerControlOperation.RemoveRepositoryAlias(fields.pkiId("authorityId"),
|
||||
RepositoryAliasService.Type.valueOf(fields.text("type")),
|
||||
fields.text("expectedCurrentCommitment"));
|
||||
}
|
||||
default -> throw new SecurityException("Control operation is not exposed");
|
||||
};
|
||||
}
|
||||
@@ -378,6 +403,12 @@ final class HttpOperationCodec {
|
||||
case ServerControlOperation.EvaluateAuthorization value -> value.resource().scope();
|
||||
case ServerControlOperation.RequestApproval value -> value.targetResource().scope();
|
||||
case ServerControlOperation.CreateBreakGlass value -> value.grant().scope();
|
||||
case ServerControlOperation.InspectRepositoryAlias value -> authorityScope(realmId, authority,
|
||||
value.authorityId());
|
||||
case ServerControlOperation.SetRepositoryAlias value -> authorityScope(realmId, authority,
|
||||
value.authorityId());
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> authorityScope(realmId, authority,
|
||||
value.authorityId());
|
||||
default -> new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty());
|
||||
};
|
||||
}
|
||||
@@ -388,10 +419,19 @@ final class HttpOperationCodec {
|
||||
case ServerControlOperation.SetDisclosure value -> Optional.of(value.objectId());
|
||||
case ServerControlOperation.IssueCapability value -> Optional.of(value.objectId());
|
||||
case ServerControlOperation.InspectAuditView value -> Optional.of(value.objectId());
|
||||
case ServerControlOperation.SetRepositoryAlias value -> Optional.of(value.targetObjectId());
|
||||
default -> Optional.empty();
|
||||
};
|
||||
}
|
||||
|
||||
private static Permission.Scope authorityScope(RealmId realmId, Optional<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) {
|
||||
return operation instanceof PkiOperation.ListAuthorities
|
||||
|| operation instanceof PkiOperation.CreateAuthority
|
||||
|
||||
@@ -84,12 +84,12 @@ final class HttpResponses {
|
||||
byte[] token = sensitive.consumeToken();
|
||||
try {
|
||||
MapBuilder result = new MapBuilder(sensitive.safeResult().fields());
|
||||
result.put("token", new PkiOperationValue.Text(java.util.Base64.getUrlEncoder()
|
||||
.withoutPadding().encodeToString(token)));
|
||||
Response encoded = encode(200, requestId, operation, "SUCCEEDED",
|
||||
new PkiOperationValue.ObjectValue(result.values()), null);
|
||||
String capabilityId = ((PkiOperationValue.Text) sensitive.safeResult().fields()
|
||||
.get("capabilityId")).value();
|
||||
result.put("token", new PkiOperationValue.Text(
|
||||
zeroecho.pki.server.DisclosureService.bearerCredential(capabilityId, token)));
|
||||
Response encoded = encode(200, requestId, operation, "SUCCEEDED",
|
||||
new PkiOperationValue.ObjectValue(result.values()), null);
|
||||
return new Response(encoded.statusCode(), encoded.body(), true, java.util.Optional.of(capabilityId));
|
||||
} finally {
|
||||
java.util.Arrays.fill(token, (byte) 0);
|
||||
|
||||
@@ -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()); }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ import zeroecho.pki.server.PkiServerConfiguration;
|
||||
final class ServerRuntime implements AutoCloseable {
|
||||
static final String TRANSPORT_PREFIX = "zeroecho-pki-https-";
|
||||
static final String OPERATION_PREFIX = "zeroecho-pki-operation-";
|
||||
static final String PUBLIC_TRANSPORT_PREFIX = "zeroecho-pki-public-https-";
|
||||
static final String PUBLIC_STREAM_PREFIX = "zeroecho-pki-public-stream-";
|
||||
static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown";
|
||||
|
||||
private final ThreadPoolExecutor transport;
|
||||
@@ -65,11 +67,15 @@ final class ServerRuntime implements AutoCloseable {
|
||||
private final AtomicBoolean accepting = new AtomicBoolean(true);
|
||||
|
||||
ServerRuntime(PkiServerConfiguration.Execution configuration) {
|
||||
this(configuration, false);
|
||||
}
|
||||
|
||||
ServerRuntime(PkiServerConfiguration.Execution configuration, boolean publicLane) {
|
||||
this.configuration = configuration;
|
||||
transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(),
|
||||
new NamedThreadFactory(TRANSPORT_PREFIX));
|
||||
new NamedThreadFactory(publicLane ? PUBLIC_TRANSPORT_PREFIX : TRANSPORT_PREFIX));
|
||||
operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(),
|
||||
new NamedThreadFactory(OPERATION_PREFIX));
|
||||
new NamedThreadFactory(publicLane ? PUBLIC_STREAM_PREFIX : OPERATION_PREFIX));
|
||||
admitted = new Semaphore(configuration.maximumAdmittedRequests(), true);
|
||||
}
|
||||
|
||||
@@ -132,8 +138,10 @@ final class ServerRuntime implements AutoCloseable {
|
||||
quiesce();
|
||||
transport.shutdown();
|
||||
operations.shutdown();
|
||||
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX);
|
||||
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX);
|
||||
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_TRANSPORT_PREFIX);
|
||||
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_STREAM_PREFIX);
|
||||
if (!operationWorker) {
|
||||
await(operations, configuration.gracefulShutdown());
|
||||
}
|
||||
|
||||
@@ -126,6 +126,18 @@ public final class HttpServerTestSupport {
|
||||
return configuration(directory, authentication);
|
||||
}
|
||||
|
||||
public static PkiServerConfiguration withPublicListener(PkiServerConfiguration source,
|
||||
PkiServerConfiguration.Authentication authentication) throws Exception {
|
||||
PkiServerConfiguration.Execution publicExecution = new PkiServerConfiguration.Execution(2, 4, 2, 4, 4,
|
||||
Duration.ofSeconds(5), Duration.ofSeconds(10), Duration.ofSeconds(1), Duration.ofSeconds(1));
|
||||
PkiServerConfiguration.PublicListener listener = new PkiServerConfiguration.PublicListener(
|
||||
InetAddress.getByName("127.0.0.1"), 0,
|
||||
Optional.of(new ProviderConfig("test-tls", Map.of())), false, authentication, 16_384,
|
||||
1_024, publicExecution, Duration.ofSeconds(10), Duration.ofHours(1), Duration.ofMinutes(1), true);
|
||||
return new PkiServerConfiguration(source.version(), source.serverName(), source.realm(), source.listener(),
|
||||
source.authentication(), source.execution(), source.runtime(), Optional.of(listener));
|
||||
}
|
||||
|
||||
private static PkiServerConfiguration.Authentication directAuthentication(X509Certificate client)
|
||||
throws Exception {
|
||||
PkiServerConfiguration.ClientCertificateMapping mapping = mapping("administrator-map", "administrator",
|
||||
|
||||
@@ -43,6 +43,7 @@ import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.io.OutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
@@ -56,6 +57,28 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.IssuerRef;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.SubjectRef;
|
||||
import zeroecho.pki.api.Validity;
|
||||
import zeroecho.pki.api.ca.CaKind;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.CaState;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.ca.IssuerGenerationState;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.credential.CaProfileBinding;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.credential.CredentialStatus;
|
||||
import zeroecho.pki.api.profile.CertificateProfileRef;
|
||||
import zeroecho.pki.impl.framework.x509.bc.SimpleAttributeSet;
|
||||
import zeroecho.pki.impl.fs.FilesystemPkiStore;
|
||||
import zeroecho.pki.impl.fs.FsPkiStoreOptions;
|
||||
import zeroecho.pki.spi.store.ContentSink;
|
||||
import zeroecho.pki.server.http.MutualTlsAuthenticator;
|
||||
import zeroecho.pki.server.http.TestTlsProvider;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
|
||||
@@ -79,6 +102,33 @@ class PkiHttpsServerTest {
|
||||
"{\"version\":1,\"version\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Execution(1, 1, 1, 1,
|
||||
1, Duration.ofSeconds(2), Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1)));
|
||||
PkiServerConfiguration.Execution publicExecution = new PkiServerConfiguration.Execution(1, 1, 1, 1, 1,
|
||||
Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1));
|
||||
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.PublicListener(
|
||||
java.net.InetAddress.getByName("192.0.2.1"), 8444, Optional.empty(), true,
|
||||
valid.authentication(), 4_096, 1_024, publicExecution, Duration.ofSeconds(1), Duration.ZERO,
|
||||
Duration.ZERO, true));
|
||||
PkiServerConfiguration.PublicListener conflicting = new PkiServerConfiguration.PublicListener(
|
||||
valid.listener().address(), 8443, Optional.of(valid.listener().tlsProvider()), false,
|
||||
valid.authentication(), 4_096, 1_024, publicExecution, Duration.ofSeconds(1), Duration.ZERO,
|
||||
Duration.ZERO, true);
|
||||
PkiServerConfiguration.Listener fixedAdministrative = new PkiServerConfiguration.Listener(
|
||||
valid.listener().address(), 8443, valid.listener().tlsProvider(), true, 16_384, 65_536);
|
||||
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration(valid.version(),
|
||||
valid.serverName(), valid.realm(), fixedAdministrative, valid.authentication(), valid.execution(),
|
||||
valid.runtime(), Optional.of(conflicting)));
|
||||
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration(2, valid.serverName(),
|
||||
valid.realm(), valid.listener(), valid.authentication(), valid.execution(), valid.runtime()));
|
||||
String production = java.nio.file.Files.readString(java.nio.file.Path.of(
|
||||
"..", "docs", "pki-server-production-example.json"));
|
||||
assertTrue(PkiServerConfigurationCodec.decode(production.getBytes(
|
||||
java.nio.charset.StandardCharsets.UTF_8)).publicListener().isEmpty());
|
||||
assertThrows(IllegalArgumentException.class, () -> PkiServerConfigurationCodec.decode(production
|
||||
.replace("\"publicListener\": {\"enabled\": false},\n", "")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
assertThrows(IllegalArgumentException.class, () -> PkiServerConfigurationCodec.decode(production
|
||||
.replace("{\"enabled\": false}", "{\"enabled\":false,\"port\":8444}")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
System.out.println("...strict-security-fields=true");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
@@ -184,6 +234,124 @@ class PkiHttpsServerTest {
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isolatedPublicListenerAllowsAnonymousMetadataWithoutAdminRouteOverlap() throws Exception {
|
||||
System.out.println("isolatedPublicListenerAllowsAnonymousMetadataWithoutAdminRouteOverlap");
|
||||
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
|
||||
PkiServerConfiguration administrative = HttpServerTestSupport.configuration(
|
||||
temporaryDirectory.resolve("public-direct"), tls.clientCertificate());
|
||||
PkiServerConfiguration configuration = HttpServerTestSupport.withPublicListener(administrative,
|
||||
administrative.authentication());
|
||||
seed(configuration);
|
||||
try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(),
|
||||
ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) {
|
||||
HttpClient anonymous = HttpClient.newBuilder().sslContext(tls.anonymousContext()).build();
|
||||
URI publicBase = URI.create("https://localhost:" + server.publicAddress().orElseThrow().getPort());
|
||||
HttpResponse<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
|
||||
void packagedEntryPointProvidesHelpAndSafeValidationFailure() {
|
||||
System.out.println("packagedEntryPointProvidesHelpAndSafeValidationFailure");
|
||||
@@ -321,6 +489,40 @@ class PkiHttpsServerTest {
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
private SeededIssuer seedIssuer(java.nio.file.Path root, java.security.cert.X509Certificate certificate)
|
||||
throws Exception {
|
||||
PkiId authorityId = new PkiId("authority-public-root");
|
||||
PkiId credentialId = new PkiId("credential-public-root");
|
||||
PkiId issuerId = IssuerGeneration.idFor(authorityId, credentialId);
|
||||
IssuerChainPath path = IssuerChainPath.create(authorityId, issuerId, List.of(credentialId));
|
||||
DurableContentReference reference;
|
||||
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(),
|
||||
ServerTestSupport.CLOCK);
|
||||
ContentSink sink = store.stagedContent().beginContent(Encoding.DER,
|
||||
DurableContentReference.Lifecycle.PERSISTED);
|
||||
OutputStream output = sink.outputStream()) {
|
||||
output.write(certificate.getEncoded());
|
||||
reference = sink.complete();
|
||||
InstantBounds bounds = new InstantBounds(certificate.getNotBefore().toInstant(),
|
||||
certificate.getNotAfter().toInstant());
|
||||
KeyRef key = new KeyRef("public-root-key");
|
||||
SubjectRef subject = new SubjectRef(certificate.getSubjectX500Principal().getName());
|
||||
Credential credential = new Credential(credentialId, new FormatId("x509"),
|
||||
new IssuerRef(authorityId, issuerId, path.pathId()), subject,
|
||||
new Validity(bounds.notBefore(), bounds.notAfter()), certificate.getSerialNumber().toString(),
|
||||
new PkiId("public-key-public-root"),
|
||||
new CaProfileBinding(new CertificateProfileRef("public-root-profile", 1, new byte[32])),
|
||||
CredentialStatus.ISSUED, reference, new SimpleAttributeSet());
|
||||
store.putCredential(credential);
|
||||
store.putIssuerGeneration(new IssuerGeneration(issuerId, authorityId, credentialId, key,
|
||||
IssuerGenerationState.ACTIVE, "0".repeat(64), "1".repeat(64)));
|
||||
store.putIssuerChainPath(path);
|
||||
store.putCa(new CaRecord(authorityId, CaKind.ROOT, CaState.ACTIVE, key, subject,
|
||||
List.of(issuerId), issuerId, path.pathId()));
|
||||
}
|
||||
return new SeededIssuer(authorityId, credentialId, issuerId, path.pathId());
|
||||
}
|
||||
|
||||
private void seed(PkiServerConfiguration configuration) throws Exception {
|
||||
seed(configuration, true);
|
||||
}
|
||||
@@ -394,4 +596,8 @@ class PkiHttpsServerTest {
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private record SeededIssuer(PkiId authorityId, PkiId credentialId, PkiId issuerId, PkiId pathId) { }
|
||||
|
||||
private record InstantBounds(java.time.Instant notBefore, java.time.Instant notAfter) { }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,10 @@ class ServerControlOperationExecutorTest {
|
||||
void catalogContainsOneExplicitPairOfOperationFamilies() {
|
||||
System.out.println("catalogContainsOneExplicitPairOfOperationFamilies");
|
||||
OperationSecurityDescriptors catalog = new OperationSecurityDescriptors();
|
||||
assertEquals(50, catalog.descriptors().size());
|
||||
assertEquals(54, catalog.descriptors().size());
|
||||
assertEquals(16, catalog.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
|
||||
assertEquals(34, catalog.descriptors().values().stream()
|
||||
assertEquals(38, catalog.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION)
|
||||
.count());
|
||||
assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION,
|
||||
@@ -211,7 +211,7 @@ class ServerControlOperationExecutorTest {
|
||||
new AuthorizationEngine(ServerTestSupport.CLOCK),
|
||||
new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit()),
|
||||
new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), disclosure,
|
||||
new AuditorViews(ServerTestSupport.CLOCK, opened.audit()), descriptors,
|
||||
new AuditorViews(ServerTestSupport.CLOCK, opened.audit()), Optional.empty(), descriptors,
|
||||
Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, policy));
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class ServerOperationGatewayTest {
|
||||
void descriptorRegistryRejectsUnknownAndDuplicateOperations() {
|
||||
System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations");
|
||||
OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors();
|
||||
assertEquals(50, descriptors.descriptors().size());
|
||||
assertEquals(54, descriptors.descriptors().size());
|
||||
assertEquals(16, descriptors.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
|
||||
assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration()));
|
||||
|
||||
@@ -44,6 +44,8 @@ import zeroecho.pki.api.ca.CaRolloverCommand;
|
||||
import zeroecho.pki.api.ca.CaState;
|
||||
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
|
||||
import zeroecho.pki.api.ca.IntermediateCreateCommand;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
|
||||
/**
|
||||
@@ -179,4 +181,27 @@ public interface CaService {
|
||||
* @throws PkiException if listing fails
|
||||
*/
|
||||
List<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);
|
||||
}
|
||||
|
||||
@@ -34,21 +34,38 @@
|
||||
package zeroecho.pki.api;
|
||||
|
||||
/**
|
||||
* References an issuing CA entity.
|
||||
* References the exact issuer generation that produced a credential.
|
||||
*
|
||||
* @param caId identifier of the CA entity acting as issuer
|
||||
* @param issuerId canonical issuer-generation identifier
|
||||
* @param chainPathId exact issuance chain path selected for the credential
|
||||
*/
|
||||
public record IssuerRef(PkiId caId) {
|
||||
public record IssuerRef(PkiId caId, PkiId issuerId, PkiId chainPathId) {
|
||||
|
||||
/**
|
||||
* Creates a transient unresolved reference used only at framework adapter
|
||||
* boundaries. Application services must replace it before persistence.
|
||||
*
|
||||
* @param caId logical issuer authority
|
||||
*/
|
||||
public IssuerRef(PkiId caId) {
|
||||
this(caId, new PkiId("issuer-unresolved:" + caId.value()), new PkiId("path-unresolved:" + caId.value()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an issuer reference.
|
||||
*
|
||||
* @param caId CA identifier
|
||||
* @throws IllegalArgumentException if {@code caId} is null
|
||||
* @throws IllegalArgumentException if either identifier is {@code null}
|
||||
*/
|
||||
public IssuerRef {
|
||||
if (caId == null) {
|
||||
throw new IllegalArgumentException("caId must not be null");
|
||||
}
|
||||
if (issuerId == null) {
|
||||
throw new IllegalArgumentException("issuerId must not be null");
|
||||
}
|
||||
if (chainPathId == null) {
|
||||
throw new IllegalArgumentException("chainPathId must not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,17 +33,14 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.api.ca;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.SubjectRef;
|
||||
|
||||
/**
|
||||
* Represents a CA entity and the ordered identifiers of its issued CA
|
||||
* credentials.
|
||||
* Represents one logical CA authority and its explicit issuer generations.
|
||||
*
|
||||
* <p>
|
||||
* A CA entity may have multiple CA credentials to support:
|
||||
@@ -60,12 +57,12 @@ import zeroecho.pki.api.SubjectRef;
|
||||
* @param issuerKeyRef key reference used for issuing operations (private key
|
||||
* reference)
|
||||
* @param subjectRef normalized subject reference
|
||||
* @param credentialIds ordered identifiers of the credentials currently
|
||||
* associated with the entity (historical and active);
|
||||
* duplicates and {@code null} elements are rejected
|
||||
* @param issuerIds canonical issuer-generation identifiers owned by the authority
|
||||
* @param currentIssuanceIssuerId exact generation selected for new issuance
|
||||
* @param issuanceChainPathId exact chain path selected for issuance bundles
|
||||
*/
|
||||
public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyRef, SubjectRef subjectRef,
|
||||
List<PkiId> credentialIds) {
|
||||
List<PkiId> issuerIds, PkiId currentIssuanceIssuerId, PkiId issuanceChainPathId) {
|
||||
|
||||
/**
|
||||
* Creates a CA record.
|
||||
@@ -90,18 +87,19 @@ public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyR
|
||||
if (subjectRef == null) {
|
||||
throw new IllegalArgumentException("subjectRef must not be null");
|
||||
}
|
||||
if (credentialIds == null) {
|
||||
throw new IllegalArgumentException("credentialIds must not be null");
|
||||
if (issuerIds == null || issuerIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("issuerIds must not be null/empty");
|
||||
}
|
||||
Set<PkiId> uniqueCredentialIds = new HashSet<>(credentialIds.size());
|
||||
for (PkiId credentialId : credentialIds) {
|
||||
if (credentialId == null) {
|
||||
throw new IllegalArgumentException("credentialIds must not contain null");
|
||||
}
|
||||
if (!uniqueCredentialIds.add(credentialId)) {
|
||||
throw new IllegalArgumentException("credentialIds must not contain duplicates");
|
||||
}
|
||||
if (issuerIds.stream().anyMatch(java.util.Objects::isNull)
|
||||
|| issuerIds.size() != new java.util.HashSet<>(issuerIds).size()) {
|
||||
throw new IllegalArgumentException("issuerIds must contain unique non-null identifiers");
|
||||
}
|
||||
credentialIds = List.copyOf(credentialIds);
|
||||
if (currentIssuanceIssuerId == null || !issuerIds.contains(currentIssuanceIssuerId)) {
|
||||
throw new IllegalArgumentException("currentIssuanceIssuerId must identify an owned generation");
|
||||
}
|
||||
if (issuanceChainPathId == null) {
|
||||
throw new IllegalArgumentException("issuanceChainPathId must not be null");
|
||||
}
|
||||
issuerIds = List.copyOf(issuerIds);
|
||||
}
|
||||
}
|
||||
|
||||
104
pki/src/main/java/zeroecho/pki/api/ca/IssuerChainPath.java
Normal file
104
pki/src/main/java/zeroecho/pki/api/ca/IssuerChainPath.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
84
pki/src/main/java/zeroecho/pki/api/ca/IssuerGeneration.java
Normal file
84
pki/src/main/java/zeroecho/pki/api/ca/IssuerGeneration.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -386,7 +386,8 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
|
||||
Map<String, PkiOperationValue> fields = fields();
|
||||
fields.put("credentialId", text(credential.credentialId().value()));
|
||||
fields.put(FORMAT_ID, text(credential.formatId().value()));
|
||||
fields.put("issuerId", text(credential.issuerRef().caId().value()));
|
||||
fields.put("authorityId", text(credential.issuerRef().caId().value()));
|
||||
fields.put("issuerId", text(credential.issuerRef().issuerId().value()));
|
||||
fields.put("publicKeyId", text(credential.publicKeyId().value()));
|
||||
fields.put("profileId", text(profile.profileId()));
|
||||
fields.put("profileVersion", integer(profile.profileVersion()));
|
||||
@@ -623,7 +624,8 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
|
||||
fields.put("caId", text(record.caId().value()));
|
||||
fields.put("kind", text(record.kind().name()));
|
||||
fields.put("state", text(record.state().name()));
|
||||
fields.put("credentialCount", integer(record.credentialIds().size()));
|
||||
fields.put("issuerGenerationCount", integer(record.issuerIds().size()));
|
||||
fields.put("currentIssuanceIssuerId", text(record.currentIssuanceIssuerId().value()));
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,7 @@ final class DefaultPkiSession implements PkiSession {
|
||||
private final X509AlgorithmBindingRegistry algorithmBindings;
|
||||
private final PkiResourceScopeResolver resourceScopes;
|
||||
private final PkiOperationExecutor operations;
|
||||
private final PkiRepository repository;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit,
|
||||
@@ -134,10 +135,17 @@ final class DefaultPkiSession implements PkiSession {
|
||||
this.signatureWorkflow = graph.signatureWorkflow();
|
||||
this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings");
|
||||
this.resourceScopes = new DefaultPkiResourceScopeResolver(store, this::requireOpen);
|
||||
this.repository = new DefaultPkiRepository(store, this::requireOpen);
|
||||
this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities,
|
||||
requests, issuance, statusObjects, publications, algorithmBindings, this::requireOpen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PkiRepository repository() {
|
||||
requireOpen();
|
||||
return repository;
|
||||
}
|
||||
|
||||
/* default */ static PkiSession open(PkiSessionConfiguration configuration) {
|
||||
return open(configuration, runtimeDependencies(configuration), Clock.systemUTC(), ProductionBootstrap.INSTANCE);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -135,6 +135,9 @@ public interface PkiSession extends AutoCloseable {
|
||||
/** @return shared typed operation executor owned by this session */
|
||||
PkiOperationExecutor operations();
|
||||
|
||||
/** @return read-only authoritative repository facade owned by this session */
|
||||
PkiRepository repository();
|
||||
|
||||
/**
|
||||
* Closes services and backend resources in reverse construction order.
|
||||
* Repeated calls are harmless; primary and suppressed failures are preserved.
|
||||
|
||||
@@ -376,10 +376,13 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
|
||||
PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16));
|
||||
PkiId issuerId = zeroecho.pki.api.ca.IssuerGeneration.idFor(caId, credId);
|
||||
zeroecho.pki.api.ca.IssuerChainPath rootPath = IssuerAuthorities.rootPath(caId, issuerId, credId);
|
||||
|
||||
PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spki.bytes()));
|
||||
|
||||
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
|
||||
Credential credential = new Credential(credId, command.formatId(),
|
||||
new IssuerRef(caId, issuerId, rootPath.pathId()), request.subjectRef(),
|
||||
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
|
||||
CredentialStatus.ISSUED, CredentialContent.stage(store, certDer),
|
||||
SimpleAttributeSet.builder().build());
|
||||
@@ -387,8 +390,10 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
requireCaCertificateMatches(credential, credential, request, caId, CREATE_ROOT_REJECTED, BACKEND_CRED_MISMATCH);
|
||||
store.putCredential(credential);
|
||||
store.putIssuerGeneration(IssuerAuthorities.generation(caId, keyRef, credential));
|
||||
store.putIssuerChainPath(rootPath);
|
||||
CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, request.subjectRef(),
|
||||
List.of(credential.credentialId()));
|
||||
List.of(issuerId), issuerId, rootPath.pathId());
|
||||
store.putCa(ca);
|
||||
return caId;
|
||||
}
|
||||
@@ -448,6 +453,8 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
|
||||
PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16));
|
||||
PkiId issuerId = zeroecho.pki.api.ca.IssuerGeneration.idFor(caId, credId);
|
||||
zeroecho.pki.api.ca.IssuerChainPath rootPath = IssuerAuthorities.rootPath(caId, issuerId, credId);
|
||||
|
||||
byte[] spkiDer;
|
||||
try {
|
||||
@@ -464,7 +471,8 @@ public final class DefaultCaService implements CaService {
|
||||
ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
|
||||
command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime,
|
||||
Optional.empty(), serial, authority);
|
||||
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
|
||||
Credential credential = new Credential(credId, command.formatId(),
|
||||
new IssuerRef(caId, issuerId, rootPath.pathId()), request.subjectRef(),
|
||||
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
|
||||
CredentialStatus.ISSUED, command.existingCaCredential(),
|
||||
SimpleAttributeSet.builder().build());
|
||||
@@ -473,8 +481,10 @@ public final class DefaultCaService implements CaService {
|
||||
ROOT_CREDENTIAL_INVALID);
|
||||
requireValidImportedRoot(command, holder);
|
||||
store.putCredential(credential);
|
||||
store.putIssuerGeneration(IssuerAuthorities.generation(caId, command.keyRef(), credential));
|
||||
store.putIssuerChainPath(rootPath);
|
||||
CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), request.subjectRef(),
|
||||
List.of(credential.credentialId()));
|
||||
List.of(issuerId), issuerId, rootPath.pathId());
|
||||
store.putCa(ca);
|
||||
return caId;
|
||||
}
|
||||
@@ -526,15 +536,14 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
CaRecord issuer = getCa(command.issuerCaId());
|
||||
ensureActive(issuer, "issuer");
|
||||
if (issuer.credentialIds().isEmpty()) {
|
||||
throw new PkiException("Issuer CA has no credentials");
|
||||
}
|
||||
if (!framework.formatId().equals(command.formatId())) {
|
||||
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.empty(), "FORMAT_UNSUPPORTED");
|
||||
}
|
||||
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
|
||||
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
|
||||
zeroecho.pki.api.ca.IssuerGeneration parentGeneration = IssuerAuthorities.current(store, issuer);
|
||||
zeroecho.pki.api.ca.IssuerChainPath parentPath = IssuerAuthorities.issuancePath(store, issuer);
|
||||
requireHistoricalCaProfile(issuerCredential,
|
||||
issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA : CertificateProfileKind.INTERMEDIATE_CA);
|
||||
|
||||
@@ -568,18 +577,28 @@ public final class DefaultCaService implements CaService {
|
||||
}
|
||||
requireCaBinding(backendCredential, issue.profileReference(), CREATE_INT_REJECTED, command.formatId(),
|
||||
Optional.of(caId));
|
||||
Credential cred;
|
||||
Credential rawCredential;
|
||||
try {
|
||||
cred = CredentialSnapshots.copy(backendCredential);
|
||||
rawCredential = CredentialSnapshots.copy(backendCredential);
|
||||
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
|
||||
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId),
|
||||
BACKEND_CRED_MISMATCH);
|
||||
}
|
||||
requireCaCertificateMatches(cred, issuerCredential, issue, caId, CREATE_INT_REJECTED, BACKEND_CRED_MISMATCH);
|
||||
requireCaCertificateMatches(rawCredential, issuerCredential, issue, caId, CREATE_INT_REJECTED,
|
||||
BACKEND_CRED_MISMATCH);
|
||||
Credential cred = IssuerAuthorities.withIssuer(rawCredential,
|
||||
new IssuerRef(issuer.caId(), parentGeneration.issuerId(), parentPath.pathId()));
|
||||
store.putCredential(cred);
|
||||
zeroecho.pki.api.ca.IssuerGeneration subjectGeneration = IssuerAuthorities.generation(caId,
|
||||
command.keyRef().orElseThrow(), cred);
|
||||
zeroecho.pki.api.ca.IssuerChainPath subjectPath = IssuerAuthorities.childPath(caId,
|
||||
subjectGeneration.issuerId(), cred.credentialId(), parentPath);
|
||||
store.putIssuerGeneration(subjectGeneration);
|
||||
store.putIssuerChainPath(subjectPath);
|
||||
|
||||
CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(),
|
||||
issue.subjectRef(), List.of(cred.credentialId()));
|
||||
issue.subjectRef(), List.of(subjectGeneration.issuerId()), subjectGeneration.issuerId(),
|
||||
subjectPath.pathId());
|
||||
store.putCa(subject);
|
||||
return caId;
|
||||
}
|
||||
@@ -631,6 +650,8 @@ public final class DefaultCaService implements CaService {
|
||||
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
|
||||
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
|
||||
zeroecho.pki.api.ca.IssuerGeneration parentGeneration = IssuerAuthorities.current(store, issuer);
|
||||
zeroecho.pki.api.ca.IssuerChainPath parentPath = IssuerAuthorities.issuancePath(store, issuer);
|
||||
requireHistoricalCaProfile(issuerCredential,
|
||||
issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA : CertificateProfileKind.INTERMEDIATE_CA);
|
||||
|
||||
@@ -661,21 +682,30 @@ public final class DefaultCaService implements CaService {
|
||||
}
|
||||
requireCaBinding(backendCredential, gated.profileReference(), ISSUE_INT_REJECTED, command.formatId(),
|
||||
Optional.of(subject.caId()));
|
||||
Credential cred;
|
||||
Credential rawCredential;
|
||||
try {
|
||||
cred = CredentialSnapshots.copy(backendCredential);
|
||||
rawCredential = CredentialSnapshots.copy(backendCredential);
|
||||
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
|
||||
throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()),
|
||||
BACKEND_CRED_MISMATCH);
|
||||
}
|
||||
requireCaCertificateMatches(cred, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED,
|
||||
requireCaCertificateMatches(rawCredential, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED,
|
||||
BACKEND_CRED_MISMATCH);
|
||||
Credential cred = IssuerAuthorities.withIssuer(rawCredential,
|
||||
new IssuerRef(issuer.caId(), parentGeneration.issuerId(), parentPath.pathId()));
|
||||
store.putCredential(cred);
|
||||
zeroecho.pki.api.ca.IssuerGeneration subjectGeneration = IssuerAuthorities.generation(subject.caId(),
|
||||
subject.issuerKeyRef(), cred);
|
||||
zeroecho.pki.api.ca.IssuerChainPath subjectPath = IssuerAuthorities.childPath(subject.caId(),
|
||||
subjectGeneration.issuerId(), cred.credentialId(), parentPath);
|
||||
store.putIssuerGeneration(subjectGeneration);
|
||||
store.putIssuerChainPath(subjectPath);
|
||||
|
||||
List<PkiId> updated = new ArrayList<>(subject.credentialIds());
|
||||
updated.add(cred.credentialId());
|
||||
List<PkiId> updated = new ArrayList<>(subject.issuerIds());
|
||||
updated.add(subjectGeneration.issuerId());
|
||||
CaRecord updatedCa = new CaRecord(subject.caId(), subject.kind(), subject.state(), subject.issuerKeyRef(),
|
||||
subject.subjectRef(), List.copyOf(updated));
|
||||
subject.subjectRef(), List.copyOf(updated), subject.currentIssuanceIssuerId(),
|
||||
subject.issuanceChainPathId());
|
||||
store.putCa(updatedCa);
|
||||
return cred;
|
||||
}
|
||||
@@ -758,7 +788,8 @@ public final class DefaultCaService implements CaService {
|
||||
}
|
||||
|
||||
CaRecord updated = new CaRecord(existing.caId(), existing.kind(), state, existing.issuerKeyRef(),
|
||||
existing.subjectRef(), existing.credentialIds());
|
||||
existing.subjectRef(), existing.issuerIds(), existing.currentIssuanceIssuerId(),
|
||||
existing.issuanceChainPathId());
|
||||
store.putCa(updated);
|
||||
|
||||
if (LOG.isLoggable(Level.INFO)) {
|
||||
@@ -810,17 +841,67 @@ public final class DefaultCaService implements CaService {
|
||||
return false;
|
||||
}
|
||||
if (query.formatId().isPresent()) {
|
||||
if (r.credentialIds().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
PkiId lastId = r.credentialIds().get(r.credentialIds().size() - 1);
|
||||
Credential last = requireCredential(lastId);
|
||||
return query.formatId().get().equals(last.formatId());
|
||||
Credential current = IssuerAuthorities.currentCredential(store, r);
|
||||
return query.formatId().get().equals(current.formatId());
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public zeroecho.pki.api.ca.IssuerGeneration getIssuerGeneration(PkiId issuerId) {
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
return store.getIssuerGeneration(issuerId).orElseThrow(() -> new PkiException("Issuer generation not found"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public zeroecho.pki.api.ca.IssuerChainPath getIssuerChainPath(PkiId pathId) {
|
||||
Objects.requireNonNull(pathId, "pathId");
|
||||
return store.getIssuerChainPath(pathId).orElseThrow(() -> new PkiException("Issuer chain path not found"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<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) {
|
||||
if (ca.state() != CaState.ACTIVE) {
|
||||
throw new PkiException("CA not ACTIVE: " + role);
|
||||
@@ -829,37 +910,17 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use,
|
||||
EffectiveCredentialStatusResolver.Evaluation evaluation) {
|
||||
Credential lastRejected = null;
|
||||
EffectiveCredentialStatus lastStatus = null;
|
||||
for (PkiId credentialId : issuer.credentialIds()) {
|
||||
Credential credential = requireCredential(credentialId);
|
||||
if (credential == null || !formatId.equals(credential.formatId())) {
|
||||
continue;
|
||||
}
|
||||
EffectiveCredentialStatus status;
|
||||
try {
|
||||
status = evaluation.resolve(credential);
|
||||
} catch (PkiException exception) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use,
|
||||
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
|
||||
throw exception;
|
||||
}
|
||||
if (status == EffectiveCredentialStatus.USABLE) {
|
||||
return credential;
|
||||
}
|
||||
lastRejected = credential;
|
||||
lastStatus = status;
|
||||
Credential credential = IssuerAuthorities.currentCredential(store, issuer);
|
||||
if (!formatId.equals(credential.formatId())) {
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
}
|
||||
if (lastRejected != null) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use,
|
||||
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
|
||||
EffectiveCredentialStatus status = evaluation.resolve(credential);
|
||||
if (status != EffectiveCredentialStatus.USABLE) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use,
|
||||
"ISSUER_CREDENTIAL_UNAVAILABLE", status);
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
}
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
}
|
||||
|
||||
private Credential requireCredential(PkiId credentialId) {
|
||||
return store.getCredential(credentialId)
|
||||
.orElseThrow(() -> new PkiException("CA credential not found"));
|
||||
return credential;
|
||||
}
|
||||
|
||||
private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) {
|
||||
@@ -943,9 +1004,9 @@ public final class DefaultCaService implements CaService {
|
||||
return framework.formatId().equals(credential.formatId()) && credential.content().encoding() == Encoding.DER
|
||||
&& credential.status() == CredentialStatus.ISSUED
|
||||
&& credential.subjectRef().equals(request.subjectRef())
|
||||
&& credential.issuerRef()
|
||||
.equals(new IssuerRef(request.certificateType() == CertificateProfileKind.ROOT_CA ? subjectCaId
|
||||
: request.issuerCaId()));
|
||||
&& credential.issuerRef().caId()
|
||||
.equals(request.certificateType() == CertificateProfileKind.ROOT_CA ? subjectCaId
|
||||
: request.issuerCaId());
|
||||
}
|
||||
|
||||
private static boolean matchesCaCertificateIdentity(X509CertificateHolder holder,
|
||||
|
||||
@@ -234,10 +234,6 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
if (issuer.state() != CaState.ACTIVE) {
|
||||
throw new PkiException("Issuer CA not ACTIVE");
|
||||
}
|
||||
if (issuer.credentialIds().isEmpty()) {
|
||||
throw new PkiException("Issuer CA has no credentials");
|
||||
}
|
||||
|
||||
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
|
||||
ActiveCertificateProfile active;
|
||||
try {
|
||||
@@ -257,6 +253,8 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||
Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(),
|
||||
CredentialUse.END_ENTITY_ISSUER, statusEvaluation));
|
||||
zeroecho.pki.api.ca.IssuerGeneration generation = IssuerAuthorities.current(store, issuer);
|
||||
zeroecho.pki.api.ca.IssuerChainPath issuancePath = IssuerAuthorities.issuancePath(store, issuer);
|
||||
ValidatedCertificateRequest validated;
|
||||
try {
|
||||
validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred,
|
||||
@@ -274,8 +272,10 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH");
|
||||
}
|
||||
requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request());
|
||||
store.putCredential(bundle.credential());
|
||||
return bundle;
|
||||
Credential exactCredential = IssuerAuthorities.withIssuer(bundle.credential(),
|
||||
new zeroecho.pki.api.IssuerRef(issuer.caId(), generation.issuerId(), issuancePath.pathId()));
|
||||
store.putCredential(exactCredential);
|
||||
return new CredentialBundle(exactCredential, pathContent(issuancePath));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,32 +303,17 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
Objects.requireNonNull(issuer, "issuer");
|
||||
Objects.requireNonNull(formatId, "formatId");
|
||||
|
||||
Credential lastRejected = null;
|
||||
EffectiveCredentialStatus lastStatus = null;
|
||||
for (PkiId credentialId : issuer.credentialIds()) {
|
||||
Credential c = requireIssuerCredential(credentialId);
|
||||
if (c == null || !formatId.equals(c.formatId())) {
|
||||
continue;
|
||||
}
|
||||
EffectiveCredentialStatus status;
|
||||
try {
|
||||
status = evaluation.resolve(c);
|
||||
} catch (PkiException exception) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), c, use,
|
||||
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
|
||||
throw exception;
|
||||
}
|
||||
if (status == EffectiveCredentialStatus.USABLE) {
|
||||
return c;
|
||||
}
|
||||
lastRejected = c;
|
||||
lastStatus = status;
|
||||
Credential credential = IssuerAuthorities.currentCredential(store, issuer);
|
||||
if (!formatId.equals(credential.formatId())) {
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
}
|
||||
if (lastRejected != null) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use,
|
||||
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
|
||||
EffectiveCredentialStatus status = evaluation.resolve(credential);
|
||||
if (status != EffectiveCredentialStatus.USABLE) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use,
|
||||
"ISSUER_CREDENTIAL_UNAVAILABLE", status);
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
}
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
return credential;
|
||||
}
|
||||
|
||||
private Credential requireIssuerCredential(PkiId credentialId) {
|
||||
@@ -459,7 +444,7 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
validated.profileReference());
|
||||
if (!framework.formatId().equals(credential.formatId()) || credential.content().encoding() != Encoding.DER
|
||||
|| !credential.subjectRef().equals(validated.subjectRef())
|
||||
|| !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId()))
|
||||
|| !credential.issuerRef().caId().equals(validated.issuerCaId())
|
||||
|| credential.status() != CredentialStatus.ISSUED) {
|
||||
throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
|
||||
}
|
||||
@@ -585,16 +570,15 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
* credential.
|
||||
*
|
||||
* <p>
|
||||
* This implementation currently returns a minimal bundle containing only the
|
||||
* resolved leaf credential and an empty chain. Chain discovery, issuer path
|
||||
* construction, and publication-aware bundle assembly are intentionally left to
|
||||
* higher layers.
|
||||
* This implementation returns the resolved leaf credential with the exact
|
||||
* immutable chain path selected and persisted when the credential was issued.
|
||||
* It never infers a path from collection order, dates, filenames, or the public
|
||||
* repository's independently managed current-chain alias.
|
||||
* </p>
|
||||
*
|
||||
* @param command bundle construction command identifying the leaf credential;
|
||||
* must not be {@code null}
|
||||
* @return minimal credential bundle containing the resolved leaf credential and
|
||||
* no chain elements
|
||||
* @return credential bundle containing the resolved leaf and its exact issuance path
|
||||
* @throws NullPointerException if {@code command} is {@code null}
|
||||
* @throws PkiException if the requested credential does not exist in
|
||||
* the store or is not currently usable
|
||||
@@ -619,8 +603,18 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
throw new PkiException(
|
||||
"Credential trust rejected: code=" + StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE);
|
||||
}
|
||||
// Minimal bundle: leaf only. Chain selection and publication are higher-layer
|
||||
// concerns.
|
||||
return new CredentialBundle(leaf, List.of());
|
||||
zeroecho.pki.api.ca.IssuerChainPath path = store.getIssuerChainPath(leaf.issuerRef().chainPathId())
|
||||
.orElseThrow(() -> new PkiException("Credential issuance chain path not found"));
|
||||
if (!leaf.issuerRef().issuerId().equals(path.issuerId())
|
||||
|| !leaf.issuerRef().caId().equals(path.authorityId())) {
|
||||
throw new PkiException("Credential issuance chain path mismatch");
|
||||
}
|
||||
return new CredentialBundle(leaf, pathContent(path));
|
||||
}
|
||||
|
||||
private List<zeroecho.pki.api.content.DurableContentReference> pathContent(
|
||||
zeroecho.pki.api.ca.IssuerChainPath path) {
|
||||
return path.orderedCredentialIds().stream().map(this::requireIssuerCredential)
|
||||
.map(Credential::content).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,9 +214,6 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
||||
if (ca.state() != CaState.ACTIVE) {
|
||||
throw new PkiException("Issuer CA not ACTIVE");
|
||||
}
|
||||
if (ca.credentialIds().isEmpty()) {
|
||||
throw new PkiException("Issuer CA has no credentials");
|
||||
}
|
||||
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||
Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation);
|
||||
|
||||
@@ -488,35 +485,17 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
||||
|
||||
private Credential selectIssuerCredential(CaRecord ca, StatusObjectGenerateCommand command,
|
||||
EffectiveCredentialStatusResolver.Evaluation evaluation) {
|
||||
Credential lastRejected = null;
|
||||
EffectiveCredentialStatus lastStatus = null;
|
||||
List<PkiId> credentialIds = ca.credentialIds();
|
||||
for (int index = credentialIds.size() - 1; index >= 0; index--) {
|
||||
Credential credential = store.getCredential(credentialIds.get(index))
|
||||
.orElseThrow(DefaultStatusObjectService::crlGenerationFailure);
|
||||
if (credential == null || !command.formatId().equals(credential.formatId())) {
|
||||
continue;
|
||||
}
|
||||
EffectiveCredentialStatus status;
|
||||
try {
|
||||
status = evaluation.resolve(credential);
|
||||
} catch (PkiException exception) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential,
|
||||
CredentialUse.STATUS_OBJECT_ISSUER,
|
||||
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
|
||||
throw exception;
|
||||
}
|
||||
if (status == EffectiveCredentialStatus.USABLE) {
|
||||
return credential;
|
||||
}
|
||||
lastRejected = credential;
|
||||
lastStatus = status;
|
||||
Credential credential = IssuerAuthorities.currentCredential(store, ca);
|
||||
if (!command.formatId().equals(credential.formatId())) {
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
}
|
||||
if (lastRejected != null) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected,
|
||||
CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
|
||||
EffectiveCredentialStatus status = evaluation.resolve(credential);
|
||||
if (status != EffectiveCredentialStatus.USABLE) {
|
||||
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential,
|
||||
CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", status);
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
}
|
||||
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||
return credential;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
110
pki/src/main/java/zeroecho/pki/impl/core/IssuerAuthorities.java
Normal file
110
pki/src/main/java/zeroecho/pki/impl/core/IssuerAuthorities.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,16 @@ import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
|
||||
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.orch.SigningSubmissionId;
|
||||
import zeroecho.pki.api.orch.WorkflowStateRecord;
|
||||
@@ -167,12 +173,12 @@ import zeroecho.pki.spi.store.RevocationHistory;
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods",
|
||||
"PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl",
|
||||
"PMD.PreserveStackTrace" })
|
||||
"PMD.PreserveStackTrace", "PMD.NcssCount" })
|
||||
public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
|
||||
|
||||
/* package */ static final String CURRENT_STORE_VERSION = "v4";
|
||||
/* package */ static final String CURRENT_STORE_VERSION = "v5";
|
||||
private static final String SIGN_RECORD_NAMESPACE = "io.zeroecho.pki.signing-record";
|
||||
private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner";
|
||||
private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record";
|
||||
@@ -580,6 +586,101 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<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
|
||||
public void putCredential(final Credential credential) {
|
||||
requireStoreUsable();
|
||||
@@ -603,15 +704,112 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||
private CaRecord validateCaCredentialReferences(CaRecord record) {
|
||||
for (PkiId credentialId : record.credentialIds()) {
|
||||
if (getCredential(credentialId).isEmpty()) {
|
||||
throw new IllegalStateException("CA credential reference is missing");
|
||||
for (PkiId issuerId : record.issuerIds()) {
|
||||
IssuerGeneration generation = getIssuerGeneration(issuerId)
|
||||
.orElseThrow(() -> new IllegalStateException("CA issuer-generation reference is missing"));
|
||||
if (!record.caId().equals(generation.authorityId())) {
|
||||
throw new IllegalStateException("CA issuer-generation authority mismatch");
|
||||
}
|
||||
}
|
||||
IssuerChainPath issuancePath = getIssuerChainPath(record.issuanceChainPathId())
|
||||
.orElseThrow(() -> new IllegalStateException("CA issuance chain path is missing"));
|
||||
if (!record.caId().equals(issuancePath.authorityId())
|
||||
|| !record.currentIssuanceIssuerId().equals(issuancePath.issuerId())) {
|
||||
throw new IllegalStateException("CA issuance selection mismatch");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private IssuerGeneration validateIssuerGeneration(IssuerGeneration generation) {
|
||||
getCredential(generation.credentialId())
|
||||
.orElseThrow(() -> new IllegalStateException("Issuer credential reference is missing"));
|
||||
return generation;
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||
private IssuerChainPath validateIssuerChainPath(IssuerChainPath path) {
|
||||
IssuerGeneration generation = getIssuerGeneration(path.issuerId())
|
||||
.orElseThrow(() -> new IllegalStateException("Issuer generation is missing"));
|
||||
if (!path.authorityId().equals(generation.authorityId())
|
||||
|| !path.orderedCredentialIds().get(0).equals(generation.credentialId())) {
|
||||
throw new IllegalStateException("Issuer chain path generation mismatch");
|
||||
}
|
||||
List<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
|
||||
public void putRequest(final ParsedCertificationRequest request) {
|
||||
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) {
|
||||
try {
|
||||
FsOperations.ensureDir(target.getParent());
|
||||
|
||||
@@ -64,6 +64,9 @@ import zeroecho.pki.api.audit.Principal;
|
||||
import zeroecho.pki.api.ca.CaKind;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.CaState;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.ca.IssuerGenerationState;
|
||||
import zeroecho.pki.api.credential.CaProfileBinding;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.credential.CredentialProfileBinding;
|
||||
@@ -112,7 +115,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
|
||||
final class FsCodec {
|
||||
|
||||
/* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024;
|
||||
/* package */ static final int CURRENT_CODEC_VERSION = 3;
|
||||
/* package */ static final int CURRENT_CODEC_VERSION = 4;
|
||||
|
||||
private static final int CODEC_MAGIC = 0x5A454346;
|
||||
private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES;
|
||||
@@ -127,6 +130,8 @@ final class FsCodec {
|
||||
private static final int DURABLE_CONTENT_VERSION = 1;
|
||||
private static final int TOP_PROFILE_VERSION = 11;
|
||||
private static final int TOP_ACTIVE_PROFILE_REF = 12;
|
||||
private static final int TOP_ISSUER_GENERATION = 13;
|
||||
private static final int TOP_ISSUER_CHAIN_PATH = 14;
|
||||
|
||||
private static final int TYPE_STRING = 1;
|
||||
private static final int TYPE_BOOLEAN = 2;
|
||||
@@ -162,6 +167,7 @@ final class FsCodec {
|
||||
private static final int TYPE_PROFILE_REF = 72;
|
||||
private static final int TYPE_PROFILE_BINDING = 73;
|
||||
private static final int TYPE_DURABLE_CONTENT = 74;
|
||||
private static final int TYPE_ISSUER_GENERATION_STATE_ENUM = 75;
|
||||
|
||||
private static final int ATTRIBUTE_STRING = 1;
|
||||
private static final int ATTRIBUTE_BOOLEAN = 2;
|
||||
@@ -218,6 +224,19 @@ final class FsCodec {
|
||||
case 4 -> CaState.DISABLED;
|
||||
default -> throw unknownEnum("CaState", code);
|
||||
});
|
||||
private static final ValueSchema<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,
|
||||
value -> switch (value) {
|
||||
case ISSUED -> 1;
|
||||
@@ -307,8 +326,11 @@ final class FsCodec {
|
||||
(writer, value) -> writer.writeValue(STRING, value.value()),
|
||||
reader -> new SubjectRef(reader.readValue(STRING)));
|
||||
private static final ValueSchema<IssuerRef> ISSUER_REF = valueSchema(TYPE_ISSUER_REF,
|
||||
(writer, value) -> writer.writeValue(PKI_ID, value.caId()),
|
||||
reader -> new IssuerRef(reader.readValue(PKI_ID)));
|
||||
(writer, value) -> {
|
||||
writer.writeValue(PKI_ID, value.caId());
|
||||
writer.writeValue(PKI_ID, value.issuerId());
|
||||
writer.writeValue(PKI_ID, value.chainPathId());
|
||||
}, reader -> new IssuerRef(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID)));
|
||||
private static final ValueSchema<FormatId> FORMAT_ID = valueSchema(TYPE_FORMAT_ID,
|
||||
(writer, value) -> writer.writeValue(STRING, value.value()),
|
||||
reader -> new FormatId(reader.readValue(STRING)));
|
||||
@@ -377,13 +399,19 @@ final class FsCodec {
|
||||
"PROFILE_VERSION", valueSchema(109, FsCodec::writeProfileVersion, FsCodec::readProfileVersion));
|
||||
/* package */ static final Schema<CertificateProfileRef> ACTIVE_PROFILE_REF = topLevel(TOP_ACTIVE_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),
|
||||
Map.entry(TOP_CREDENTIAL, CREDENTIAL), Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST),
|
||||
Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT),
|
||||
Map.entry(TOP_POLICY_TRACE, POLICY_TRACE),
|
||||
Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD),
|
||||
Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF));
|
||||
Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF),
|
||||
Map.entry(TOP_ISSUER_GENERATION, ISSUER_GENERATION),
|
||||
Map.entry(TOP_ISSUER_CHAIN_PATH, ISSUER_CHAIN_PATH));
|
||||
|
||||
private FsCodec() {
|
||||
// utility
|
||||
@@ -616,12 +644,44 @@ final class FsCodec {
|
||||
writer.writeValue(CA_STATE, value.state());
|
||||
writer.writeValue(KEY_REF, value.issuerKeyRef());
|
||||
writer.writeValue(SUBJECT_REF, value.subjectRef());
|
||||
writer.writeValue(PKI_IDS, value.credentialIds());
|
||||
writer.writeValue(PKI_IDS, value.issuerIds());
|
||||
writer.writeValue(PKI_ID, value.currentIssuanceIssuerId());
|
||||
writer.writeValue(PKI_ID, value.issuanceChainPathId());
|
||||
}
|
||||
|
||||
private static CaRecord readCaRecord(Reader reader) throws IOException {
|
||||
return new CaRecord(reader.readValue(PKI_ID), reader.readValue(CA_KIND), reader.readValue(CA_STATE),
|
||||
reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(PKI_IDS));
|
||||
reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(PKI_IDS),
|
||||
reader.readValue(PKI_ID), reader.readValue(PKI_ID));
|
||||
}
|
||||
|
||||
private static void writeIssuerGeneration(Writer writer, IssuerGeneration value) throws IOException {
|
||||
writer.writeValue(PKI_ID, value.issuerId());
|
||||
writer.writeValue(PKI_ID, value.authorityId());
|
||||
writer.writeValue(PKI_ID, value.credentialId());
|
||||
writer.writeValue(KEY_REF, value.signingKeyRef());
|
||||
writer.writeValue(ISSUER_GENERATION_STATE, value.state());
|
||||
writer.writeValue(STRING, value.profilePolicyCommitment());
|
||||
writer.writeValue(STRING, value.x509BindingCommitment());
|
||||
}
|
||||
|
||||
private static IssuerGeneration readIssuerGeneration(Reader reader) throws IOException {
|
||||
return new IssuerGeneration(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID),
|
||||
reader.readValue(KEY_REF), reader.readValue(ISSUER_GENERATION_STATE), reader.readValue(STRING),
|
||||
reader.readValue(STRING));
|
||||
}
|
||||
|
||||
private static void writeIssuerChainPath(Writer writer, IssuerChainPath value) throws IOException {
|
||||
writer.writeValue(PKI_ID, value.pathId());
|
||||
writer.writeValue(PKI_ID, value.authorityId());
|
||||
writer.writeValue(PKI_ID, value.issuerId());
|
||||
writer.writeValue(PKI_IDS, value.orderedCredentialIds());
|
||||
writer.writeValue(STRING, value.pathCommitment());
|
||||
}
|
||||
|
||||
private static IssuerChainPath readIssuerChainPath(Reader reader) throws IOException {
|
||||
return new IssuerChainPath(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID),
|
||||
reader.readValue(PKI_IDS), reader.readValue(STRING));
|
||||
}
|
||||
|
||||
private static void writeParsedRequest(Writer writer, ParsedCertificationRequest value) throws IOException {
|
||||
|
||||
@@ -125,6 +125,16 @@ final class FsPaths {
|
||||
return caDir(caId).resolve(HISTORY_DIR);
|
||||
}
|
||||
|
||||
/* default */ Path issuerGenerationPath(final PkiId issuerId) {
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
return root.resolve("issuer-generations").resolve(BY_ID).resolve(FsUtil.safeId(issuerId) + BINARY_EXTENSION);
|
||||
}
|
||||
|
||||
/* default */ Path issuerChainPath(final PkiId pathId) {
|
||||
Objects.requireNonNull(pathId, "pathId");
|
||||
return root.resolve("issuer-chain-paths").resolve(BY_ID).resolve(FsUtil.safeId(pathId) + BINARY_EXTENSION);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Profiles (immutable versions plus one active pointer)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -60,6 +60,8 @@ import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.publication.PublicationCursor;
|
||||
@@ -219,12 +221,18 @@ final class FsSnapshotExporter {
|
||||
private SnapshotAuthority plan(Instant at) throws IOException {
|
||||
CredentialInventory inventory = inventoryCredentials();
|
||||
List<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();
|
||||
Set<String> remintedContentIds = new HashSet<>(inventory.contentIds());
|
||||
for (StatusObject status : statuses) {
|
||||
remintedContentIds.add(status.content().contentId());
|
||||
}
|
||||
return new SnapshotAuthority(cas, inventory.credentials(), statuses, remintedContentIds);
|
||||
return new SnapshotAuthority(cas, generations, paths, inventory.credentials(), statuses,
|
||||
remintedContentIds);
|
||||
}
|
||||
|
||||
private CredentialInventory inventoryCredentials() throws IOException {
|
||||
@@ -277,7 +285,7 @@ final class FsSnapshotExporter {
|
||||
List<CaRecord> selected = new ArrayList<>();
|
||||
for (Path record : selectedRecords) {
|
||||
CaRecord ca = loadCa(record);
|
||||
if (ca != null && credentials.keySet().containsAll(ca.credentialIds())) {
|
||||
if (ca != null && validCa(ca, credentials)) {
|
||||
selected.add(ca);
|
||||
} else if (ca != null) {
|
||||
rejectCa();
|
||||
@@ -286,6 +294,24 @@ final class FsSnapshotExporter {
|
||||
return List.copyOf(selected);
|
||||
}
|
||||
|
||||
private boolean validCa(CaRecord ca, Map<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 {
|
||||
try {
|
||||
return FsCodec.decode(FsCodec.CA_RECORD, FsOperations.readAll(record), source.stagedContent());
|
||||
@@ -448,8 +474,19 @@ final class FsSnapshotExporter {
|
||||
transferred.add(entry.getKey());
|
||||
}
|
||||
}
|
||||
for (IssuerGeneration generation : authority.generations()) {
|
||||
if (transferred.contains(generation.credentialId())) {
|
||||
target.putIssuerGeneration(generation);
|
||||
}
|
||||
}
|
||||
for (IssuerChainPath path : authority.paths()) {
|
||||
if (transferred.containsAll(path.orderedCredentialIds())) {
|
||||
target.putIssuerChainPath(path);
|
||||
}
|
||||
}
|
||||
for (CaRecord ca : authority.cas()) {
|
||||
if (transferred.containsAll(ca.credentialIds())) {
|
||||
if (ca.issuerIds().stream().allMatch(id -> target.getIssuerGeneration(id).isPresent())
|
||||
&& target.getIssuerChainPath(ca.issuanceChainPathId()).isPresent()) {
|
||||
persistCa(target, ca);
|
||||
}
|
||||
}
|
||||
@@ -789,10 +826,13 @@ final class FsSnapshotExporter {
|
||||
}
|
||||
}
|
||||
|
||||
private record SnapshotAuthority(List<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) {
|
||||
private SnapshotAuthority {
|
||||
cas = List.copyOf(cas);
|
||||
generations = List.copyOf(generations);
|
||||
paths = List.copyOf(paths);
|
||||
credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials));
|
||||
statuses = List.copyOf(statuses);
|
||||
remintedContentIds = Set.copyOf(remintedContentIds);
|
||||
|
||||
@@ -39,6 +39,8 @@ import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.orch.WorkflowStateRecord;
|
||||
import zeroecho.pki.api.policy.PolicyTrace;
|
||||
@@ -105,8 +107,8 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
|
||||
* <p>
|
||||
* Implementations must store CA records atomically. Replacing an existing
|
||||
* record should be either fully visible or not visible at all. Every
|
||||
* identifier in {@link CaRecord#credentialIds()} must resolve through
|
||||
* {@link #getCredential(PkiId)} before the CA record is published.
|
||||
* identifier in {@link CaRecord#issuerIds()} must resolve through
|
||||
* {@link #getIssuerGeneration(PkiId)} before the CA record is published.
|
||||
* </p>
|
||||
*
|
||||
* @param record CA record (never {@code null})
|
||||
@@ -139,6 +141,33 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -48,25 +48,25 @@ import zeroecho.pki.api.SubjectRef;
|
||||
final class CaRecordTest {
|
||||
|
||||
@Test
|
||||
void credentialIdentifiersAreOrderedImmutableAndUnique() {
|
||||
System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique");
|
||||
PkiId first = new PkiId("credential-first");
|
||||
PkiId second = new PkiId("credential-second");
|
||||
void issuerIdentifiersAreOrderedImmutableAndUnique() {
|
||||
System.out.println("issuerIdentifiersAreOrderedImmutableAndUnique");
|
||||
PkiId first = new PkiId("issuer-first");
|
||||
PkiId second = new PkiId("issuer-second");
|
||||
List<PkiId> source = new ArrayList<>(List.of(first, second));
|
||||
|
||||
CaRecord record = record(source);
|
||||
source.clear();
|
||||
|
||||
assertEquals(List.of(first, second), record.credentialIds());
|
||||
assertEquals(List.of(first, second), record.issuerIds());
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> record.credentialIds().add(new PkiId("credential-third")));
|
||||
() -> record.issuerIds().add(new PkiId("issuer-third")));
|
||||
assertThrows(IllegalArgumentException.class, () -> record(List.of(first, first)));
|
||||
assertThrows(IllegalArgumentException.class, () -> record(java.util.Arrays.asList(first, null)));
|
||||
System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique...ok");
|
||||
System.out.println("issuerIdentifiersAreOrderedImmutableAndUnique...ok");
|
||||
}
|
||||
|
||||
private static CaRecord record(List<PkiId> credentialIds) {
|
||||
private static CaRecord record(List<PkiId> issuerIds) {
|
||||
return new CaRecord(new PkiId("ca-test"), CaKind.ROOT, CaState.ACTIVE, new KeyRef("key-test"),
|
||||
new SubjectRef("CN=Test"), credentialIds);
|
||||
new SubjectRef("CN=Test"), issuerIds, issuerIds.getFirst(), new PkiId("path-test"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ import zeroecho.pki.api.SubjectRef;
|
||||
import zeroecho.pki.api.Validity;
|
||||
import zeroecho.pki.api.ca.CaCreateCommand;
|
||||
import zeroecho.pki.api.ca.CaImportCommand;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
|
||||
import zeroecho.pki.api.ca.IntermediateCreateCommand;
|
||||
@@ -140,7 +142,7 @@ final class CaProfileIssuanceEnforcementTest {
|
||||
assertEquals(holder.getSubject().toString(), intermediate.subjectRef().value());
|
||||
assertEquals(intermediate.subjectRef(), credential.subjectRef());
|
||||
assertEquals(intermediate.subjectRef(), additional.subjectRef());
|
||||
assertEquals(2, intermediate.credentialIds().size());
|
||||
assertEquals(2, intermediate.issuerIds().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +227,7 @@ final class CaProfileIssuanceEnforcementTest {
|
||||
.caCredential(runtime.caService().getCa(rootId), 0)
|
||||
.profileBinding();
|
||||
assertEquals(1, issuerBinding.reference().profileVersion());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateId).credentialIds().size());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateId).issuerIds().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,8 +438,18 @@ final class CaProfileIssuanceEnforcementTest {
|
||||
original.serialOrUniqueId(), original.publicKeyId(), new CaProfileBinding(wrongFormat),
|
||||
original.status(), original.content(), original.attributes());
|
||||
runtime.store().putCredential(mutated);
|
||||
IssuerGeneration originalGeneration = runtime.store()
|
||||
.getIssuerGeneration(root.currentIssuanceIssuerId()).orElseThrow();
|
||||
IssuerGeneration generation = new IssuerGeneration(
|
||||
IssuerGeneration.idFor(root.caId(), mutated.credentialId()), root.caId(),
|
||||
mutated.credentialId(), originalGeneration.signingKeyRef(), originalGeneration.state(),
|
||||
originalGeneration.profilePolicyCommitment(), originalGeneration.x509BindingCommitment());
|
||||
runtime.store().putIssuerGeneration(generation);
|
||||
IssuerChainPath path = IssuerChainPath.create(root.caId(), generation.issuerId(),
|
||||
List.of(mutated.credentialId()));
|
||||
runtime.store().putIssuerChainPath(path);
|
||||
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
|
||||
root.subjectRef(), List.of(mutated.credentialId())));
|
||||
root.subjectRef(), List.of(generation.issuerId()), generation.issuerId(), path.pathId()));
|
||||
|
||||
int signCount = runtime.submittedSignCount();
|
||||
assertThrows(PkiException.class,
|
||||
@@ -521,7 +533,7 @@ final class CaProfileIssuanceEnforcementTest {
|
||||
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, rootId,
|
||||
"intermediate-ca", Optional.empty(), new SimpleAttributeSet())));
|
||||
assertEquals(signCount, runtime.submittedSignCount());
|
||||
assertEquals(1, runtime.caService().getCa(rootId).credentialIds().size());
|
||||
assertEquals(1, runtime.caService().getCa(rootId).issuerIds().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +560,7 @@ final class CaProfileIssuanceEnforcementTest {
|
||||
runtime.framework().formatId(), rootId, intermediateId, "intermediate-ca",
|
||||
Optional.of(invalid), new SimpleAttributeSet())));
|
||||
assertEquals(signCount, runtime.submittedSignCount());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateId).credentialIds().size());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateId).issuerIds().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,7 +682,7 @@ final class CaProfileIssuanceEnforcementTest {
|
||||
|
||||
private static Credential onlyCredential(PkiTestRuntime runtime, PkiId caId) {
|
||||
CaRecord ca = runtime.caService().getCa(caId);
|
||||
assertEquals(1, ca.credentialIds().size());
|
||||
assertEquals(1, ca.issuerIds().size());
|
||||
return runtime.caCredential(ca, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,16 +123,11 @@ public final class PkiCoreE2eTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyIssuerPathSkipsEarlierUnusableCredentialAndSelectsLaterUsable(@TempDir Path tempDir) throws Exception {
|
||||
void explicitIssuerSelectionNeverFallsBackByCollectionOrder(@TempDir Path tempDir) throws Exception {
|
||||
KeyPair rootKey = genRsa();
|
||||
KeyPair intermediateKey = genRsa();
|
||||
KeyPair nextIntermediateKey = genRsa();
|
||||
KeyPair leafKey = genRsa();
|
||||
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:matrix-root");
|
||||
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-intermediate");
|
||||
KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-next");
|
||||
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey,
|
||||
nextIntermediateKeyRef, nextIntermediateKey);
|
||||
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey);
|
||||
|
||||
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
|
||||
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
|
||||
@@ -141,8 +136,7 @@ public final class PkiCoreE2eTest {
|
||||
Credential unusable = copyWithId(usable, new PkiId("credential:matrix-unusable"));
|
||||
runtime.store().putCredential(unusable);
|
||||
CaRecord root = runtime.caService().getCa(rootCaId);
|
||||
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
|
||||
root.subjectRef(), List.of(unusable.credentialId(), usable.credentialId())));
|
||||
runtime.selectIssuerCredential(root, unusable, true);
|
||||
|
||||
List<PkiId> resolved = new ArrayList<>();
|
||||
EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> {
|
||||
@@ -152,37 +146,64 @@ public final class PkiCoreE2eTest {
|
||||
}, false);
|
||||
CountingIssuerBackend backend = new CountingIssuerBackend(runtime.issuerBackend());
|
||||
IssuanceService issuance = runtime.issuanceService(backend, resolver);
|
||||
CaService caService = runtime.caService(backend, resolver);
|
||||
StatusObjectService statusService = runtime.statusObjectService(resolver);
|
||||
ParsedCertificationRequest leafRequest = runtime.certificationRequestService()
|
||||
.parse(new CertificationRequest(runtime.framework().formatId(),
|
||||
new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Matrix Leaf").getEncoded())));
|
||||
|
||||
assertThrows(PkiException.class, () -> issuance.issueEndEntity(
|
||||
new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty())));
|
||||
assertEquals(List.of(unusable.credentialId()), List.copyOf(resolved));
|
||||
resolved.clear();
|
||||
|
||||
runtime.caService().selectIssuancePath(rootCaId, root.currentIssuanceIssuerId(),
|
||||
root.issuanceChainPathId(), "restore explicit test selection");
|
||||
issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()));
|
||||
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
|
||||
resolved.clear();
|
||||
|
||||
PkiId intermediateCaId = caService.createIntermediate(new IntermediateCreateCommand(
|
||||
runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Matrix Intermediate"),
|
||||
"intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes()));
|
||||
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
|
||||
resolved.clear();
|
||||
|
||||
caService.issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(),
|
||||
rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), emptyAttributes()));
|
||||
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
|
||||
resolved.clear();
|
||||
|
||||
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
|
||||
root.subjectRef(), List.of(usable.credentialId(), unusable.credentialId())));
|
||||
statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
|
||||
runtime.framework().formatId(), emptyAttributes()));
|
||||
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
|
||||
assertEquals(List.of(usable.credentialId()), List.copyOf(resolved));
|
||||
assertEquals(1, backend.endEntityCalls.get());
|
||||
assertEquals(2, backend.intermediateCalls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossSignedIssuerPathsRemainExplicitAndDoNotChangeIssuanceSelection(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
System.out.println("crossSignedIssuerPathsRemainExplicitAndDoNotChangeIssuanceSelection");
|
||||
KeyPair rootKey = genRsa();
|
||||
KeyPair intermediateKey = genRsa();
|
||||
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:cross-root");
|
||||
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:cross-intermediate");
|
||||
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"),
|
||||
Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) {
|
||||
PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
|
||||
new SubjectRef("CN=Cross Root"), "root-ca", Optional.of(rootKeyRef), emptyAttributes()));
|
||||
PkiId intermediateId = runtime.caService().createIntermediate(new IntermediateCreateCommand(
|
||||
runtime.framework().formatId(), rootId, new SubjectRef("CN=Cross Intermediate"),
|
||||
"intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes()));
|
||||
CaRecord originalRoot = runtime.caService().getCa(rootId);
|
||||
CaRecord intermediate = runtime.caService().getCa(intermediateId);
|
||||
PkiId originalIssuancePath = intermediate.issuanceChainPathId();
|
||||
|
||||
Credential rootCredential = runtime.caCredential(originalRoot, 0);
|
||||
Credential alternateRootCredential = copyWithId(rootCredential,
|
||||
new PkiId("credential:cross-root-alternate"));
|
||||
runtime.store().putCredential(alternateRootCredential);
|
||||
CaRecord rotatedRoot = runtime.selectIssuerCredential(originalRoot, alternateRootCredential, true);
|
||||
PkiId alternateRootPath = rotatedRoot.issuanceChainPathId();
|
||||
|
||||
zeroecho.pki.api.ca.IssuerChainPath alternate = runtime.caService().registerIssuerChainPath(
|
||||
intermediateId, intermediate.currentIssuanceIssuerId(), alternateRootPath);
|
||||
assertEquals(2, runtime.caService().listIssuerChainPaths(
|
||||
intermediate.currentIssuanceIssuerId()).size());
|
||||
assertFalse(originalIssuancePath.equals(alternate.pathId()));
|
||||
assertEquals(originalIssuancePath,
|
||||
runtime.caService().getCa(intermediateId).issuanceChainPathId());
|
||||
assertEquals(alternateRootCredential.credentialId(),
|
||||
alternate.orderedCredentialIds().getLast());
|
||||
System.out.println("...issuerId=" + intermediate.currentIssuanceIssuerId().value()
|
||||
+ " paths=2 selected=" + originalIssuancePath.value());
|
||||
}
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permanentlyRevokedIssuerIsRejectedByEveryReachableTrustPath(@TempDir Path tempDir) throws Exception {
|
||||
KeyPair rootKey = genRsa();
|
||||
@@ -202,7 +223,8 @@ public final class PkiCoreE2eTest {
|
||||
.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
|
||||
new SubjectRef("CN=H6 Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef),
|
||||
emptyAttributes()));
|
||||
PkiId rootCredentialId = runtime.caService().getCa(rootCaId).credentialIds().get(0);
|
||||
PkiId rootCredentialId = runtime.store().getIssuerGeneration(
|
||||
runtime.caService().getCa(rootCaId).currentIssuanceIssuerId()).orElseThrow().credentialId();
|
||||
runtime.revocationService().revokePermanently(new RevocationCommand.RevokePermanently(rootCredentialId,
|
||||
RevocationReason.KEY_COMPROMISE, emptyAttributes()));
|
||||
int submissionsBeforeRejections = runtime.submittedSignCount();
|
||||
@@ -231,7 +253,7 @@ public final class PkiCoreE2eTest {
|
||||
|
||||
assertEquals(submissionsBeforeRejections, runtime.submittedSignCount());
|
||||
assertTrue(runtime.store().getCredential(rootCredentialId).isPresent());
|
||||
assertTrue(runtime.caService().getCa(intermediateCaId).credentialIds().size() == 1);
|
||||
assertTrue(runtime.caService().getCa(intermediateCaId).issuerIds().size() == 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +302,10 @@ public final class PkiCoreE2eTest {
|
||||
.issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty()));
|
||||
|
||||
assertNotNull(bundle);
|
||||
assertEquals(1, bundle.supportingObjects().size());
|
||||
CredentialBundle rebuilt = issSvc.buildBundle(new BundleCommand(
|
||||
bundle.credential().credentialId(), Optional.empty(), Optional.empty()));
|
||||
assertEquals(bundle.supportingObjects(), rebuilt.supportingObjects());
|
||||
System.out.println("...issuedCredentialId=" + bundle.credential().credentialId().value());
|
||||
|
||||
X509CertificateHolder eeCert = new X509CertificateHolder(runtime.credentialBytes(bundle.credential()));
|
||||
@@ -333,7 +359,7 @@ public final class PkiCoreE2eTest {
|
||||
int signCount = runtime.submittedSignCount();
|
||||
int caCount = runtime.store().listCas().size();
|
||||
int statusCount = runtime.store().listStatusObjects(rootCaId).size();
|
||||
int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).credentialIds().size();
|
||||
int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).issuerIds().size();
|
||||
|
||||
assertThrows(PkiException.class, () -> issuance
|
||||
.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty())));
|
||||
@@ -356,7 +382,7 @@ public final class PkiCoreE2eTest {
|
||||
assertEquals(caCount, runtime.store().listCas().size());
|
||||
assertEquals(statusCount, runtime.store().listStatusObjects(rootCaId).size());
|
||||
assertEquals(intermediateCredentialCount,
|
||||
runtime.caService().getCa(intermediateCaId).credentialIds().size());
|
||||
runtime.caService().getCa(intermediateCaId).issuerIds().size());
|
||||
assertTrue(runtime.store().getCredential(rootCredential.credentialId()).isPresent());
|
||||
assertFalse(runtime.auditSink().snapshot().toString().contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL"));
|
||||
}
|
||||
|
||||
@@ -538,7 +538,7 @@ final class PkiProofGateE2eTest {
|
||||
runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca",
|
||||
Optional.empty(), new SimpleAttributeSet())));
|
||||
assertEquals(7, runtime.submittedSignCount());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size());
|
||||
assertTrue(runtime.store().listWorkflowStates().isEmpty());
|
||||
}
|
||||
|
||||
@@ -796,8 +796,7 @@ final class PkiProofGateE2eTest {
|
||||
original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(),
|
||||
original.profileBinding(), CredentialStatus.REVOKED, original.content(), original.attributes());
|
||||
runtime.store().putCredential(revoked);
|
||||
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
|
||||
root.subjectRef(), List.of(revoked.credentialId())));
|
||||
runtime.selectIssuerCredential(root, revoked, true);
|
||||
int before = runtime.submittedSignCount();
|
||||
assertThrows(PkiException.class, () -> runtime.issuanceService()
|
||||
.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty())));
|
||||
@@ -810,14 +809,13 @@ final class PkiProofGateE2eTest {
|
||||
original.serialOrUniqueId(), original.publicKeyId(), original.profileBinding(),
|
||||
CredentialStatus.ISSUED, original.content(), original.attributes());
|
||||
runtime.store().putCredential(expired);
|
||||
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
|
||||
root.subjectRef(), List.of(expired.credentialId())));
|
||||
runtime.selectIssuerCredential(root, expired, true);
|
||||
assertThrows(PkiException.class, () -> runtime.issuanceService()
|
||||
.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty())));
|
||||
assertEquals(before, runtime.submittedSignCount());
|
||||
|
||||
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
|
||||
root.subjectRef(), List.of(original.credentialId())));
|
||||
runtime.caService().selectIssuancePath(root.caId(), root.currentIssuanceIssuerId(),
|
||||
root.issuanceChainPathId(), "restore explicit test selection");
|
||||
ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet());
|
||||
AttributeSet hostileAttributes = new AttributeSet() {
|
||||
@Override
|
||||
@@ -917,7 +915,7 @@ final class PkiProofGateE2eTest {
|
||||
Optional.empty(), new SimpleAttributeSet())),
|
||||
mutation.name());
|
||||
assertTrue(rejected.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH"), mutation.name());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), mutation.name());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size(), mutation.name());
|
||||
if (produced.get() != null) {
|
||||
assertTrue(runtime.store().getCredential(produced.get().credentialId()).isEmpty(), mutation.name());
|
||||
}
|
||||
@@ -942,7 +940,7 @@ final class PkiProofGateE2eTest {
|
||||
() -> wrongSubjectService.issueIntermediateCertificate(
|
||||
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId,
|
||||
"intermediate-ca", Optional.empty(), new SimpleAttributeSet())));
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size());
|
||||
|
||||
CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() {
|
||||
@Override
|
||||
@@ -967,7 +965,7 @@ final class PkiProofGateE2eTest {
|
||||
() -> invalidSignatureService.issueIntermediateCertificate(
|
||||
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId,
|
||||
"intermediate-ca", Optional.empty(), new SimpleAttributeSet())));
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size());
|
||||
|
||||
for (IntermediateExtensionVariant variant : IntermediateExtensionVariant.values()) {
|
||||
CaService maliciousExtensionService = runtime
|
||||
@@ -977,7 +975,7 @@ final class PkiProofGateE2eTest {
|
||||
runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca",
|
||||
Optional.empty(), new SimpleAttributeSet())),
|
||||
variant.name());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), variant.name());
|
||||
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size(), variant.name());
|
||||
}
|
||||
|
||||
AtomicReference<Credential> rawCredential = new AtomicReference<>();
|
||||
|
||||
@@ -51,6 +51,9 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
@@ -68,6 +71,10 @@ import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
@@ -90,6 +97,9 @@ import zeroecho.pki.api.audit.Principal;
|
||||
import zeroecho.pki.api.ca.CaKind;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.CaState;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.ca.IssuerGenerationState;
|
||||
import zeroecho.pki.api.credential.CaProfileBinding;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.credential.CredentialStatus;
|
||||
@@ -557,7 +567,7 @@ public final class FilesystemPkiStoreTest {
|
||||
store.putCa(ca1);
|
||||
|
||||
CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(),
|
||||
ca1.credentialIds());
|
||||
ca1.issuerIds(), ca1.currentIssuanceIssuerId(), ca1.issuanceChainPathId());
|
||||
store.putCa(ca2);
|
||||
|
||||
Optional<CaRecord> loaded = store.getCa(ca1.caId());
|
||||
@@ -571,28 +581,42 @@ public final class FilesystemPkiStoreTest {
|
||||
System.out.println("caHistoryCreatesCurrentAndHistory...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding() throws Exception {
|
||||
System.out.println("caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding");
|
||||
Path root = tmp.resolve("store-ca-keyset-page");
|
||||
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
|
||||
CaRecord first = TestObjects.minimalCaRecord(store, "ca:z", CaState.ACTIVE);
|
||||
CaRecord second = TestObjects.minimalCaRecord(store, "ca:zz", CaState.ACTIVE);
|
||||
store.putCa(first);
|
||||
store.putCa(second);
|
||||
assertEquals(List.of(first.caId()), store.listCasPage(Optional.empty(), 1).stream()
|
||||
.map(CaRecord::caId).toList());
|
||||
assertEquals(List.of(second.caId()), store.listCasPage(Optional.of(first.caId()), 1).stream()
|
||||
.map(CaRecord::caId).toList());
|
||||
System.out.println("...after=" + first.caId() + " next=" + second.caId());
|
||||
}
|
||||
System.out.println("caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void caReferencesRequireValidStandaloneCredentialsAndPreserveOrder() throws Exception {
|
||||
System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder");
|
||||
Path root = tmp.resolve("store-ca-authority");
|
||||
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
|
||||
Credential first = TestObjects.minimalCredential(store, "SERIAL-FIRST", "profile-ca");
|
||||
Credential second = TestObjects.minimalCredential(store, "SERIAL-SECOND", "profile-ca");
|
||||
store.putCredential(first);
|
||||
store.putCredential(second);
|
||||
CaRecord ordered = new CaRecord(new PkiId("ca-ordered"), CaKind.ROOT, CaState.ACTIVE,
|
||||
new KeyRef("key-ordered"), new SubjectRef("CN=Ordered"),
|
||||
List.of(second.credentialId(), first.credentialId()));
|
||||
CaRecord ordered = TestObjects.minimalCaRecord(store, "ca-ordered", CaState.ACTIVE);
|
||||
store.putCa(ordered);
|
||||
|
||||
assertEquals(List.of(second.credentialId(), first.credentialId()),
|
||||
store.getCa(ordered.caId()).orElseThrow().credentialIds());
|
||||
assertEquals(ordered.issuerIds(), store.getCa(ordered.caId()).orElseThrow().issuerIds());
|
||||
PkiId missingIssuer = new PkiId("issuer-missing");
|
||||
CaRecord missing = new CaRecord(new PkiId("ca-missing"), CaKind.ROOT, CaState.ACTIVE,
|
||||
new KeyRef("key-missing"), new SubjectRef("CN=Missing"),
|
||||
List.of(new PkiId("credential-missing")));
|
||||
List.of(missingIssuer), missingIssuer, new PkiId("path-missing"));
|
||||
assertThrows(IllegalStateException.class, () -> store.putCa(missing));
|
||||
|
||||
Files.write(root.resolve("staged-content").resolve(first.content().contentId() + ".content"),
|
||||
Credential selected = store.getCredential(store.getIssuerGeneration(
|
||||
ordered.currentIssuanceIssuerId()).orElseThrow().credentialId()).orElseThrow();
|
||||
Files.write(root.resolve("staged-content").resolve(selected.content().contentId() + ".content"),
|
||||
new byte[] { 9, 9, 9 });
|
||||
assertThrows(IllegalStateException.class, () -> store.getCa(ordered.caId()));
|
||||
assertThrows(IllegalStateException.class, store::listCas);
|
||||
@@ -613,22 +637,20 @@ public final class FilesystemPkiStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotReconstructsSharedCredentialOnceWithNewTargetReference() throws Exception {
|
||||
System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference");
|
||||
void snapshotReconstructsIssuerGenerationsWithNewTargetReferences() throws Exception {
|
||||
System.out.println("snapshotReconstructsIssuerGenerationsWithNewTargetReferences");
|
||||
Path root = tmp.resolve("store-snapshot-authority");
|
||||
Path snapshot = tmp.resolve("snapshot-authority");
|
||||
FsPkiStoreOptions options = nonStrictSnapshotOptions();
|
||||
PkiId credentialId;
|
||||
String sourceContentId;
|
||||
try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) {
|
||||
Credential shared = TestObjects.minimalCredential(source, "SERIAL-SHARED", "profile-ca");
|
||||
source.putCredential(shared);
|
||||
credentialId = shared.credentialId();
|
||||
sourceContentId = shared.content().contentId();
|
||||
source.putCa(new CaRecord(new PkiId("ca-shared-one"), CaKind.ROOT, CaState.ACTIVE,
|
||||
new KeyRef("key-shared-one"), new SubjectRef("CN=Shared One"), List.of(credentialId)));
|
||||
source.putCa(new CaRecord(new PkiId("ca-shared-two"), CaKind.ROOT, CaState.ACTIVE,
|
||||
new KeyRef("key-shared-two"), new SubjectRef("CN=Shared Two"), List.of(credentialId)));
|
||||
CaRecord first = TestObjects.minimalCaRecord(source, "ca-shared-one", CaState.ACTIVE);
|
||||
CaRecord second = TestObjects.minimalCaRecord(source, "ca-shared-two", CaState.ACTIVE);
|
||||
source.putCa(first);
|
||||
source.putCa(second);
|
||||
credentialId = source.getIssuerGeneration(first.currentIssuanceIssuerId()).orElseThrow().credentialId();
|
||||
sourceContentId = source.getCredential(credentialId).orElseThrow().content().contentId();
|
||||
source.exportSnapshot(snapshot, Instant.now());
|
||||
}
|
||||
|
||||
@@ -636,16 +658,17 @@ public final class FilesystemPkiStoreTest {
|
||||
assertEquals(2, restored.listCas().size());
|
||||
Credential credential = restored.getCredential(credentialId).orElseThrow();
|
||||
assertFalse(sourceContentId.equals(credential.content().contentId()));
|
||||
assertEquals(List.of(credentialId),
|
||||
restored.getCa(new PkiId("ca-shared-one")).orElseThrow().credentialIds());
|
||||
CaRecord restoredAuthority = restored.getCa(new PkiId("ca-shared-one")).orElseThrow();
|
||||
assertEquals(credentialId, restored.getIssuerGeneration(
|
||||
restoredAuthority.currentIssuanceIssuerId()).orElseThrow().credentialId());
|
||||
}
|
||||
try (java.util.stream.Stream<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 + ".meta")));
|
||||
assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".owners")));
|
||||
System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference...ok");
|
||||
System.out.println("snapshotReconstructsIssuerGenerationsWithNewTargetReferences...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -672,7 +695,8 @@ public final class FilesystemPkiStoreTest {
|
||||
Credential standalone = restored.getCredential(standaloneId).orElseThrow();
|
||||
assertFalse(standaloneSourceContentId.equals(standalone.content().contentId()));
|
||||
CaRecord ca = restored.getCa(caId).orElseThrow();
|
||||
assertTrue(restored.getCredential(ca.credentialIds().get(0)).isPresent());
|
||||
assertTrue(restored.getCredential(restored.getIssuerGeneration(
|
||||
ca.currentIssuanceIssuerId()).orElseThrow().credentialId()).isPresent());
|
||||
}
|
||||
System.out.println("snapshotPreservesStandaloneCredentialWithNewReferenceAndResolvableCa...ok");
|
||||
}
|
||||
@@ -755,8 +779,8 @@ public final class FilesystemPkiStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa() throws Exception {
|
||||
System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa");
|
||||
void snapshotFailsClosedForMissingIssuerAuthorityInEveryMode() throws Exception {
|
||||
System.out.println("snapshotFailsClosedForMissingIssuerAuthorityInEveryMode");
|
||||
Path root = tmp.resolve("store-snapshot-inconsistent");
|
||||
Path strictSnapshot = tmp.resolve("snapshot-inconsistent-strict");
|
||||
Path nonStrictSnapshot = tmp.resolve("snapshot-inconsistent-nonstrict");
|
||||
@@ -766,20 +790,19 @@ public final class FilesystemPkiStoreTest {
|
||||
CaRecord inconsistent = TestObjects.minimalCaRecord(source, "ca-inconsistent", CaState.ACTIVE);
|
||||
source.putCa(valid);
|
||||
source.putCa(inconsistent);
|
||||
Files.delete(new FsPaths(root).credentialPath(inconsistent.credentialIds().get(0)));
|
||||
Files.delete(new FsPaths(root).credentialPath(source.getIssuerGeneration(
|
||||
inconsistent.currentIssuanceIssuerId()).orElseThrow().credentialId()));
|
||||
|
||||
FsPkiStoreOptions strict = strictSnapshotOptions();
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new FsSnapshotExporter(strict).exportSnapshot(source, strictSnapshot, Instant.now()));
|
||||
assertFalse(Files.exists(strictSnapshot));
|
||||
|
||||
source.exportSnapshot(nonStrictSnapshot, Instant.now());
|
||||
assertThrows(IllegalStateException.class, () -> source.exportSnapshot(nonStrictSnapshot, Instant.now()));
|
||||
assertFalse(Files.exists(nonStrictSnapshot));
|
||||
}
|
||||
try (FilesystemPkiStore restored = new FilesystemPkiStore(nonStrictSnapshot, nonStrict)) {
|
||||
assertEquals(List.of("ca-valid"), restored.listCas().stream()
|
||||
.map(ca -> ca.caId().value()).toList());
|
||||
}
|
||||
System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa...ok");
|
||||
System.out.println("...missing-issuer-authority=rejected");
|
||||
System.out.println("snapshotFailsClosedForMissingIssuerAuthorityInEveryMode...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1283,14 +1306,46 @@ public final class FilesystemPkiStoreTest {
|
||||
|
||||
static CaRecord minimalCaRecord(PkiStore store, String caId, CaState state) throws IOException {
|
||||
PkiId id = new PkiId(caId);
|
||||
|
||||
KeyRef issuerKeyRef = new KeyRef("issuer-key-" + caId);
|
||||
SubjectRef subjectRef = new SubjectRef("CN=" + caId);
|
||||
|
||||
Credential cred = minimalCredential(store, "CA-" + caId, "profile-ca");
|
||||
store.putCredential(cred);
|
||||
PkiId credentialId = new PkiId("cred-ca-" + caId);
|
||||
PkiId issuerId = IssuerGeneration.idFor(id, credentialId);
|
||||
IssuerChainPath path = IssuerChainPath.create(id, issuerId, List.of(credentialId));
|
||||
Credential credential = caCredential(store, id, credentialId, issuerId, path.pathId(), subjectRef);
|
||||
store.putCredential(credential);
|
||||
String commitment = "0".repeat(64);
|
||||
store.putIssuerGeneration(new IssuerGeneration(issuerId, id, credentialId, issuerKeyRef,
|
||||
IssuerGenerationState.ACTIVE, commitment, commitment));
|
||||
store.putIssuerChainPath(path);
|
||||
return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef,
|
||||
List.of(cred.credentialId()));
|
||||
List.of(issuerId), issuerId, path.pathId());
|
||||
}
|
||||
|
||||
private static Credential caCredential(PkiStore store, PkiId authorityId, PkiId credentialId,
|
||||
PkiId issuerId, PkiId pathId, SubjectRef subject) throws IOException {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair keyPair = generator.generateKeyPair();
|
||||
X500Name name = new X500Name(subject.value());
|
||||
Instant notBefore = Instant.parse("2020-01-01T00:00:00Z");
|
||||
Instant notAfter = Instant.parse("2030-01-01T00:00:00Z");
|
||||
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name, BigInteger.ONE,
|
||||
java.util.Date.from(notBefore), java.util.Date.from(notAfter), name, keyPair.getPublic());
|
||||
byte[] der = builder.build(new JcaContentSignerBuilder("SHA256withRSA")
|
||||
.build(keyPair.getPrivate())).getEncoded();
|
||||
zeroecho.pki.api.content.DurableContentReference content =
|
||||
zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER, der);
|
||||
return new Credential(credentialId, new FormatId("fmt-x509"),
|
||||
new IssuerRef(authorityId, issuerId, pathId), subject,
|
||||
new Validity(notBefore, notAfter), "CA-" + authorityId.value(),
|
||||
new PkiId("pk-" + authorityId.value()),
|
||||
new CaProfileBinding(new CertificateProfileRef("profile-ca", 1, new byte[32])),
|
||||
CredentialStatus.ISSUED, content, emptyAttributes());
|
||||
} catch (java.security.GeneralSecurityException | org.bouncycastle.operator.OperatorCreationException
|
||||
exception) {
|
||||
throw new IOException("CA fixture construction failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static CertificateProfile minimalProfile(String profileId) {
|
||||
|
||||
@@ -249,15 +249,16 @@ final class FsCodecTest {
|
||||
@Test
|
||||
void caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag() {
|
||||
System.out.println("caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag");
|
||||
PkiId first = new PkiId("credential-first");
|
||||
PkiId second = new PkiId("credential-second");
|
||||
PkiId first = new PkiId("issuer-first");
|
||||
PkiId second = new PkiId("issuer-second");
|
||||
CaRecord original = new CaRecord(new PkiId("ca-codec"), CaKind.ROOT, CaState.ACTIVE,
|
||||
new KeyRef("key-codec"), new SubjectRef("CN=Codec"), List.of(first, second));
|
||||
new KeyRef("key-codec"), new SubjectRef("CN=Codec"), List.of(first, second), first,
|
||||
new PkiId("path-codec"));
|
||||
|
||||
byte[] encoded = FsCodec.encode(FsCodec.CA_RECORD, original);
|
||||
CaRecord decoded = FsCodec.decode(FsCodec.CA_RECORD, encoded);
|
||||
assertEquals(original, decoded);
|
||||
assertEquals(List.of(first, second), decoded.credentialIds());
|
||||
assertEquals(List.of(first, second), decoded.issuerIds());
|
||||
|
||||
byte[] embeddedValueTag = encoded.clone();
|
||||
int firstIdentifier = indexOf(embeddedValueTag, first.value().getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
@@ -209,10 +209,36 @@ public final class PkiTestRuntime implements AutoCloseable {
|
||||
}
|
||||
|
||||
public Credential caCredential(zeroecho.pki.api.ca.CaRecord ca, int index) {
|
||||
PkiId credentialId = ca.credentialIds().get(index);
|
||||
PkiId credentialId = store.getIssuerGeneration(ca.issuerIds().get(index)).orElseThrow().credentialId();
|
||||
return store.getCredential(credentialId).orElseThrow();
|
||||
}
|
||||
|
||||
public zeroecho.pki.api.ca.CaRecord selectIssuerCredential(zeroecho.pki.api.ca.CaRecord authority,
|
||||
Credential credential, boolean retainExisting) {
|
||||
zeroecho.pki.api.ca.IssuerGeneration previous = store
|
||||
.getIssuerGeneration(authority.currentIssuanceIssuerId()).orElseThrow();
|
||||
zeroecho.pki.api.ca.IssuerGeneration generation = new zeroecho.pki.api.ca.IssuerGeneration(
|
||||
zeroecho.pki.api.ca.IssuerGeneration.idFor(authority.caId(), credential.credentialId()),
|
||||
authority.caId(), credential.credentialId(), previous.signingKeyRef(), previous.state(),
|
||||
previous.profilePolicyCommitment(), previous.x509BindingCommitment());
|
||||
store.putIssuerGeneration(generation);
|
||||
zeroecho.pki.api.ca.IssuerChainPath oldPath = store
|
||||
.getIssuerChainPath(authority.issuanceChainPathId()).orElseThrow();
|
||||
java.util.List<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,
|
||||
DurableContentReference.Lifecycle lifecycle) implements DurableContentReference {
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user