feat(pki-server): add OCSP responder and close server release
Add durable multi-authority OCSP responders with strict request parsing, issuer-bound serial lookup, stable revocation views, signed responses, nonce policies and bounded protocol execution. Complete in-process and packaged OCSP validation and close the PKI server after the final architecture, security and release audit.
This commit is contained in:
@@ -8,7 +8,7 @@ An ACME directory is an immutable policy revision bound to one realm, logical au
|
||||
|
||||
Direct deployments use server-authenticated TLS. A client TLS certificate is not ACME account authority; ACME identity is the account key authenticated by JWS. Trusted-reverse-proxy deployments require the established mutually authenticated proxy-to-ZeroEcho TLS hop and a dedicated enabled proxy principal with `FORWARD_AUTHENTICATED_CLIENT_IDENTITY`. Forwarded administrative identity is not used as an ACME account. Source addresses and `Forwarded` or `X-Forwarded-*` headers never authorize ACME.
|
||||
|
||||
The server configuration schema is version 4. ACME is disabled explicitly with:
|
||||
The server configuration schema is version 5. ACME is disabled explicitly with:
|
||||
|
||||
```json
|
||||
"acmeListener": {"enabled": false}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 4,
|
||||
"version": 5,
|
||||
"serverName": "zeroecho-admin",
|
||||
"realm": {
|
||||
"realmId": "production",
|
||||
|
||||
137
docs/pki-server-ocsp.md
Normal file
137
docs/pki-server-ocsp.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# ZeroEcho OCSP responder
|
||||
|
||||
ZeroEcho serves OCSP only on the separately bounded public listener at
|
||||
`/ocsp/{responderAlias}`. Administrative operations remain under `/admin/v1`,
|
||||
the public repository remains under `/public/v1`, and ACME remains on its own
|
||||
listener. All four surfaces share one realm and one long-lived `PkiSession`, but
|
||||
they do not share authorization or admission authority.
|
||||
|
||||
## Responder authority
|
||||
|
||||
An OCSP responder is a durable server-control record. Its alias binds exactly
|
||||
one realm, logical authority, issuer generation, responder certificate,
|
||||
`KeyRef`, explicit chain path, signature algorithm/binding commitment, nonce
|
||||
policy, response-validity policy, accepted CertID hashes, and finite request
|
||||
bounds. Activation revalidates those dependencies and freezes their
|
||||
commitments. There is no first/last issuer selection, filename inference,
|
||||
timestamp inference, runtime path building, or fallback signing algorithm.
|
||||
|
||||
The unified administrative catalog exposes:
|
||||
|
||||
- `ocsp.responder.register`
|
||||
- `ocsp.responder.inspect`
|
||||
- `ocsp.responder.list`
|
||||
- `ocsp.responder.activate`
|
||||
- `ocsp.responder.deactivate`
|
||||
|
||||
Inspection requires `OCSP_RESPONDER_READ`; mutation requires the scoped
|
||||
`OCSP_ADMINISTER` permission. Registration and activation are high-risk
|
||||
operations and use the existing approval policy. Registration input does not
|
||||
accept authoritative creation timestamps, lifecycle state, or record
|
||||
commitments; the server creates those values. Ordinary results omit `KeyRef`,
|
||||
certificate DER, and provider configuration.
|
||||
|
||||
## Signing modes
|
||||
|
||||
`ISSUER_SIGNED` is intended for explicitly approved internal deployments. The
|
||||
configured credential and `KeyRef` must be the exact issuer-generation binding.
|
||||
It should not be used to place an offline root key in an online service.
|
||||
|
||||
`DELEGATED_RESPONDER` is preferred for public online operation. Before
|
||||
activation ZeroEcho verifies the responder certificate, its exact issuer
|
||||
relationship, validity, `id-kp-OCSPSigning` EKU, digital-signature key usage
|
||||
when present, explicit chain path, signing binding, and configured key
|
||||
capability. Private keys remain confined behind `KeyRef` and the existing
|
||||
signing workflow; the HTTP layer never obtains a `PrivateKey`.
|
||||
|
||||
Conceptual registration policies:
|
||||
|
||||
```text
|
||||
issuer-signed internal:
|
||||
signingMode = ISSUER_SIGNED
|
||||
responderCredentialId = exact issuer-generation credential
|
||||
noncePolicy = REQUIRED
|
||||
|
||||
delegated public:
|
||||
signingMode = DELEGATED_RESPONDER
|
||||
responderCredentialId = exact OCSP-signing credential
|
||||
noncePolicy = REJECT
|
||||
|
||||
cacheable public:
|
||||
noncePolicy = REJECT
|
||||
cacheLifetime <= responseValidity
|
||||
|
||||
nonce echo:
|
||||
noncePolicy = OPTIONAL_ECHO
|
||||
nonce-bearing responses use no-store
|
||||
```
|
||||
|
||||
## Requests and responses
|
||||
|
||||
POST uses `Content-Type: application/ocsp-request`. GET uses an unpadded,
|
||||
canonical URL-safe Base64 request path:
|
||||
|
||||
```text
|
||||
/ocsp/{responderAlias}/{base64url-request}
|
||||
```
|
||||
|
||||
Query-string requests and alternate Base64 normalization are rejected. The
|
||||
strict DER parser accepts unsigned requests only, consumes the complete input,
|
||||
rejects duplicate or unknown critical extensions, and enforces configured byte
|
||||
and entry bounds. Signed OCSP requests are rejected in this release; a request
|
||||
signature is never treated as administrative or certificate-owner authority.
|
||||
|
||||
Configured CertID hashing supports SHA-1 and SHA-256. SHA-1 is permitted only
|
||||
for RFC-compatible issuer-name/key hashes. It is never enabled as a certificate
|
||||
or OCSP response signature algorithm. The responder validates hashes against
|
||||
its exact issuer-generation certificate and performs a disk-backed direct
|
||||
issuer-generation-plus-serial lookup; it never searches another authority or
|
||||
scans all credentials per request.
|
||||
|
||||
One response captures one revocation revision and commitment. `good` means the
|
||||
exact credential was issued by that issuer and is not revoked in that stable
|
||||
ZeroEcho view; it is not a general statement of application validity.
|
||||
`revoked` preserves the authoritative revocation time and supported reason.
|
||||
`unknown` means exact issuance could not be established. Corrupt or
|
||||
recovery-required authority returns a transient protocol/transport failure, not
|
||||
`unknown`.
|
||||
|
||||
Successful responses are strict signed DER with
|
||||
`application/ocsp-response`, `nosniff`, `producedAt`, `thisUpdate`, and
|
||||
`nextUpdate`. Nonce-free responses carry a strong ETag and bounded public cache
|
||||
policy. An echoed nonce is returned exactly and makes the response non-cacheable.
|
||||
No request DER, response DER, serial, issuer hash, nonce, certificate identity,
|
||||
key reference, or parser/provider exception is logged or audited.
|
||||
|
||||
## Deployment topology and capacity
|
||||
|
||||
Direct mode permits anonymous OCSP over the public TLS listener. An optional
|
||||
public client certificate does not change certificate status, and forwarded
|
||||
identity headers are rejected. In trusted-reverse-proxy mode the backend proxy
|
||||
hop still requires mTLS and the dedicated forwarding permission; proxy and
|
||||
forwarded end-client identities do not authorize or select OCSP status. Source
|
||||
IP, `Forwarded`, and `X-Forwarded-For` are never responder authority.
|
||||
|
||||
OCSP has a separate bounded runtime lane even though it shares the public
|
||||
transport listener. Parsing, lookup, signing, and response validation are
|
||||
protected by finite request, queue, admission, concurrency, and deadline bounds.
|
||||
OCSP saturation cannot consume administrative, public-stream, or ACME operation
|
||||
permits. Shutdown quiesces admission, cancels remaining operations after the
|
||||
finite grace period, and never retries an uncertain signature automatically.
|
||||
|
||||
Example topologies use the existing strict public-listener configuration:
|
||||
|
||||
```text
|
||||
direct public listener:
|
||||
public TLS identity + DIRECT_MTLS optional-client mapping policy
|
||||
|
||||
trusted proxy public listener:
|
||||
proxy-to-ZeroEcho mTLS + dedicated proxy principal
|
||||
+ FORWARD_AUTHENTICATED_CLIENT_IDENTITY
|
||||
```
|
||||
|
||||
The current release is single-node. It has no distributed responder cache,
|
||||
cluster coordination, or automatic status/checkpoint scheduling. Deployments
|
||||
must route a responder alias to the node owning its realm and provision normal
|
||||
external availability monitoring without treating that monitoring as PKI
|
||||
authority.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 4,
|
||||
"version": 5,
|
||||
"serverName": "zeroecho-admin",
|
||||
"realm": {
|
||||
"realmId": "production",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 4,
|
||||
"version": 5,
|
||||
"serverName": "zeroecho-admin-nginx",
|
||||
"realm": {
|
||||
"realmId": "production",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 4,
|
||||
"version": 5,
|
||||
"serverName": "zeroecho-admin-proxy",
|
||||
"realm": {
|
||||
"realmId": "production",
|
||||
|
||||
@@ -6,12 +6,20 @@ plugins {
|
||||
|
||||
group = 'org.egothor'
|
||||
|
||||
configurations {
|
||||
mockitoAgent
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api project(':pki')
|
||||
implementation project(':lib')
|
||||
implementation platform('tools.jackson:jackson-bom:3.1.5')
|
||||
implementation 'tools.jackson.core:jackson-core'
|
||||
testImplementation 'org.bouncycastle:bcpkix-jdk18on:1.84'
|
||||
testImplementation 'org.mockito:mockito-core:5.23.0'
|
||||
mockitoAgent('org.mockito:mockito-core:5.23.0') {
|
||||
transitive = false
|
||||
}
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -21,6 +29,7 @@ application {
|
||||
|
||||
tasks.named('test') {
|
||||
dependsOn tasks.named('installDist')
|
||||
jvmArgs("-javaagent:${configurations.mockitoAgent.singleFile}")
|
||||
}
|
||||
|
||||
jar {
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
/*******************************************************************************
|
||||
* 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.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
|
||||
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.application.OcspResponseService;
|
||||
import zeroecho.pki.application.PkiRepository;
|
||||
|
||||
/** Durable exact responder-binding authority over the server control store. */
|
||||
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.ControlStatementBraces",
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.LinguisticNaming", "PMD.UseObjectForClearerAPI", "PMD.ExcessiveParameterList",
|
||||
"PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException", "PMD.UseEnumCollections",
|
||||
"PMD.ExceptionAsFlowControl" })
|
||||
public final class OcspResponderService {
|
||||
/** Exact signing authority. */
|
||||
public enum SigningMode {
|
||||
ISSUER_SIGNED(1), DELEGATED_RESPONDER(2);
|
||||
private final int code;
|
||||
SigningMode(int code) { this.code = code; }
|
||||
/** Stable persistence code. */ public int code() { return code; }
|
||||
/** Resolves an exact stable code. */
|
||||
public static SigningMode fromCode(int code) {
|
||||
return switch (code) { case 1 -> ISSUER_SIGNED; case 2 -> DELEGATED_RESPONDER;
|
||||
default -> throw new IllegalArgumentException("Unknown OCSP signing mode code"); };
|
||||
}
|
||||
}
|
||||
/** Closed nonce behavior. */
|
||||
public enum NoncePolicy {
|
||||
REJECT(1), OPTIONAL_ECHO(2), REQUIRED(3);
|
||||
private final int code;
|
||||
NoncePolicy(int code) { this.code = code; }
|
||||
/** Stable persistence code. */ public int code() { return code; }
|
||||
/** Resolves an exact stable code. */
|
||||
public static NoncePolicy fromCode(int code) {
|
||||
return switch (code) { case 1 -> REJECT; case 2 -> OPTIONAL_ECHO; case 3 -> REQUIRED;
|
||||
default -> throw new IllegalArgumentException("Unknown OCSP nonce policy code"); };
|
||||
}
|
||||
}
|
||||
/** Durable activation state. */
|
||||
public enum State {
|
||||
INACTIVE(1), ACTIVE(2);
|
||||
private final int code;
|
||||
State(int code) { this.code = code; }
|
||||
/** Stable persistence code. */ public int code() { return code; }
|
||||
/** Resolves an exact stable code. */
|
||||
public static State fromCode(int code) {
|
||||
return switch (code) { case 1 -> INACTIVE; case 2 -> ACTIVE;
|
||||
default -> throw new IllegalArgumentException("Unknown OCSP responder state code"); };
|
||||
}
|
||||
}
|
||||
|
||||
/** Closed administrator input without server-authoritative timestamps, state, or record commitments. */
|
||||
public record Registration(String responderId, String alias, PkiId authorityId, PkiId issuerId,
|
||||
SigningMode signingMode, PkiId responderCredentialId, KeyRef signingKeyRef, PkiId chainPathId,
|
||||
String signatureAlgorithm, Optional<String> signatureBindingId, String signatureBindingCommitment,
|
||||
OcspResponseService.ResponderId responderIdForm, Duration responseValidity, NoncePolicy noncePolicy,
|
||||
int maximumNonceBytes, Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumRequestBytes,
|
||||
int maximumEntries, Duration cacheLifetime) {
|
||||
/** Snapshots the typed finite registration input. */
|
||||
public Registration {
|
||||
Permission.requireId(responderId, "OCSP responder"); Permission.requireId(alias, "OCSP alias");
|
||||
Objects.requireNonNull(authorityId); Objects.requireNonNull(issuerId); Objects.requireNonNull(signingMode);
|
||||
Objects.requireNonNull(responderCredentialId); Objects.requireNonNull(signingKeyRef);
|
||||
Objects.requireNonNull(chainPathId); Permission.requireBounded(signatureAlgorithm, 128,
|
||||
"OCSP signature algorithm");
|
||||
signatureBindingId = Objects.requireNonNull(signatureBindingId);
|
||||
requireDigest(signatureBindingCommitment); Objects.requireNonNull(responderIdForm);
|
||||
Objects.requireNonNull(responseValidity); Objects.requireNonNull(noncePolicy);
|
||||
acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes));
|
||||
Objects.requireNonNull(cacheLifetime);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strict versioned responder binding. */
|
||||
public record Responder(String responderId, String alias, RealmId realmId, PkiId authorityId,
|
||||
PkiId issuerId, SigningMode signingMode, PkiId responderCredentialId, KeyRef signingKeyRef,
|
||||
PkiId chainPathId, String signatureAlgorithm, Optional<String> signatureBindingId,
|
||||
String signatureBindingCommitment, OcspResponseService.ResponderId responderIdForm,
|
||||
Duration responseValidity, NoncePolicy noncePolicy, int maximumNonceBytes,
|
||||
Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumRequestBytes,
|
||||
int maximumEntries, Duration cacheLifetime, State state, Instant createdAt,
|
||||
String configurationCommitment) {
|
||||
/** Validates all finite immutable dependencies and the record commitment. */
|
||||
public Responder {
|
||||
Permission.requireId(responderId, "OCSP responder"); Permission.requireId(alias, "OCSP alias");
|
||||
Objects.requireNonNull(realmId); Objects.requireNonNull(authorityId); Objects.requireNonNull(issuerId);
|
||||
Objects.requireNonNull(signingMode); Objects.requireNonNull(responderCredentialId);
|
||||
Objects.requireNonNull(signingKeyRef); Objects.requireNonNull(chainPathId);
|
||||
Permission.requireBounded(signatureAlgorithm, 128, "OCSP signature algorithm");
|
||||
signatureBindingId = Objects.requireNonNull(signatureBindingId);
|
||||
requireDigest(signatureBindingCommitment); Objects.requireNonNull(responderIdForm);
|
||||
positive(responseValidity, Duration.ofDays(7), "response validity"); Objects.requireNonNull(noncePolicy);
|
||||
if (maximumNonceBytes < 8 || maximumNonceBytes > 4096 || maximumRequestBytes < 256
|
||||
|| maximumRequestBytes > 1_048_576 || maximumEntries < 1 || maximumEntries > 4096) {
|
||||
throw new IllegalArgumentException("OCSP responder bounds are invalid");
|
||||
}
|
||||
acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes));
|
||||
if (acceptedHashes.isEmpty()) throw new IllegalArgumentException("OCSP CertID hashes are empty");
|
||||
positive(cacheLifetime, responseValidity, "cache lifetime"); Objects.requireNonNull(state);
|
||||
Objects.requireNonNull(createdAt); requireDigest(configurationCommitment);
|
||||
}
|
||||
}
|
||||
|
||||
private final RealmId realmId;
|
||||
private final ServerControlStore control;
|
||||
private final PkiRepository repository;
|
||||
private final X509AlgorithmBindingRegistry bindings;
|
||||
private final Optional<OcspResponseService> signing;
|
||||
private final Clock clock;
|
||||
|
||||
/** Binds responder control to one realm and one authoritative PKI repository. */
|
||||
public OcspResponderService(RealmId realmId, ServerControlStore control, PkiRepository repository,
|
||||
X509AlgorithmBindingRegistry bindings, Optional<OcspResponseService> signing, Clock clock) {
|
||||
this.realmId = Objects.requireNonNull(realmId); this.control = Objects.requireNonNull(control);
|
||||
this.repository = Objects.requireNonNull(repository); this.bindings = Objects.requireNonNull(bindings);
|
||||
this.signing = Objects.requireNonNull(signing);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
/** Creates authoritative metadata and durably registers one inactive responder. */
|
||||
public Responder register(Registration supplied) {
|
||||
Objects.requireNonNull(supplied, "supplied");
|
||||
return register(create(supplied.responderId(), supplied.alias(), supplied.authorityId(), supplied.issuerId(),
|
||||
supplied.signingMode(), supplied.responderCredentialId(), supplied.signingKeyRef(),
|
||||
supplied.chainPathId(), supplied.signatureAlgorithm(), supplied.signatureBindingId(),
|
||||
supplied.signatureBindingCommitment(), supplied.responderIdForm(), supplied.responseValidity(),
|
||||
supplied.noncePolicy(), supplied.maximumNonceBytes(), supplied.acceptedHashes(),
|
||||
supplied.maximumRequestBytes(), supplied.maximumEntries(), supplied.cacheLifetime()));
|
||||
}
|
||||
|
||||
/** Canonically seals an already constructed internal draft. */
|
||||
/* default */ Responder register(Responder supplied) {
|
||||
if (!supplied.realmId().equals(realmId) || supplied.state() != State.INACTIVE) {
|
||||
throw new IllegalArgumentException("OCSP responder realm or initial state is invalid");
|
||||
}
|
||||
if (!supplied.configurationCommitment().equals(commitment(supplied, true))) {
|
||||
throw new IllegalArgumentException("OCSP responder commitment differs");
|
||||
}
|
||||
if (!aliases(supplied.alias()).isEmpty()) {
|
||||
throw new IllegalStateException("OCSP responder alias already exists");
|
||||
}
|
||||
validateDependencies(supplied, true);
|
||||
Responder sealed = seal(supplied, State.INACTIVE);
|
||||
control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(sealed), Optional.empty())));
|
||||
return sealed;
|
||||
}
|
||||
|
||||
/** Returns one exact responder. */
|
||||
public Responder require(String responderId) {
|
||||
return control.protocolRecord(ServerControlStore.OCSP_RESPONDER, responderId)
|
||||
.map(this::decode).orElseThrow(() -> new IllegalArgumentException("OCSP responder is unavailable"));
|
||||
}
|
||||
|
||||
/** Resolves one unique active public alias. */
|
||||
public Responder requireActiveAlias(String alias) {
|
||||
Responder result = requireAlias(alias);
|
||||
if (result.state() != State.ACTIVE) {
|
||||
throw new IllegalStateException("OCSP responder is inactive");
|
||||
}
|
||||
validateDependencies(result, false); return result;
|
||||
}
|
||||
|
||||
/** Resolves one unique durable alias regardless of activation state. */
|
||||
public Responder requireAlias(String alias) {
|
||||
Permission.requireId(alias, "OCSP alias");
|
||||
List<Responder> matches = aliases(alias);
|
||||
if (matches.size() != 1) throw new IllegalArgumentException("OCSP responder alias is unavailable");
|
||||
return matches.getFirst();
|
||||
}
|
||||
|
||||
/** Lists one bounded deterministic responder page. */
|
||||
public ServerControlStore.Page<Responder> list(int offset, int limit) {
|
||||
ServerControlStore.Page<ServerControlStore.ProtocolRecord> page =
|
||||
control.protocolRecords(ServerControlStore.OCSP_RESPONDER, offset, limit);
|
||||
return new ServerControlStore.Page<>(page.values().stream().map(this::decode).toList(),
|
||||
page.nextOffset(), page.hasMore());
|
||||
}
|
||||
|
||||
private List<Responder> aliases(String alias) {
|
||||
List<Responder> matches = new ArrayList<>();
|
||||
int offset = 0;
|
||||
while (true) {
|
||||
ServerControlStore.Page<Responder> page = list(offset, 256);
|
||||
page.values().stream().filter(value -> value.alias().equals(alias)).forEach(matches::add);
|
||||
if (!page.hasMore()) return List.copyOf(matches);
|
||||
offset = page.nextOffset();
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically changes only responder activation after dependency revalidation. */
|
||||
public Responder setActive(String responderId, boolean active) {
|
||||
Responder prior = require(responderId); validateDependencies(prior, true);
|
||||
Responder next = seal(prior, active ? State.ACTIVE : State.INACTIVE);
|
||||
control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(next),
|
||||
Optional.of(record(prior).commitment()))));
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Revalidates every durable binding during realm recovery. */
|
||||
public void validateAll() {
|
||||
int offset = 0;
|
||||
while (true) {
|
||||
ServerControlStore.Page<Responder> page = list(offset, 256);
|
||||
page.values().forEach(value -> validateDependencies(value, true));
|
||||
if (!page.hasMore()) return;
|
||||
offset = page.nextOffset();
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a canonical unsealed record for callers before registration. */
|
||||
public Responder create(String responderId, String alias, PkiId authorityId, PkiId issuerId,
|
||||
SigningMode signingMode, PkiId responderCredentialId, KeyRef signingKeyRef, PkiId chainPathId,
|
||||
String signatureAlgorithm, Optional<String> signatureBindingId, String bindingCommitment,
|
||||
OcspResponseService.ResponderId responderIdForm, Duration validity, NoncePolicy noncePolicy,
|
||||
int maximumNonceBytes, Set<OcspResponseService.CertIdHash> hashes, int maximumRequestBytes,
|
||||
int maximumEntries, Duration cacheLifetime) {
|
||||
Responder draft = new Responder(responderId, alias, realmId, authorityId, issuerId, signingMode,
|
||||
responderCredentialId, signingKeyRef, chainPathId, signatureAlgorithm, signatureBindingId,
|
||||
bindingCommitment, responderIdForm, validity, noncePolicy, maximumNonceBytes, hashes,
|
||||
maximumRequestBytes, maximumEntries, cacheLifetime, State.INACTIVE, clock.instant(),
|
||||
"0".repeat(64));
|
||||
return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(),
|
||||
draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(),
|
||||
draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(),
|
||||
draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(),
|
||||
draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(),
|
||||
draft.state(), draft.createdAt(), commitment(draft, true));
|
||||
}
|
||||
|
||||
private Responder seal(Responder value, State state) {
|
||||
Responder draft = new Responder(value.responderId(), value.alias(), value.realmId(), value.authorityId(),
|
||||
value.issuerId(), value.signingMode(), value.responderCredentialId(), value.signingKeyRef(),
|
||||
value.chainPathId(), value.signatureAlgorithm(), value.signatureBindingId(),
|
||||
value.signatureBindingCommitment(), value.responderIdForm(), value.responseValidity(),
|
||||
value.noncePolicy(), value.maximumNonceBytes(), value.acceptedHashes(), value.maximumRequestBytes(),
|
||||
value.maximumEntries(), value.cacheLifetime(), state, value.createdAt(), value.configurationCommitment());
|
||||
return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(),
|
||||
draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(),
|
||||
draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(),
|
||||
draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(),
|
||||
draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(),
|
||||
draft.state(), draft.createdAt(), commitment(draft, true));
|
||||
}
|
||||
|
||||
private void validateDependencies(Responder value, boolean proveSigning) {
|
||||
value.signatureBindingId().ifPresentOrElse(
|
||||
bindingId -> bindings.require(bindingId, value.signatureBindingCommitment()),
|
||||
() -> {
|
||||
if (!bindings.commitment().equals(value.signatureBindingCommitment())) {
|
||||
throw new IllegalStateException("OCSP algorithm registry commitment differs");
|
||||
}
|
||||
});
|
||||
IssuerGeneration issuer = repository.issuer(value.issuerId()).orElseThrow();
|
||||
IssuerChainPath path = repository.chainPath(value.chainPathId()).orElseThrow();
|
||||
if (!issuer.authorityId().equals(value.authorityId()) || !path.authorityId().equals(value.authorityId())
|
||||
|| !path.issuerId().equals(value.issuerId()) || !path.orderedCredentialIds().getFirst()
|
||||
.equals(issuer.credentialId())) {
|
||||
throw new IllegalStateException("OCSP responder issuer/path binding differs");
|
||||
}
|
||||
try {
|
||||
X509CertificateHolder issuerCertificate = certificate(issuer.credentialId());
|
||||
X509CertificateHolder responderCertificate = certificate(value.responderCredentialId());
|
||||
if (!responderCertificate.isValidOn(Date.from(clock.instant()))) {
|
||||
throw new IllegalStateException("OCSP responder certificate is outside its validity interval");
|
||||
}
|
||||
if (value.signingMode() == SigningMode.ISSUER_SIGNED) {
|
||||
if (!value.responderCredentialId().equals(issuer.credentialId())
|
||||
|| !value.signingKeyRef().equals(issuer.signingKeyRef())) {
|
||||
throw new IllegalStateException("Issuer-signed responder binding differs");
|
||||
}
|
||||
} else {
|
||||
if (!responderCertificate.isSignatureValid(new JcaContentVerifierProviderBuilder()
|
||||
.build(issuerCertificate)) || responderCertificate.getExtension(Extension.extendedKeyUsage) == null
|
||||
|| !org.bouncycastle.asn1.x509.ExtendedKeyUsage.fromExtensions(
|
||||
responderCertificate.getExtensions()).hasKeyPurposeId(
|
||||
org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_OCSPSigning)) {
|
||||
throw new IllegalStateException("Delegated OCSP responder certificate is unauthorized");
|
||||
}
|
||||
Extension usage = responderCertificate.getExtension(Extension.keyUsage);
|
||||
if (usage != null && !KeyUsage.fromExtensions(responderCertificate.getExtensions())
|
||||
.hasUsages(KeyUsage.digitalSignature)) {
|
||||
throw new IllegalStateException("Delegated OCSP responder key usage is invalid");
|
||||
}
|
||||
}
|
||||
} catch (IllegalStateException failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("OCSP responder certificate validation failed");
|
||||
}
|
||||
if (proveSigning) {
|
||||
try {
|
||||
signing.orElseThrow(() -> new IllegalStateException("OCSP signing service is unavailable"))
|
||||
.validateSigningBinding(value.responderCredentialId(), value.signingKeyRef(),
|
||||
value.signatureAlgorithm(), value.signatureBindingId());
|
||||
} catch (IllegalStateException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException failure) {
|
||||
throw new IllegalStateException("OCSP signing capability proof failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private X509CertificateHolder certificate(PkiId credentialId) throws IOException {
|
||||
try (zeroecho.pki.application.PkiRepositoryContent content = repository.openCredential(credentialId);
|
||||
java.io.InputStream input = content.openStream()) {
|
||||
byte[] encoded = input.readNBytes(1_048_577);
|
||||
if (encoded.length == 1_048_577 || input.read() != -1) {
|
||||
throw new IOException("OCSP responder certificate exceeds its finite bound");
|
||||
}
|
||||
X509CertificateHolder certificate = new X509CertificateHolder(encoded);
|
||||
if (!java.util.Arrays.equals(encoded, certificate.getEncoded())) {
|
||||
throw new IOException("OCSP responder certificate is not canonical DER");
|
||||
}
|
||||
return certificate;
|
||||
}
|
||||
}
|
||||
|
||||
private ServerControlStore.ProtocolRecord record(Responder value) {
|
||||
byte[] payload = encode(value);
|
||||
return new ServerControlStore.ProtocolRecord(ServerControlStore.OCSP_RESPONDER,
|
||||
value.responderId(), sha256(payload), payload);
|
||||
}
|
||||
|
||||
private Responder decode(ServerControlStore.ProtocolRecord stored) {
|
||||
Responder value = decode(stored.payload());
|
||||
if (!stored.recordId().equals(value.responderId()) || !stored.commitment().equals(sha256(stored.payload()))) {
|
||||
throw new IllegalStateException("OCSP responder record commitment differs");
|
||||
}
|
||||
if (value.configurationCommitment().equals("0".repeat(64))) {
|
||||
throw new IllegalStateException("OCSP responder commitment is unsealed");
|
||||
}
|
||||
if (!value.configurationCommitment().equals(commitment(value, true))) {
|
||||
throw new IllegalStateException("OCSP responder commitment differs");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String commitment(Responder value, boolean ignoreStored) {
|
||||
return sha256(encode(value, ignoreStored));
|
||||
}
|
||||
|
||||
private static byte[] encode(Responder value) { return encode(value, false); }
|
||||
private static byte[] encode(Responder value, boolean ignoreCommitment) {
|
||||
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(1024);
|
||||
DataOutputStream output = new DataOutputStream(bytes)) {
|
||||
output.writeInt(1); write(output, value.responderId()); write(output, value.alias());
|
||||
write(output, value.realmId().value()); write(output, value.authorityId().value());
|
||||
write(output, value.issuerId().value()); output.writeInt(value.signingMode().code());
|
||||
write(output, value.responderCredentialId().value()); write(output, value.signingKeyRef().value());
|
||||
write(output, value.chainPathId().value()); write(output, value.signatureAlgorithm());
|
||||
output.writeBoolean(value.signatureBindingId().isPresent());
|
||||
if (value.signatureBindingId().isPresent()) write(output, value.signatureBindingId().orElseThrow());
|
||||
write(output, value.signatureBindingCommitment()); output.writeInt(value.responderIdForm().code());
|
||||
output.writeLong(value.responseValidity().toSeconds()); output.writeInt(value.noncePolicy().code());
|
||||
output.writeInt(value.maximumNonceBytes()); output.writeInt(value.acceptedHashes().size());
|
||||
for (OcspResponseService.CertIdHash hash : value.acceptedHashes().stream().sorted().toList()) {
|
||||
output.writeInt(hash.code());
|
||||
}
|
||||
output.writeInt(value.maximumRequestBytes()); output.writeInt(value.maximumEntries());
|
||||
output.writeLong(value.cacheLifetime().toSeconds()); output.writeInt(value.state().code());
|
||||
output.writeLong(value.createdAt().toEpochMilli());
|
||||
write(output, ignoreCommitment ? "0".repeat(64) : value.configurationCommitment()); output.flush();
|
||||
return bytes.toByteArray();
|
||||
} catch (IOException impossible) { throw new IllegalStateException("OCSP encoding failed", impossible); }
|
||||
}
|
||||
|
||||
private static Responder decode(byte[] payload) {
|
||||
if (payload.length == 0 || payload.length > 65_536) throw new IllegalStateException("OCSP record bound differs");
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) {
|
||||
if (input.readInt() != 1) throw new IOException("OCSP record schema is obsolete");
|
||||
String id = read(input); String alias = read(input); RealmId realm = new RealmId(read(input));
|
||||
PkiId authority = new PkiId(read(input)); PkiId issuer = new PkiId(read(input));
|
||||
SigningMode mode = SigningMode.fromCode(input.readInt()); PkiId credential = new PkiId(read(input));
|
||||
KeyRef key = new KeyRef(read(input)); PkiId path = new PkiId(read(input)); String algorithm = read(input);
|
||||
Optional<String> binding = input.readBoolean() ? Optional.of(read(input)) : Optional.empty();
|
||||
String bindingCommitment = read(input);
|
||||
OcspResponseService.ResponderId form = OcspResponseService.ResponderId.fromCode(input.readInt());
|
||||
Duration validity = Duration.ofSeconds(input.readLong()); NoncePolicy nonce = NoncePolicy.fromCode(input.readInt());
|
||||
int nonceBytes = input.readInt(); int count = input.readInt();
|
||||
if (count < 1 || count > 2) throw new IOException("OCSP hash count differs");
|
||||
Set<OcspResponseService.CertIdHash> hashes = new java.util.HashSet<>();
|
||||
for (int index = 0; index < count; index++) {
|
||||
hashes.add(OcspResponseService.CertIdHash.fromCode(input.readInt()));
|
||||
}
|
||||
int requestBytes = input.readInt(); int entries = input.readInt(); Duration cache = Duration.ofSeconds(input.readLong());
|
||||
State state = State.fromCode(input.readInt()); Instant created = Instant.ofEpochMilli(input.readLong());
|
||||
String commitment = read(input); if (input.read() != -1) throw new IOException("Trailing OCSP record data");
|
||||
return new Responder(id, alias, realm, authority, issuer, mode, credential, key, path, algorithm,
|
||||
binding, bindingCommitment, form, validity, nonce, nonceBytes, hashes, requestBytes,
|
||||
entries, cache, state, created, commitment);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new IllegalStateException("OCSP responder record is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static void write(DataOutputStream output, String value) throws IOException {
|
||||
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (encoded.length > 16_384) throw new IOException("OCSP string bound differs");
|
||||
output.writeInt(encoded.length); output.write(encoded);
|
||||
}
|
||||
private static String read(DataInputStream input) throws IOException {
|
||||
int length = input.readInt(); if (length < 0 || length > 16_384) throw new IOException("OCSP string bound differs");
|
||||
byte[] encoded = input.readNBytes(length); if (encoded.length != length) throw new IOException("OCSP record is truncated");
|
||||
String value = new String(encoded, StandardCharsets.UTF_8);
|
||||
if (!java.util.Arrays.equals(encoded, value.getBytes(StandardCharsets.UTF_8))) throw new IOException("OCSP UTF-8 differs");
|
||||
return value;
|
||||
}
|
||||
private static String sha256(byte[] value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); }
|
||||
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("OCSP commitment is invalid");
|
||||
}
|
||||
private static void positive(Duration value, Duration maximum, String name) {
|
||||
if (value == null || value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
|
||||
throw new IllegalArgumentException("OCSP " + name + " is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,17 @@ public final class OperationSecurityDescriptors {
|
||||
control(ServerControlOperation.ListAcmeAccounts.NAME, Permission.Action.ACME_ACCOUNT_READ,
|
||||
Permission.ResourceType.ACME_ACCOUNT, false, false),
|
||||
control(ServerControlOperation.DeactivateAcmeAccount.NAME, Permission.Action.ACME_ACCOUNT_MANAGE,
|
||||
Permission.ResourceType.ACME_ACCOUNT, true, false)));
|
||||
Permission.ResourceType.ACME_ACCOUNT, true, false),
|
||||
control(ServerControlOperation.RegisterOcspResponder.NAME, Permission.Action.OCSP_ADMINISTER,
|
||||
Permission.ResourceType.OCSP_RESPONDER, true, true),
|
||||
control(ServerControlOperation.InspectOcspResponder.NAME, Permission.Action.OCSP_RESPONDER_READ,
|
||||
Permission.ResourceType.OCSP_RESPONDER, false, false),
|
||||
control(ServerControlOperation.ListOcspResponders.NAME, Permission.Action.OCSP_RESPONDER_READ,
|
||||
Permission.ResourceType.OCSP_RESPONDER, false, false),
|
||||
control(ServerControlOperation.SetOcspResponderActive.ACTIVATE, Permission.Action.OCSP_ADMINISTER,
|
||||
Permission.ResourceType.OCSP_RESPONDER, true, true),
|
||||
control(ServerControlOperation.SetOcspResponderActive.DEACTIVATE, Permission.Action.OCSP_ADMINISTER,
|
||||
Permission.ResourceType.OCSP_RESPONDER, true, false)));
|
||||
}
|
||||
|
||||
/** Creates a registry and rejects duplicate operation identities. */
|
||||
@@ -420,6 +430,16 @@ public final class OperationSecurityDescriptors {
|
||||
case ServerControlOperation.InspectAcmeAccount value -> "account=" + atom(value.accountId());
|
||||
case ServerControlOperation.ListAcmeAccounts value -> "offset=" + value.offset() + ";limit=" + value.limit();
|
||||
case ServerControlOperation.DeactivateAcmeAccount value -> "account=" + atom(value.accountId());
|
||||
case ServerControlOperation.RegisterOcspResponder value -> "responder="
|
||||
+ atom(value.registration().responderId()) + ";authority="
|
||||
+ atom(value.registration().authorityId().value()) + ";issuer="
|
||||
+ atom(value.registration().issuerId().value()) + ";binding="
|
||||
+ value.registration().signatureBindingCommitment();
|
||||
case ServerControlOperation.InspectOcspResponder value -> "responder=" + atom(value.responderId());
|
||||
case ServerControlOperation.ListOcspResponders value -> "offset=" + value.offset()
|
||||
+ ";limit=" + value.limit();
|
||||
case ServerControlOperation.SetOcspResponderActive value -> "responder="
|
||||
+ atom(value.responderId()) + ";active=" + value.active();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public final class Permission {
|
||||
CERTIFICATE_READ_PII(76), CERTIFICATE_DOWNLOAD(77), CERTIFICATE_PUBLICATION_CHANGE(78),
|
||||
CERTIFICATE_REVOKE(80), CERTIFICATE_HOLD(81), CERTIFICATE_RELEASE_HOLD(82),
|
||||
REVOCATION_HISTORY_READ(83), CRL_GENERATE(84), CRL_PUBLISH(85), CRL_DOWNLOAD(86),
|
||||
OCSP_ADMINISTER(87), PUBLICATION_REGISTER(100), PUBLICATION_READ(101),
|
||||
OCSP_ADMINISTER(87), OCSP_RESPONDER_READ(88), PUBLICATION_REGISTER(100), PUBLICATION_READ(101),
|
||||
PUBLICATION_PROCESS(102), PUBLICATION_RETRY(103), PUBLICATION_RECONCILE(104),
|
||||
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),
|
||||
@@ -114,7 +114,7 @@ public final class Permission {
|
||||
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), REPOSITORY_ALIAS(37),
|
||||
ACME_DIRECTORY(38), ACME_ACCOUNT(39);
|
||||
ACME_DIRECTORY(38), ACME_ACCOUNT(39), OCSP_RESPONDER(40);
|
||||
private final int code;
|
||||
ResourceType(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
|
||||
@@ -68,7 +68,7 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
Optional<PublicListener> publicListener, Optional<AcmeListener> acmeListener) {
|
||||
|
||||
/** Current server configuration schema. */
|
||||
public static final int CURRENT_VERSION = 4;
|
||||
public static final int CURRENT_VERSION = 5;
|
||||
|
||||
/** Validates all security-sensitive fields before resource allocation. */
|
||||
public PkiServerConfiguration {
|
||||
|
||||
@@ -41,7 +41,7 @@ import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.server.acme.AcmeService;
|
||||
|
||||
/** Closed transport-neutral server-control administration operation hierarchy. */
|
||||
@SuppressWarnings("PMD.ControlStatementBraces")
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.ExcessivePublicCount" })
|
||||
public sealed interface ServerControlOperation permits ServerControlOperation.RegisterPrincipal,
|
||||
ServerControlOperation.InspectPrincipal, ServerControlOperation.ListPrincipals,
|
||||
ServerControlOperation.SetPrincipalEnabled, ServerControlOperation.ListRoleTemplates,
|
||||
@@ -63,7 +63,9 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
|
||||
ServerControlOperation.RegisterAcmeDirectory, ServerControlOperation.InspectAcmeDirectory,
|
||||
ServerControlOperation.ListAcmeDirectories, ServerControlOperation.SetAcmeDirectoryActive,
|
||||
ServerControlOperation.InspectAcmeAccount, ServerControlOperation.ListAcmeAccounts,
|
||||
ServerControlOperation.DeactivateAcmeAccount {
|
||||
ServerControlOperation.DeactivateAcmeAccount, ServerControlOperation.RegisterOcspResponder,
|
||||
ServerControlOperation.InspectOcspResponder, ServerControlOperation.ListOcspResponders,
|
||||
ServerControlOperation.SetOcspResponderActive {
|
||||
|
||||
/** @return stable operation identity */
|
||||
String name();
|
||||
@@ -357,6 +359,32 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
/** Registers one exact inactive OCSP responder binding. */
|
||||
record RegisterOcspResponder(OcspResponderService.Registration registration) implements ServerControlOperation {
|
||||
public static final String NAME = "ocsp.responder.register";
|
||||
public RegisterOcspResponder { Objects.requireNonNull(registration, "registration"); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
/** Inspects one exact OCSP responder binding. */
|
||||
record InspectOcspResponder(String responderId) implements ServerControlOperation {
|
||||
public static final String NAME = "ocsp.responder.inspect";
|
||||
public InspectOcspResponder { Permission.requireId(responderId, "OCSP responder"); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
/** Lists a bounded OCSP responder page. */
|
||||
record ListOcspResponders(int offset, int limit) implements ServerControlOperation {
|
||||
public static final String NAME = "ocsp.responder.list";
|
||||
public ListOcspResponders { page(offset, limit); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
/** Activates or deactivates one exact responder. */
|
||||
record SetOcspResponderActive(String responderId, boolean active) implements ServerControlOperation {
|
||||
public static final String ACTIVATE = "ocsp.responder.activate";
|
||||
public static final String DEACTIVATE = "ocsp.responder.deactivate";
|
||||
public SetOcspResponderActive { Permission.requireId(responderId, "OCSP responder"); }
|
||||
@Override public String name() { return active ? ACTIVATE : DEACTIVATE; }
|
||||
}
|
||||
|
||||
private static void page(int offset, int limit) {
|
||||
if (offset < 0 || limit <= 0 || limit > 256) throw invalid();
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ public final class ServerControlOperationExecutor {
|
||||
private final OperationSecurityDescriptors descriptors;
|
||||
private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies;
|
||||
private final java.util.concurrent.atomic.AtomicReference<AcmeService> acme = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
private final java.util.concurrent.atomic.AtomicReference<OcspResponderService> ocsp = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
|
||||
/** Creates the one control dispatcher over existing durable authorities. */
|
||||
public ServerControlOperationExecutor(RealmId realmId, AuthorityExposurePolicy exposure,
|
||||
@@ -117,6 +118,13 @@ public final class ServerControlOperationExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs the realm-owned durable OCSP responder capability exactly once. */
|
||||
public void installOcsp(OcspResponderService service) {
|
||||
if (!ocsp.compareAndSet(null, Objects.requireNonNull(service, "service"))) {
|
||||
throw new IllegalStateException("OCSP administration capability is already installed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves exact ACME authority/profile scope from durable records. */
|
||||
/* default */ Permission.Scope acmeScope(ServerControlOperation operation) {
|
||||
AcmeState.Directory directory = switch (operation) {
|
||||
@@ -220,6 +228,14 @@ public final class ServerControlOperationExecutor {
|
||||
page(acme().accounts(value.offset(), value.limit()), ServerControlOperationExecutor::account));
|
||||
case ServerControlOperation.DeactivateAcmeAccount value -> ordinary(operation,
|
||||
account(acme().deactivateAccount(value.accountId())));
|
||||
case ServerControlOperation.RegisterOcspResponder value -> ordinary(operation,
|
||||
responder(ocsp().register(value.registration())));
|
||||
case ServerControlOperation.InspectOcspResponder value -> ordinary(operation,
|
||||
responder(ocsp().require(value.responderId())));
|
||||
case ServerControlOperation.ListOcspResponders value -> ordinary(operation,
|
||||
page(ocsp().list(value.offset(), value.limit()), ServerControlOperationExecutor::responder));
|
||||
case ServerControlOperation.SetOcspResponderActive value -> ordinary(operation,
|
||||
responder(ocsp().setActive(value.responderId(), value.active())));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,6 +243,14 @@ public final class ServerControlOperationExecutor {
|
||||
return Optional.ofNullable(acme.get()).orElseThrow(() -> new IllegalStateException("ACME capability unavailable"));
|
||||
}
|
||||
|
||||
private OcspResponderService ocsp() {
|
||||
return Optional.ofNullable(ocsp.get()).orElseThrow(() -> new IllegalStateException("OCSP capability unavailable"));
|
||||
}
|
||||
|
||||
/* default */ OcspResponderService.Responder ocspResponder(String responderId) {
|
||||
return ocsp().require(responderId);
|
||||
}
|
||||
|
||||
private RepositoryAliasService aliases() {
|
||||
return repositoryAliases.orElseThrow(() -> new IllegalStateException("Repository aliases are unavailable"));
|
||||
}
|
||||
@@ -390,6 +414,13 @@ public final class ServerControlOperationExecutor {
|
||||
"directoryRevision", integer(value.directoryRevision()), "status", text(value.status().name()),
|
||||
"createdAt", text(value.createdAt().toString()), "updatedAt", text(value.updatedAt().toString()));
|
||||
}
|
||||
private static PkiOperationValue responder(OcspResponderService.Responder value) {
|
||||
return object("responderId", text(value.responderId()), "alias", text(value.alias()),
|
||||
"authorityId", text(value.authorityId().value()), "issuerId", text(value.issuerId().value()),
|
||||
"signingMode", text(value.signingMode().name()), "responderCredentialId",
|
||||
text(value.responderCredentialId().value()), "state", text(value.state().name()),
|
||||
"configurationCommitment", text(value.configurationCommitment()));
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -103,9 +103,11 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
public static final String ACME_CHALLENGE = "io.zeroecho.server.acme-challenge";
|
||||
/** Stable namespace for ACME validation-evidence records. */
|
||||
public static final String ACME_EVIDENCE = "io.zeroecho.server.acme-evidence";
|
||||
/** Stable namespace for durable OCSP responder bindings. */
|
||||
public static final String OCSP_RESPONDER = "io.zeroecho.server.ocsp-responder";
|
||||
|
||||
private static final int MAGIC = 0x5a455331;
|
||||
private static final int SCHEMA = 4;
|
||||
private static final int SCHEMA = 5;
|
||||
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;
|
||||
@@ -118,39 +120,39 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
private static final int KIND_DISCLOSURE = 7;
|
||||
private static final int KIND_CAPABILITY = 8;
|
||||
private static final int KIND_REPOSITORY_ALIAS = 9;
|
||||
private static final int KIND_ACME = 10;
|
||||
private static final int KIND_PROTOCOL = 10;
|
||||
|
||||
/**
|
||||
* Strict opaque ACME payload framed by the server-control authority.
|
||||
* Strict opaque protocol payload framed by the server-control authority.
|
||||
*
|
||||
* <p>The ACME domain codec owns the payload schema. This record keeps the
|
||||
* <p>The owning closed protocol codec owns the payload schema. This record keeps the
|
||||
* transactional metadata layer independent of protocol classes while still
|
||||
* enforcing canonical key identity and bounded content.</p>
|
||||
*
|
||||
* @param namespace one closed ACME namespace
|
||||
* @param namespace one closed protocol namespace
|
||||
* @param recordId canonical domain identity
|
||||
* @param commitment SHA-256 commitment of the complete domain payload
|
||||
* @param payload strict versioned ACME domain encoding
|
||||
* @param payload strict versioned protocol-domain encoding
|
||||
*/
|
||||
public record AcmeRecord(String namespace, String recordId, String commitment, byte[] payload) {
|
||||
public record ProtocolRecord(String namespace, String recordId, String commitment, byte[] payload) {
|
||||
/** Validates namespace, identity, commitment, and defensive payload bounds. */
|
||||
public AcmeRecord {
|
||||
if (!ACME_NAMESPACES.contains(namespace)) {
|
||||
throw new IllegalArgumentException("Unknown ACME control namespace");
|
||||
public ProtocolRecord {
|
||||
if (!PROTOCOL_NAMESPACES.contains(namespace)) {
|
||||
throw new IllegalArgumentException("Unknown protocol control namespace");
|
||||
}
|
||||
Permission.requireId(recordId, "ACME record");
|
||||
Permission.requireId(recordId, "protocol record");
|
||||
requireDigest(commitment);
|
||||
payload = Objects.requireNonNull(payload, "payload").clone();
|
||||
if (payload.length == 0 || payload.length > MAXIMUM_RECORD_BYTES / 2) {
|
||||
throw new IllegalArgumentException("ACME control payload bound is invalid");
|
||||
throw new IllegalArgumentException("Protocol control payload bound is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public byte[] payload() { return payload.clone(); }
|
||||
}
|
||||
|
||||
private static final Set<String> ACME_NAMESPACES = Set.of(ACME_DIRECTORY, ACME_ACCOUNT, ACME_ORDER,
|
||||
ACME_AUTHORIZATION, ACME_CHALLENGE, ACME_EVIDENCE);
|
||||
private static final Set<String> PROTOCOL_NAMESPACES = Set.of(ACME_DIRECTORY, ACME_ACCOUNT, ACME_ORDER,
|
||||
ACME_AUTHORIZATION, ACME_CHALLENGE, ACME_EVIDENCE, OCSP_RESPONDER);
|
||||
|
||||
/**
|
||||
* Durable realm-control identity and commitments.
|
||||
@@ -393,72 +395,73 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** One compare-and-set mutation participating in an atomic ACME state change. */
|
||||
public record AcmeMutation(AcmeRecord record, Optional<String> expectedCommitment) {
|
||||
/** One compare-and-set mutation participating in an atomic protocol-state change. */
|
||||
public record ProtocolMutation(ProtocolRecord record, Optional<String> expectedCommitment) {
|
||||
/** Validates the immutable mutation request. */
|
||||
public AcmeMutation {
|
||||
public ProtocolMutation {
|
||||
Objects.requireNonNull(record, "record");
|
||||
expectedCommitment = Objects.requireNonNull(expectedCommitment, "expectedCommitment");
|
||||
expectedCommitment.ifPresent(ServerControlStore::requireDigest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads one exact ACME record from its closed namespace. */
|
||||
public synchronized Optional<AcmeRecord> acmeRecord(String namespace, String recordId) {
|
||||
requireAcmeNamespace(namespace);
|
||||
return read(namespace, recordId, KIND_ACME, input -> readAcme(input, namespace));
|
||||
/** Reads one exact protocol record from its closed namespace. */
|
||||
public synchronized Optional<ProtocolRecord> protocolRecord(String namespace, String recordId) {
|
||||
requireProtocolNamespace(namespace);
|
||||
return read(namespace, recordId, KIND_PROTOCOL, input -> readProtocol(input, namespace));
|
||||
}
|
||||
|
||||
/** Returns one bounded deterministic page of ACME records. */
|
||||
public synchronized Page<AcmeRecord> acmeRecords(String namespace, int offset, int limit) {
|
||||
requireAcmeNamespace(namespace);
|
||||
return scanPage(namespace, KIND_ACME, input -> readAcme(input, namespace), offset, limit);
|
||||
/** Returns one bounded deterministic page of protocol records. */
|
||||
public synchronized Page<ProtocolRecord> protocolRecords(String namespace, int offset, int limit) {
|
||||
requireProtocolNamespace(namespace);
|
||||
return scanPage(namespace, KIND_PROTOCOL, input -> readProtocol(input, namespace), offset, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically creates or compare-and-replaces a finite set of ACME records.
|
||||
* Atomically creates or compare-and-replaces a finite set of protocol records.
|
||||
* Empty expected commitments mean create-only; present commitments mean exact
|
||||
* compare-and-replace. Provider I/O and PKI operations must occur outside this
|
||||
* method.
|
||||
*/
|
||||
public synchronized void mutateAcme(List<AcmeMutation> requested) {
|
||||
public synchronized void mutateProtocol(List<ProtocolMutation> requested) {
|
||||
requireOpen();
|
||||
List<AcmeMutation> mutations = List.copyOf(Objects.requireNonNull(requested, "requested"));
|
||||
List<ProtocolMutation> mutations = List.copyOf(Objects.requireNonNull(requested, "requested"));
|
||||
if (mutations.isEmpty() || mutations.size() > 256) {
|
||||
throw new IllegalArgumentException("ACME transaction size is invalid");
|
||||
throw new IllegalArgumentException("Protocol transaction size is invalid");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
if (mutations.stream().anyMatch(item -> !keys.add(item.record().namespace() + '\n'
|
||||
+ item.record().recordId()))) {
|
||||
throw new IllegalArgumentException("Duplicate ACME transaction identity");
|
||||
throw new IllegalArgumentException("Duplicate protocol transaction identity");
|
||||
}
|
||||
try (MetadataSnapshot snapshot = metadata.snapshot();
|
||||
MetadataTransaction transaction = metadata.beginTransaction()) {
|
||||
for (AcmeMutation mutation : mutations) {
|
||||
AcmeRecord value = mutation.record();
|
||||
for (ProtocolMutation mutation : mutations) {
|
||||
ProtocolRecord value = mutation.record();
|
||||
MetadataKey metadataKey = key(value.namespace(), value.recordId());
|
||||
Optional<MetadataSnapshot.Record> existing = snapshot.get(metadataKey);
|
||||
byte[] encoded = encode(output -> writeAcme(output, value));
|
||||
byte[] encoded = encode(output -> writeProtocol(output, value));
|
||||
RepeatableContent content = new ByteContent(encoded);
|
||||
if (mutation.expectedCommitment().isEmpty()) {
|
||||
if (existing.isPresent()) throw new IllegalStateException("ACME record already exists");
|
||||
if (existing.isPresent()) throw new IllegalStateException("Protocol record already exists");
|
||||
transaction.create(metadataKey, content, CancellationSignal.NONE);
|
||||
} else {
|
||||
MetadataSnapshot.Record current = existing
|
||||
.orElseThrow(() -> new IllegalStateException("ACME record is unavailable"));
|
||||
AcmeRecord decoded = decode(current, KIND_ACME, input -> readAcme(input, value.namespace()));
|
||||
.orElseThrow(() -> new IllegalStateException("Protocol record is unavailable"));
|
||||
ProtocolRecord decoded = decode(current, KIND_PROTOCOL,
|
||||
input -> readProtocol(input, value.namespace()));
|
||||
if (!mutation.expectedCommitment().orElseThrow().equals(decoded.commitment())) {
|
||||
throw new IllegalStateException("ACME record commitment conflict");
|
||||
throw new IllegalStateException("Protocol record commitment conflict");
|
||||
}
|
||||
transaction.replace(metadataKey, current.recordRevision(), content, CancellationSignal.NONE);
|
||||
}
|
||||
}
|
||||
MetadataCommitResult result = transaction.commit();
|
||||
if (result.outcome() != MetadataCommitResult.Outcome.COMMITTED) {
|
||||
throw new IllegalStateException("ACME metadata commit requires reconciliation");
|
||||
throw new IllegalStateException("Protocol metadata commit requires reconciliation");
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("ACME metadata mutation failed");
|
||||
throw new IllegalStateException("Protocol metadata mutation failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,8 +522,8 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure);
|
||||
scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability);
|
||||
scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias);
|
||||
for (String namespace : ACME_NAMESPACES) {
|
||||
scan(namespace, KIND_ACME, input -> readAcme(input, namespace));
|
||||
for (String namespace : PROTOCOL_NAMESPACES) {
|
||||
scan(namespace, KIND_PROTOCOL, input -> readProtocol(input, namespace));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,14 +726,14 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
case DisclosureService.Record item -> item.objectId().value();
|
||||
case DisclosureService.Capability item -> item.capabilityId();
|
||||
case RepositoryAliasService.Record item -> item.aliasId();
|
||||
case AcmeRecord item -> item.recordId();
|
||||
case ProtocolRecord item -> item.recordId();
|
||||
default -> throw new IllegalArgumentException("Unsupported control record type");
|
||||
};
|
||||
}
|
||||
|
||||
private static void requireAcmeNamespace(String namespace) {
|
||||
if (!ACME_NAMESPACES.contains(namespace)) {
|
||||
throw new IllegalArgumentException("Unknown ACME control namespace");
|
||||
private static void requireProtocolNamespace(String namespace) {
|
||||
if (!PROTOCOL_NAMESPACES.contains(namespace)) {
|
||||
throw new IllegalArgumentException("Unknown protocol control namespace");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,15 +767,15 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
readString(in), readString(in), readString(in), new MetadataStoreId(readString(in)));
|
||||
}
|
||||
|
||||
private static void writeAcme(DataOutputStream out, AcmeRecord value) throws IOException {
|
||||
out.writeInt(KIND_ACME); writeString(out, value.namespace()); writeString(out, value.recordId());
|
||||
private static void writeProtocol(DataOutputStream out, ProtocolRecord value) throws IOException {
|
||||
out.writeInt(KIND_PROTOCOL); writeString(out, value.namespace()); writeString(out, value.recordId());
|
||||
writeString(out, value.commitment()); writeBytes(out, value.payload());
|
||||
}
|
||||
|
||||
private static AcmeRecord readAcme(DataInputStream in, String expectedNamespace) throws IOException {
|
||||
private static ProtocolRecord readProtocol(DataInputStream in, String expectedNamespace) throws IOException {
|
||||
String namespace = readString(in);
|
||||
if (!expectedNamespace.equals(namespace)) throw new IllegalArgumentException("ACME namespace mismatch");
|
||||
return new AcmeRecord(namespace, readString(in), readString(in),
|
||||
return new ProtocolRecord(namespace, readString(in), readString(in),
|
||||
readBoundedBytes(in, MAXIMUM_RECORD_BYTES / 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,8 @@ public final class ServerOperationGateway {
|
||||
|
||||
/** Installs the optional configured ACME control capability once before listener readiness. */
|
||||
public void installAcme(AcmeService service) { controlExecutor.installAcme(service); }
|
||||
/** Installs the realm-owned OCSP administration capability before readiness. */
|
||||
public void installOcsp(OcspResponderService service) { controlExecutor.installOcsp(service); }
|
||||
|
||||
/** Creates the pre-control-plane gateway surface for embedded source compatibility. */
|
||||
public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control,
|
||||
@@ -538,6 +540,19 @@ public final class ServerOperationGateway {
|
||||
case ServerControlOperation.SetAcmeDirectoryActive value -> controlExecutor.acmeScope(value);
|
||||
case ServerControlOperation.InspectAcmeAccount value -> controlExecutor.acmeScope(value);
|
||||
case ServerControlOperation.DeactivateAcmeAccount value -> controlExecutor.acmeScope(value);
|
||||
case ServerControlOperation.RegisterOcspResponder value -> new Permission.Scope(realmId,
|
||||
Optional.of(value.registration().authorityId()), Optional.of(value.registration().issuerId()),
|
||||
Optional.empty());
|
||||
case ServerControlOperation.InspectOcspResponder value -> {
|
||||
OcspResponderService.Responder responder = controlExecutor.ocspResponder(value.responderId());
|
||||
yield new Permission.Scope(realmId, Optional.of(responder.authorityId()),
|
||||
Optional.of(responder.issuerId()), Optional.empty());
|
||||
}
|
||||
case ServerControlOperation.SetOcspResponderActive value -> {
|
||||
OcspResponderService.Responder responder = controlExecutor.ocspResponder(value.responderId());
|
||||
yield new Permission.Scope(realmId, Optional.of(responder.authorityId()),
|
||||
Optional.of(responder.issuerId()), Optional.empty());
|
||||
}
|
||||
default -> resource.scope();
|
||||
};
|
||||
if (!actual.equals(resource.scope())) throw new SecurityException("Control scope differs");
|
||||
|
||||
@@ -79,6 +79,7 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
private final DisclosureService disclosure;
|
||||
private final RepositoryAliasService repositoryAliases;
|
||||
private final AcmeControlStore acmeControl;
|
||||
private final OcspResponderService ocspResponders;
|
||||
private final PublicRepositoryGateway publicRepository;
|
||||
private final AuditorViews auditorViews;
|
||||
private final ServerOperationGateway gateway;
|
||||
@@ -101,6 +102,9 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
this.repositoryAliases = repositoryAliases;
|
||||
this.acmeControl = new AcmeControlStore(control);
|
||||
this.acmeControl.validateAndRecover(clock);
|
||||
this.ocspResponders = new OcspResponderService(configuration.realmId(), control, session.repository(),
|
||||
session.algorithmBindings(), session.ocsp(), clock);
|
||||
this.ocspResponders.validateAll();
|
||||
this.publicRepository = new PublicRepositoryGateway(configuration.realmId(), configuration.authorityExposure(),
|
||||
session.repository(), control, roles, authorization, breakGlass, disclosure, repositoryAliases,
|
||||
this::requireOpen);
|
||||
@@ -112,6 +116,7 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
new OperationSecurityDescriptors(),
|
||||
session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink,
|
||||
this::requireOpen);
|
||||
this.gateway.installOcsp(ocspResponders);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +153,7 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
control.validateAll();
|
||||
control.validateReferences(roles);
|
||||
audit = new SharedAuditSink(PkiBootstrap.openAudit(exact.pkiSessionConfiguration().audit()));
|
||||
session = PkiSession.open(exact.pkiSessionConfiguration(), runtime.withAuditSink(audit));
|
||||
session = PkiSession.open(exact.pkiSessionConfiguration(), runtime.withAuditSink(audit), clock);
|
||||
validateExposure(exact.authorityExposure(), session);
|
||||
AuthorizationEngine authorization = new AuthorizationEngine(clock);
|
||||
ApprovalService approvals = new ApprovalService(control, clock, audit);
|
||||
@@ -186,6 +191,8 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
public RepositoryAliasService repositoryAliases() { requireOpen(); return repositoryAliases; }
|
||||
/** @return typed ACME records in the realm's sole durable control authority */
|
||||
public AcmeControlStore acmeControl() { requireOpen(); return acmeControl; }
|
||||
/** @return durable exact OCSP responder bindings */
|
||||
public OcspResponderService ocspResponders() { requireOpen(); return ocspResponders; }
|
||||
/** @return read-only disclosed public repository gateway */
|
||||
public PublicRepositoryGateway publicRepository() { requireOpen(); return publicRepository; }
|
||||
/** @return explicit auditor projection service */
|
||||
|
||||
@@ -56,7 +56,7 @@ public final class AcmeControlStore {
|
||||
/** Creates one record after canonical sealing. */
|
||||
public <T> T create(T unsealed) {
|
||||
T sealed = seal(unsealed);
|
||||
control.mutateAcme(List.of(new ServerControlStore.AcmeMutation(record(sealed), Optional.empty())));
|
||||
control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(sealed), Optional.empty())));
|
||||
return sealed;
|
||||
}
|
||||
|
||||
@@ -68,11 +68,11 @@ public final class AcmeControlStore {
|
||||
.map(this::<AcmeState.Authorization>seal).toList();
|
||||
List<AcmeState.Challenge> sealedChallenges = challenges.stream()
|
||||
.map(this::<AcmeState.Challenge>seal).toList();
|
||||
List<ServerControlStore.AcmeMutation> changes = new ArrayList<>();
|
||||
changes.add(new ServerControlStore.AcmeMutation(record(sealedOrder), Optional.empty()));
|
||||
sealedAuthorizations.forEach(value -> changes.add(new ServerControlStore.AcmeMutation(record(value), Optional.empty())));
|
||||
sealedChallenges.forEach(value -> changes.add(new ServerControlStore.AcmeMutation(record(value), Optional.empty())));
|
||||
control.mutateAcme(changes);
|
||||
List<ServerControlStore.ProtocolMutation> changes = new ArrayList<>();
|
||||
changes.add(new ServerControlStore.ProtocolMutation(record(sealedOrder), Optional.empty()));
|
||||
sealedAuthorizations.forEach(value -> changes.add(new ServerControlStore.ProtocolMutation(record(value), Optional.empty())));
|
||||
sealedChallenges.forEach(value -> changes.add(new ServerControlStore.ProtocolMutation(record(value), Optional.empty())));
|
||||
control.mutateProtocol(changes);
|
||||
return new Graph(sealedOrder, sealedAuthorizations, sealedChallenges);
|
||||
}
|
||||
|
||||
@@ -85,13 +85,13 @@ public final class AcmeControlStore {
|
||||
public Transition transition(List<Replacement> replacements, List<?> creations) {
|
||||
List<Object> sealed = replacements.stream().map(Replacement::next).map(this::sealObject).toList();
|
||||
List<Object> created = creations.stream().map(this::sealObject).toList();
|
||||
List<ServerControlStore.AcmeMutation> changes = new ArrayList<>();
|
||||
List<ServerControlStore.ProtocolMutation> changes = new ArrayList<>();
|
||||
for (int index = 0; index < replacements.size(); index++) {
|
||||
Object prior = replacements.get(index).prior(); Object next = sealed.get(index);
|
||||
changes.add(new ServerControlStore.AcmeMutation(record(next), Optional.of(commitment(prior))));
|
||||
changes.add(new ServerControlStore.ProtocolMutation(record(next), Optional.of(commitment(prior))));
|
||||
}
|
||||
created.forEach(value -> changes.add(new ServerControlStore.AcmeMutation(record(value), Optional.empty())));
|
||||
control.mutateAcme(changes); return new Transition(sealed, created);
|
||||
created.forEach(value -> changes.add(new ServerControlStore.ProtocolMutation(record(value), Optional.empty())));
|
||||
control.mutateProtocol(changes); return new Transition(sealed, created);
|
||||
}
|
||||
|
||||
/** Results of one atomic ACME graph transition. */
|
||||
@@ -114,7 +114,7 @@ public final class AcmeControlStore {
|
||||
/** Reads one exact typed record and cross-checks its identity. */
|
||||
public <T> Optional<T> get(Class<T> type, String recordId) {
|
||||
String namespace = namespace(type);
|
||||
return control.acmeRecord(namespace, recordId).map(value -> {
|
||||
return control.protocolRecord(namespace, recordId).map(value -> {
|
||||
Object decoded = codec.decode(value.payload());
|
||||
if (!type.isInstance(decoded) || !recordId.equals(identity(decoded))
|
||||
|| !value.commitment().equals(commitment(decoded))) {
|
||||
@@ -126,8 +126,8 @@ public final class AcmeControlStore {
|
||||
|
||||
/** Returns one bounded typed page without aggregating the namespace. */
|
||||
public <T> ServerControlStore.Page<T> page(Class<T> type, int offset, int limit) {
|
||||
ServerControlStore.Page<ServerControlStore.AcmeRecord> page =
|
||||
control.acmeRecords(namespace(type), offset, limit);
|
||||
ServerControlStore.Page<ServerControlStore.ProtocolRecord> page =
|
||||
control.protocolRecords(namespace(type), offset, limit);
|
||||
List<T> values = page.values().stream().map(value -> {
|
||||
Object decoded = codec.decode(value.payload());
|
||||
if (!type.isInstance(decoded) || !value.recordId().equals(identity(decoded))
|
||||
@@ -251,8 +251,8 @@ public final class AcmeControlStore {
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T seal(T value) { return (T) codec.seal(Objects.requireNonNull(value, "value")); }
|
||||
private Object sealObject(Object value) { return codec.seal(Objects.requireNonNull(value, "value")); }
|
||||
private ServerControlStore.AcmeRecord record(Object value) {
|
||||
return new ServerControlStore.AcmeRecord(namespace(value.getClass()), identity(value),
|
||||
private ServerControlStore.ProtocolRecord record(Object value) {
|
||||
return new ServerControlStore.ProtocolRecord(namespace(value.getClass()), identity(value),
|
||||
commitment(value), codec.encode(value));
|
||||
}
|
||||
private static String commitment(Object value) {
|
||||
|
||||
@@ -51,10 +51,12 @@ import zeroecho.pki.api.status.StatusObjectType;
|
||||
import zeroecho.pki.application.PkiOperation;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.server.OperationSecurityDescriptors;
|
||||
import zeroecho.pki.application.OcspResponseService;
|
||||
import zeroecho.pki.server.AdministrativeOperation;
|
||||
import zeroecho.pki.server.ApprovalService;
|
||||
import zeroecho.pki.server.DisclosureService;
|
||||
import zeroecho.pki.server.Permission;
|
||||
import zeroecho.pki.server.OcspResponderService;
|
||||
import zeroecho.pki.server.RealmId;
|
||||
import zeroecho.pki.server.RepositoryAliasService;
|
||||
import zeroecho.pki.server.RoleTemplateCatalog;
|
||||
@@ -429,6 +431,39 @@ final class HttpOperationCodec {
|
||||
case ServerControlOperation.DeactivateAcmeAccount.NAME -> {
|
||||
fields.exact("accountId"); yield new ServerControlOperation.DeactivateAcmeAccount(fields.text("accountId"));
|
||||
}
|
||||
case ServerControlOperation.RegisterOcspResponder.NAME -> {
|
||||
fields.exact("responderId", "alias", "authorityId", "issuerId", "signingMode",
|
||||
"responderCredentialId", "signingKeyRef", "chainPathId", "signatureAlgorithm",
|
||||
"signatureBindingId", "signatureBindingCommitment", "responderIdForm",
|
||||
"responseValidityMillis", "noncePolicy", "maximumNonceBytes", "acceptedHashes",
|
||||
"maximumRequestBytes", "maximumEntries", "cacheLifetimeMillis");
|
||||
yield new ServerControlOperation.RegisterOcspResponder(new OcspResponderService.Registration(
|
||||
fields.text("responderId"), fields.text("alias"),
|
||||
fields.pkiId("authorityId"), fields.pkiId("issuerId"),
|
||||
OcspResponderService.SigningMode.valueOf(fields.text("signingMode")),
|
||||
fields.pkiId("responderCredentialId"), new KeyRef(fields.text("signingKeyRef")),
|
||||
fields.pkiId("chainPathId"), fields.text("signatureAlgorithm"),
|
||||
fields.optionalText("signatureBindingId"), fields.text("signatureBindingCommitment"),
|
||||
OcspResponseService.ResponderId.valueOf(fields.text("responderIdForm")),
|
||||
Duration.ofMillis(fields.longValue("responseValidityMillis")),
|
||||
OcspResponderService.NoncePolicy.valueOf(fields.text("noncePolicy")),
|
||||
fields.integer("maximumNonceBytes"),
|
||||
fields.enumSet("acceptedHashes", OcspResponseService.CertIdHash.class),
|
||||
fields.integer("maximumRequestBytes"), fields.integer("maximumEntries"),
|
||||
Duration.ofMillis(fields.longValue("cacheLifetimeMillis"))));
|
||||
}
|
||||
case ServerControlOperation.InspectOcspResponder.NAME -> {
|
||||
fields.exact("responderId"); yield new ServerControlOperation.InspectOcspResponder(fields.text("responderId"));
|
||||
}
|
||||
case ServerControlOperation.ListOcspResponders.NAME -> {
|
||||
fields.exact("offset", "limit"); yield new ServerControlOperation.ListOcspResponders(
|
||||
fields.integer("offset"), fields.integer("limit"));
|
||||
}
|
||||
case ServerControlOperation.SetOcspResponderActive.ACTIVATE,
|
||||
ServerControlOperation.SetOcspResponderActive.DEACTIVATE -> {
|
||||
fields.exact("responderId"); yield new ServerControlOperation.SetOcspResponderActive(
|
||||
fields.text("responderId"), id.equals(ServerControlOperation.SetOcspResponderActive.ACTIVATE));
|
||||
}
|
||||
default -> throw new SecurityException("Control operation is not exposed");
|
||||
};
|
||||
}
|
||||
@@ -449,6 +484,9 @@ final class HttpOperationCodec {
|
||||
value.authorityId());
|
||||
case ServerControlOperation.RegisterAcmeDirectory value -> authorityScope(realmId, authority,
|
||||
value.registration().authorityId());
|
||||
case ServerControlOperation.RegisterOcspResponder value -> new Permission.Scope(realmId,
|
||||
Optional.of(value.registration().authorityId()), Optional.of(value.registration().issuerId()),
|
||||
Optional.empty());
|
||||
default -> new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/*******************************************************************************
|
||||
* 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.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
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.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.application.OcspResponseService;
|
||||
import zeroecho.pki.application.PkiRepositoryContent;
|
||||
import zeroecho.pki.server.AdministrativeAuthenticationMode;
|
||||
import zeroecho.pki.server.OcspResponderService;
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
import zeroecho.pki.server.ServerRealmContext;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
|
||||
|
||||
/** Strict anonymous OCSP protocol adapter on the public listener only. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class OcspHttpHandler implements HttpHandler {
|
||||
private static final String MEDIA_REQUEST = "application/ocsp-request";
|
||||
private static final String MEDIA_RESPONSE = "application/ocsp-response";
|
||||
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;
|
||||
|
||||
OcspHttpHandler(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"; boolean admitted = false;
|
||||
OcspResponderService.Responder auditedResponder = null;
|
||||
try {
|
||||
requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER));
|
||||
if (!ready.getAsBoolean()) { transportFailure(exchange, 503); return; }
|
||||
requireHeadersBounded(exchange.getRequestHeaders());
|
||||
validateTransport(exchange, requestId);
|
||||
Route route = route(exchange);
|
||||
OcspResponderService.Responder responder = activeResponder(route.alias());
|
||||
auditedResponder = responder;
|
||||
audit(requestId, Optional.of(responder), "ACCEPTED");
|
||||
if (!runtime.tryAdmit()) { audit(requestId, Optional.of(responder), "OVERLOAD"); transportFailure(exchange, 429); return; }
|
||||
admitted = true;
|
||||
int maximumRequestBytes = Math.min(configuration.maximumBodyBytes(), responder.maximumRequestBytes());
|
||||
byte[] request = route.encoded().orElseGet(() -> read(exchange, maximumRequestBytes));
|
||||
if (request.length > maximumRequestBytes) { transportFailure(exchange, 413); return; }
|
||||
OcspRequestParser.Parsed parsed = OcspRequestParser.parse(request, responder.maximumEntries(),
|
||||
responder.acceptedHashes(), responder.maximumNonceBytes());
|
||||
validateNonce(responder.noncePolicy(), parsed.nonce());
|
||||
OcspResponseService.Response response = execute(responder, parsed);
|
||||
send(exchange, responder, response, parsed.nonce().isPresent(), requestId);
|
||||
audit(requestId, Optional.of(responder), "RESPONDED_GOOD_" + response.goodCount()
|
||||
+ "_REVOKED_" + response.revokedCount() + "_UNKNOWN_" + response.unknownCount());
|
||||
} catch (UnknownAlias unavailable) { audit(requestId, Optional.empty(), "UNKNOWN_RESPONDER"); transportFailure(exchange, 404);
|
||||
} catch (InactiveResponder inactive) { audit(requestId, Optional.empty(), "INACTIVE_RESPONDER"); transportFailure(exchange, 503);
|
||||
} catch (MethodFailure method) { audit(requestId, Optional.ofNullable(auditedResponder), "METHOD_REJECTED"); transportFailure(exchange, 405);
|
||||
} catch (MediaFailure media) { audit(requestId, Optional.ofNullable(auditedResponder), "MEDIA_REJECTED"); transportFailure(exchange, 406);
|
||||
} catch (IllegalArgumentException malformed) { audit(requestId, Optional.ofNullable(auditedResponder), "MALFORMED"); protocolFailure(exchange, org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST);
|
||||
} catch (RejectedExecutionException overload) { audit(requestId, Optional.ofNullable(auditedResponder), "OVERLOAD"); transportFailure(exchange, 429);
|
||||
} catch (TimeoutException deadline) { audit(requestId, Optional.ofNullable(auditedResponder), "DEADLINE"); transportFailure(exchange, 504);
|
||||
} catch (RuntimeException failure) { audit(requestId, Optional.ofNullable(auditedResponder), "UNAVAILABLE"); transportFailure(exchange, 503);
|
||||
} finally { if (admitted) runtime.releaseAdmission(); exchange.close(); }
|
||||
}
|
||||
|
||||
private OcspResponderService.Responder activeResponder(String alias) {
|
||||
try {
|
||||
OcspResponderService.Responder responder = realm.ocspResponders().requireAlias(alias);
|
||||
if (responder.state() != OcspResponderService.State.ACTIVE) throw new InactiveResponder();
|
||||
return realm.ocspResponders().requireActiveAlias(alias);
|
||||
} catch (IllegalArgumentException unavailable) {
|
||||
throw new UnknownAlias();
|
||||
}
|
||||
}
|
||||
|
||||
private OcspResponseService.Response execute(OcspResponderService.Responder responder,
|
||||
OcspRequestParser.Parsed parsed) throws TimeoutException {
|
||||
IssuerGeneration issuer = realm.session().repository().issuer(responder.issuerId()).orElseThrow();
|
||||
IssuerChainPath path = realm.session().repository().chainPath(responder.chainPathId()).orElseThrow();
|
||||
List<PkiId> chain = new ArrayList<>();
|
||||
if (responder.signingMode() == OcspResponderService.SigningMode.DELEGATED_RESPONDER) {
|
||||
chain.add(responder.responderCredentialId());
|
||||
}
|
||||
chain.addAll(path.orderedCredentialIds());
|
||||
Instant produced = producedAt(responder, parsed.nonce().isPresent());
|
||||
OcspResponseService.Command command = new OcspResponseService.Command(responder.authorityId(),
|
||||
responder.issuerId(), issuer.credentialId(), responder.responderCredentialId(),
|
||||
responder.signingKeyRef(), chain, responder.signatureAlgorithm(), responder.signatureBindingId(),
|
||||
responder.responderIdForm(), produced, produced, produced.plus(responder.responseValidity()),
|
||||
parsed.nonce(), parsed.requests());
|
||||
ServerRuntime.Submitted<OcspResponseService.Response> submitted = runtime.submit(cancellation -> {
|
||||
cancellation.throwIfCancelled();
|
||||
return realm.session().ocsp().orElseThrow().respond(command);
|
||||
});
|
||||
try {
|
||||
return submitted.future().get(configuration.maximumStreamDuration().toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException failure) {
|
||||
submitted.cancellation().cancel(); submitted.future().cancel(true); Thread.currentThread().interrupt();
|
||||
throw new TimeoutException("OCSP operation interrupted");
|
||||
} catch (ExecutionException failure) {
|
||||
if (failure.getCause() instanceof RuntimeException runtimeFailure) throw runtimeFailure;
|
||||
throw new IllegalStateException("OCSP operation failed");
|
||||
} finally { submitted.finish(); }
|
||||
}
|
||||
|
||||
private Instant producedAt(OcspResponderService.Responder responder, boolean nonce) {
|
||||
Instant now = clock.instant().truncatedTo(ChronoUnit.SECONDS);
|
||||
if (nonce) {
|
||||
return now;
|
||||
}
|
||||
long seconds = Math.max(1L, responder.cacheLifetime().toSeconds());
|
||||
long bucket = Math.multiplyExact(Math.floorDiv(now.getEpochSecond(), seconds), seconds);
|
||||
Instant produced = Instant.ofEpochSecond(bucket);
|
||||
try (PkiRepositoryContent content = realm.session().repository()
|
||||
.openCredential(responder.responderCredentialId()); InputStream input = content.openStream()) {
|
||||
byte[] encoded = input.readNBytes(1_048_577);
|
||||
if (encoded.length > 1_048_576 || input.read() != -1) {
|
||||
throw new IllegalStateException("OCSP responder certificate exceeds its finite bound");
|
||||
}
|
||||
Instant notBefore = new org.bouncycastle.cert.X509CertificateHolder(encoded)
|
||||
.getNotBefore().toInstant();
|
||||
return produced.isBefore(notBefore) ? notBefore : produced;
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("OCSP responder certificate is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateTransport(HttpExchange exchange, String requestId) {
|
||||
Map<String, List<String>> headers = Map.copyOf(exchange.getRequestHeaders());
|
||||
boolean forwarded = ForwardedClientCertificateParser.containsForwardedIdentity(headers);
|
||||
if (configuration.authentication().mode() == AdministrativeAuthenticationMode.DIRECT_MTLS) {
|
||||
if (forwarded) throw new IllegalArgumentException("Forwarded identity is prohibited");
|
||||
return;
|
||||
}
|
||||
Optional<PkiServerAuthenticationContext> context = tlsContext(exchange, requestId, headers);
|
||||
if (context.isEmpty() || authenticator.authenticatePublicProxyTransport(context.orElseThrow()).isEmpty()) {
|
||||
throw new IllegalArgumentException("Proxy transport is unauthenticated");
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<PkiServerAuthenticationContext> tlsContext(HttpExchange exchange, String requestId,
|
||||
Map<String, List<String>> headers) {
|
||||
if (!(exchange instanceof HttpsExchange https)) return Optional.empty();
|
||||
try {
|
||||
Certificate[] peers = https.getSSLSession().getPeerCertificates(); List<X509Certificate> chain = new ArrayList<>();
|
||||
for (Certificate peer : peers) { if (!(peer instanceof X509Certificate certificate)) return Optional.empty(); chain.add(certificate); }
|
||||
return Optional.of(new PkiServerAuthenticationContext(chain, https.getSSLSession().getProtocol(),
|
||||
https.getSSLSession().getCipherSuite(), requestId, realm.configuration().realmId(), headers));
|
||||
} catch (SSLPeerUnverifiedException failure) { return Optional.empty(); }
|
||||
}
|
||||
|
||||
private Route route(HttpExchange exchange) {
|
||||
String path = exchange.getRequestURI().getRawPath();
|
||||
if (exchange.getRequestURI().getRawQuery() != null || path.indexOf('%') >= 0) throw new IllegalArgumentException();
|
||||
String[] part = path.split("/", -1);
|
||||
if (part.length != 3 && part.length != 4 || !"".equals(part[0]) || !"ocsp".equals(part[1])
|
||||
|| !part[2].matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) throw new UnknownAlias();
|
||||
if (part.length == 3) {
|
||||
if (!"POST".equals(exchange.getRequestMethod())) throw new MethodFailure();
|
||||
requireAccept(exchange.getRequestHeaders());
|
||||
List<String> type = exchange.getRequestHeaders().get("Content-Type");
|
||||
if (type == null || type.size() != 1 || !MEDIA_REQUEST.equalsIgnoreCase(type.getFirst())) throw new IllegalArgumentException();
|
||||
requireBodyFraming(exchange.getRequestHeaders());
|
||||
return new Route(part[2], Optional.empty());
|
||||
}
|
||||
if (!"GET".equals(exchange.getRequestMethod())) throw new MethodFailure();
|
||||
requireAccept(exchange.getRequestHeaders());
|
||||
return new Route(part[2], Optional.of(OcspRequestParser.decodeGet(part[3], configuration.maximumBodyBytes())));
|
||||
}
|
||||
|
||||
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("OCSP request headers are oversized");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireAccept(Headers headers) {
|
||||
List<String> accept = headers.get("Accept");
|
||||
if (accept != null && (accept.size() != 1 || !("*/*".equals(accept.getFirst())
|
||||
|| MEDIA_RESPONSE.equalsIgnoreCase(accept.getFirst())))) {
|
||||
throw new MediaFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireBodyFraming(Headers headers) {
|
||||
if (headers.containsKey("Transfer-Encoding")) throw new IllegalArgumentException("OCSP framing is invalid");
|
||||
List<String> length = headers.get("Content-Length");
|
||||
if (length != null) {
|
||||
if (length.size() != 1 || !length.getFirst().matches("[0-9]{1,10}")) {
|
||||
throw new IllegalArgumentException("OCSP content length is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] read(HttpExchange exchange, int maximum) {
|
||||
try (InputStream input = exchange.getRequestBody(); ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(maximum, 16_384))) {
|
||||
byte[] buffer = new byte[8192]; int total = 0;
|
||||
while (true) { int count = input.read(buffer); if (count < 0) break; if (count == 0) throw new IOException("No progress");
|
||||
total = Math.addExact(total, count); if (total > maximum) throw new IllegalArgumentException(); output.write(buffer, 0, count); }
|
||||
return output.toByteArray();
|
||||
} catch (IOException failure) { throw new IllegalArgumentException("OCSP request body is invalid"); }
|
||||
}
|
||||
|
||||
private static void validateNonce(OcspResponderService.NoncePolicy policy, Optional<byte[]> nonce) {
|
||||
if (policy == OcspResponderService.NoncePolicy.REJECT && nonce.isPresent()
|
||||
|| policy == OcspResponderService.NoncePolicy.REQUIRED && nonce.isEmpty()) throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
private void send(HttpExchange exchange, OcspResponderService.Responder responder,
|
||||
OcspResponseService.Response response, boolean nonce, String requestId) throws IOException {
|
||||
Headers headers = exchange.getResponseHeaders(); headers.set("Content-Type", MEDIA_RESPONSE);
|
||||
headers.set("X-Content-Type-Options", "nosniff"); headers.set(RequestIds.HEADER, requestId);
|
||||
if (nonce) { headers.set("Cache-Control", "no-store"); }
|
||||
else {
|
||||
long seconds = Math.min(responder.cacheLifetime().toSeconds(), responder.responseValidity().toSeconds());
|
||||
String validator = etag(response.der());
|
||||
headers.set("Cache-Control", "public, max-age=" + seconds);
|
||||
headers.set("Expires", java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format(
|
||||
java.time.ZonedDateTime.ofInstant(clock.instant().plusSeconds(seconds), java.time.ZoneOffset.UTC)));
|
||||
headers.set("ETag", validator);
|
||||
List<String> conditional = exchange.getRequestHeaders().get("If-None-Match");
|
||||
if (conditional != null && conditional.size() == 1 && validator.equals(conditional.getFirst())) {
|
||||
exchange.sendResponseHeaders(304, -1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
exchange.sendResponseHeaders(200, response.der().length);
|
||||
try (OutputStream output = exchange.getResponseBody()) { output.write(response.der()); }
|
||||
}
|
||||
|
||||
private static void protocolFailure(HttpExchange exchange, int status) throws IOException {
|
||||
byte[] body;
|
||||
try { body = new org.bouncycastle.cert.ocsp.OCSPRespBuilder().build(status, null).getEncoded(); }
|
||||
catch (org.bouncycastle.cert.ocsp.OCSPException impossible) { throw new IOException("OCSP failure encoding failed"); }
|
||||
exchange.getResponseHeaders().set("Content-Type", MEDIA_RESPONSE); exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.sendResponseHeaders(200, body.length); try (OutputStream output = exchange.getResponseBody()) { output.write(body); }
|
||||
}
|
||||
private static void transportFailure(HttpExchange exchange, int status) throws IOException {
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store"); exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
|
||||
exchange.sendResponseHeaders(status, -1);
|
||||
}
|
||||
private void audit(String requestId, Optional<OcspResponderService.Responder> responder,
|
||||
String classification) {
|
||||
Map<String, String> details = new java.util.LinkedHashMap<>();
|
||||
details.put("request", requestId); details.put("classification", classification);
|
||||
responder.ifPresent(value -> details.put("responder", value.responderId()));
|
||||
realm.auditTransport("OCSP_REQUEST", "anonymous", Map.copyOf(details));
|
||||
}
|
||||
private static String etag(byte[] value) {
|
||||
try { return '"' + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)) + '"'; }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); }
|
||||
}
|
||||
private record Route(String alias, Optional<byte[]> encoded) { Route { encoded = encoded.map(byte[]::clone); } }
|
||||
private static final class UnknownAlias extends RuntimeException { private static final long serialVersionUID = 1L; }
|
||||
private static final class InactiveResponder extends RuntimeException { private static final long serialVersionUID = 1L; }
|
||||
private static final class MethodFailure extends RuntimeException { private static final long serialVersionUID = 1L; }
|
||||
private static final class MediaFailure extends RuntimeException { private static final long serialVersionUID = 1L; }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*******************************************************************************
|
||||
* 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.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.bouncycastle.asn1.ASN1OctetString;
|
||||
import org.bouncycastle.asn1.DERNull;
|
||||
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers;
|
||||
import org.bouncycastle.cert.ocsp.CertificateID;
|
||||
import org.bouncycastle.cert.ocsp.OCSPReq;
|
||||
import org.bouncycastle.cert.ocsp.Req;
|
||||
|
||||
import zeroecho.pki.application.OcspResponseService;
|
||||
|
||||
/** Narrow canonical DER and unpadded Base64url OCSP request decoder. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.ExceptionAsFlowControl", "PMD.PreserveStackTrace",
|
||||
"PMD.AvoidCatchingGenericException", "PMD.CyclomaticComplexity" })
|
||||
final class OcspRequestParser {
|
||||
/* default */ record Parsed(List<OcspResponseService.CertId> requests, Optional<byte[]> nonce) {
|
||||
Parsed { requests = List.copyOf(requests); nonce = nonce.map(byte[]::clone); }
|
||||
@Override public Optional<byte[]> nonce() { return nonce.map(byte[]::clone); }
|
||||
}
|
||||
|
||||
private OcspRequestParser() { }
|
||||
|
||||
/* default */ static byte[] decodeGet(String encoded, int maximumBytes) {
|
||||
if (encoded.isEmpty() || encoded.length() > Math.addExact(maximumBytes * 2, 8)
|
||||
|| !encoded.matches("[A-Za-z0-9_-]+") || encoded.indexOf('=') >= 0) throw malformed();
|
||||
try {
|
||||
byte[] decoded = Base64.getUrlDecoder().decode(encoded);
|
||||
if (decoded.length > maximumBytes || !Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(decoded).equals(encoded)) throw malformed();
|
||||
return decoded;
|
||||
} catch (IllegalArgumentException failure) { throw malformed(); }
|
||||
}
|
||||
|
||||
/* default */ static Parsed parse(byte[] der, int maximumEntries,
|
||||
Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumNonceBytes) {
|
||||
try {
|
||||
OCSPReq request = new OCSPReq(der);
|
||||
if (!Arrays.equals(der, request.getEncoded()) || request.isSigned()) throw malformed();
|
||||
Req[] entries = request.getRequestList();
|
||||
if (entries.length == 0 || entries.length > maximumEntries) throw malformed();
|
||||
List<OcspResponseService.CertId> result = new ArrayList<>(entries.length);
|
||||
for (Req entry : entries) {
|
||||
CertificateID id = entry.getCertID();
|
||||
org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm = id.toASN1Primitive().getHashAlgorithm();
|
||||
OcspResponseService.CertIdHash hash;
|
||||
if (OIWObjectIdentifiers.idSHA1.equals(id.getHashAlgOID())
|
||||
&& DERNull.INSTANCE.equals(algorithm.getParameters())) {
|
||||
hash = OcspResponseService.CertIdHash.SHA1;
|
||||
} else if (NISTObjectIdentifiers.id_sha256.equals(id.getHashAlgOID())
|
||||
&& algorithm.getParameters() == null) {
|
||||
hash = OcspResponseService.CertIdHash.SHA256;
|
||||
}
|
||||
else throw malformed();
|
||||
if (!acceptedHashes.contains(hash)) throw malformed();
|
||||
result.add(new OcspResponseService.CertId(hash, id.getIssuerNameHash(),
|
||||
id.getIssuerKeyHash(), id.getSerialNumber()));
|
||||
}
|
||||
List<?> extensionIds = request.getExtensionOIDs();
|
||||
if (extensionIds.size() > 1 || extensionIds.stream().anyMatch(
|
||||
oid -> !OCSPObjectIdentifiers.id_pkix_ocsp_nonce.equals(oid))) throw malformed();
|
||||
Optional<byte[]> nonce = Optional.empty();
|
||||
if (!extensionIds.isEmpty()) {
|
||||
org.bouncycastle.asn1.x509.Extension extension = request.getExtension(
|
||||
OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
|
||||
if (extension == null || extension.isCritical()) throw malformed();
|
||||
byte[] value = ASN1OctetString.getInstance(extension.getParsedValue()).getOctets();
|
||||
if (value.length == 0 || value.length > maximumNonceBytes) throw malformed();
|
||||
nonce = Optional.of(value);
|
||||
}
|
||||
return new Parsed(result, nonce);
|
||||
} catch (IOException | RuntimeException failure) { throw malformed(); }
|
||||
}
|
||||
|
||||
private static IllegalArgumentException malformed() {
|
||||
return new IllegalArgumentException("OCSP request is malformed");
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,12 @@ import zeroecho.pki.server.ServerRealmContext;
|
||||
public final class PublicRepositoryTransport implements AutoCloseable {
|
||||
private final HttpServer listener;
|
||||
private final ServerRuntime runtime;
|
||||
private final ServerRuntime ocspRuntime;
|
||||
|
||||
private PublicRepositoryTransport(HttpServer listener, ServerRuntime runtime) {
|
||||
private PublicRepositoryTransport(HttpServer listener, ServerRuntime runtime, ServerRuntime ocspRuntime) {
|
||||
this.listener = listener;
|
||||
this.runtime = runtime;
|
||||
this.ocspRuntime = ocspRuntime;
|
||||
}
|
||||
|
||||
/** Starts one separately bounded public listener over the shared realm. */
|
||||
@@ -72,9 +74,11 @@ public final class PublicRepositoryTransport implements AutoCloseable {
|
||||
Objects.requireNonNull(realm, "realm");
|
||||
Objects.requireNonNull(authenticator, "authenticator");
|
||||
ServerRuntime runtime = null;
|
||||
ServerRuntime ocspRuntime = null;
|
||||
HttpServer listener = null;
|
||||
try {
|
||||
runtime = new ServerRuntime(configuration.execution(), true);
|
||||
ocspRuntime = new ServerRuntime(configuration.execution(), ServerRuntime.Lane.OCSP);
|
||||
if (configuration.tlsProvider().isPresent()) {
|
||||
SSLContext context = TlsProviders.create(configuration.tlsProvider().orElseThrow(), loader);
|
||||
HttpsServer secure = HttpsServer.create(configuration.socketAddress(),
|
||||
@@ -88,15 +92,17 @@ public final class PublicRepositoryTransport implements AutoCloseable {
|
||||
configuration.execution().transportQueueCapacity());
|
||||
}
|
||||
listener.setExecutor(runtime.transportExecutor());
|
||||
listener.createContext("/ocsp/", new OcspHttpHandler(configuration, realm, authenticator,
|
||||
ocspRuntime, clock, new RequestIds(random), ready));
|
||||
listener.createContext("/", new PublicRepositoryHttpHandler(configuration, realm, authenticator,
|
||||
runtime, clock, new RequestIds(random), ready));
|
||||
listener.start();
|
||||
return new PublicRepositoryTransport(listener, runtime);
|
||||
return new PublicRepositoryTransport(listener, runtime, ocspRuntime);
|
||||
} catch (IOException failure) {
|
||||
closePartial(listener, runtime);
|
||||
closePartial(listener, runtime, ocspRuntime);
|
||||
throw new IllegalStateException("Public repository listener initialization failed", failure);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
closePartial(listener, runtime);
|
||||
closePartial(listener, runtime, ocspRuntime);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
@@ -105,7 +111,7 @@ public final class PublicRepositoryTransport implements AutoCloseable {
|
||||
public InetSocketAddress address() { return listener.getAddress(); }
|
||||
|
||||
/** Prevents new public stream admission. */
|
||||
public void quiesce() { runtime.quiesce(); }
|
||||
public void quiesce() { runtime.quiesce(); ocspRuntime.quiesce(); }
|
||||
|
||||
/** Stops the listener and its independent bounded resources. */
|
||||
public void shutdown(Duration graceful) {
|
||||
@@ -113,6 +119,7 @@ public final class PublicRepositoryTransport implements AutoCloseable {
|
||||
int seconds = Math.toIntExact(Math.min(Integer.MAX_VALUE,
|
||||
Objects.requireNonNull(graceful, "graceful").toSeconds()));
|
||||
listener.stop(seconds);
|
||||
ocspRuntime.close();
|
||||
runtime.close();
|
||||
}
|
||||
|
||||
@@ -132,12 +139,15 @@ public final class PublicRepositoryTransport implements AutoCloseable {
|
||||
};
|
||||
}
|
||||
|
||||
private static void closePartial(HttpServer listener, ServerRuntime runtime) {
|
||||
private static void closePartial(HttpServer listener, ServerRuntime runtime, ServerRuntime ocspRuntime) {
|
||||
if (listener != null) {
|
||||
listener.stop(0);
|
||||
}
|
||||
if (runtime != null) {
|
||||
runtime.close();
|
||||
}
|
||||
if (ocspRuntime != null) {
|
||||
ocspRuntime.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ final class ServerRuntime implements AutoCloseable {
|
||||
static final String ACME_TRANSPORT_PREFIX = "zeroecho-pki-acme-https-";
|
||||
static final String ACME_PROTOCOL_PREFIX = "zeroecho-pki-acme-protocol-";
|
||||
static final String ACME_VALIDATION_PREFIX = "zeroecho-pki-acme-validation-";
|
||||
static final String OCSP_TRANSPORT_PREFIX = "zeroecho-pki-ocsp-https-";
|
||||
static final String OCSP_OPERATION_PREFIX = "zeroecho-pki-ocsp-response-";
|
||||
static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown";
|
||||
|
||||
private final ThreadPoolExecutor transport;
|
||||
@@ -81,10 +83,12 @@ final class ServerRuntime implements AutoCloseable {
|
||||
this.configuration = configuration;
|
||||
transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(),
|
||||
new NamedThreadFactory(lane == Lane.PUBLIC ? PUBLIC_TRANSPORT_PREFIX
|
||||
: lane == Lane.ACME ? ACME_TRANSPORT_PREFIX : TRANSPORT_PREFIX));
|
||||
: lane == Lane.ACME ? ACME_TRANSPORT_PREFIX
|
||||
: lane == Lane.OCSP ? OCSP_TRANSPORT_PREFIX : TRANSPORT_PREFIX));
|
||||
operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(),
|
||||
new NamedThreadFactory(lane == Lane.PUBLIC ? PUBLIC_STREAM_PREFIX
|
||||
: lane == Lane.ACME ? ACME_PROTOCOL_PREFIX : OPERATION_PREFIX));
|
||||
: lane == Lane.ACME ? ACME_PROTOCOL_PREFIX
|
||||
: lane == Lane.OCSP ? OCSP_OPERATION_PREFIX : OPERATION_PREFIX));
|
||||
admitted = new Semaphore(configuration.maximumAdmittedRequests(), true);
|
||||
}
|
||||
|
||||
@@ -150,9 +154,11 @@ final class ServerRuntime implements AutoCloseable {
|
||||
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_TRANSPORT_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(ACME_TRANSPORT_PREFIX);
|
||||
transportWorker = transportWorker || Thread.currentThread().getName().startsWith(OCSP_TRANSPORT_PREFIX);
|
||||
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_STREAM_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(ACME_PROTOCOL_PREFIX);
|
||||
operationWorker = operationWorker || Thread.currentThread().getName().startsWith(OCSP_OPERATION_PREFIX);
|
||||
if (!operationWorker) {
|
||||
await(operations, configuration.gracefulShutdown());
|
||||
}
|
||||
@@ -173,7 +179,7 @@ final class ServerRuntime implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Lane { ADMIN, PUBLIC, ACME }
|
||||
enum Lane { ADMIN, PUBLIC, ACME, OCSP }
|
||||
|
||||
/** One separately admitted bounded challenge-validation execution lane. */
|
||||
static final class ChallengeRuntime implements AutoCloseable {
|
||||
|
||||
@@ -35,10 +35,12 @@ package zeroecho.pki.server;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
@@ -65,12 +67,23 @@ import java.util.HexFormat;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.DEROctetString;
|
||||
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.Extensions;
|
||||
import org.bouncycastle.asn1.x509.ExtensionsGenerator;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.asn1.x509.GeneralNames;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
|
||||
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
|
||||
import org.bouncycastle.cert.ocsp.CertificateID;
|
||||
import org.bouncycastle.cert.ocsp.OCSPReqBuilder;
|
||||
import org.bouncycastle.cert.ocsp.OCSPResp;
|
||||
import org.bouncycastle.cert.ocsp.RevokedStatus;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -112,12 +125,13 @@ class AcmeEndToEndTest {
|
||||
System.out.println("completesRealHttpsAccountIssuanceRolloverRestartAndRevocation");
|
||||
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
|
||||
int acmePort = reservePort();
|
||||
int publicPort = reservePort();
|
||||
int challengePort = reservePort();
|
||||
KeyPair rootKey = rsa((byte) 21);
|
||||
KeyPair leafKey = rsa((byte) 22);
|
||||
KeyPair accountKey = ec((byte) 23);
|
||||
KeyPair replacementKey = ec((byte) 24);
|
||||
Fixture fixture = fixture(tls, acmePort, challengePort, rootKey);
|
||||
Fixture fixture = fixture(tls, acmePort, publicPort, challengePort, rootKey);
|
||||
seed(fixture);
|
||||
|
||||
URI directoryUri = URI.create("https://localhost:" + acmePort + "/acme/" + DIRECTORY_ALIAS
|
||||
@@ -181,6 +195,10 @@ class AcmeEndToEndTest {
|
||||
.toList());
|
||||
chain.get(0).verify(chain.get(1).getPublicKey());
|
||||
leafDer = chain.get(0).getEncoded();
|
||||
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), false);
|
||||
assertOcspNonce(wire, publicPort, chain.get(1), chain.get(0));
|
||||
assertOcspUnknownAndMulti(wire, publicPort, chain.get(1), chain.get(0));
|
||||
assertOcspNoncePolicies(wire, publicPort, chain.get(1), chain.get(0));
|
||||
|
||||
assertEquals(200, client.rollover(keyChange, replacementKey).status());
|
||||
assertEquals(200, client.postAsGet(accountUri).status());
|
||||
@@ -201,6 +219,8 @@ class AcmeEndToEndTest {
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(leafDer);
|
||||
assertEquals(200, replacement.kid(revoke,
|
||||
"{\"certificate\":\"" + encoded + "\",\"reason\":1}").status());
|
||||
List<X509Certificate> chain = certificates(replacement.postAsGet(certificateUri).bodyText());
|
||||
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true);
|
||||
assertFalse(replacement.kid(revoke,
|
||||
"{\"certificate\":\"" + encoded + "\",\"reason\":1}").status() == 200);
|
||||
System.out.println("...rollover-restart-revocation=true");
|
||||
@@ -218,9 +238,10 @@ class AcmeEndToEndTest {
|
||||
HttpServerTestSupport.PackagedTls tls = HttpServerTestSupport.packagedTls(root.resolve("tls"));
|
||||
int adminPort = reservePort();
|
||||
int acmePort = reservePort();
|
||||
int publicPort = reservePort();
|
||||
int challengePort = reservePort();
|
||||
KeyPair rootKey = rsa((byte) 41);
|
||||
Fixture fixture = packagedFixture(root, tls, adminPort, acmePort, challengePort, rootKey);
|
||||
Fixture fixture = packagedFixture(root, tls, adminPort, acmePort, publicPort, challengePort, rootKey);
|
||||
seed(fixture, false);
|
||||
Path configuration = root.resolve("server.json");
|
||||
java.nio.file.Files.writeString(configuration, packagedJson(fixture.configuration(), tls),
|
||||
@@ -271,10 +292,12 @@ class AcmeEndToEndTest {
|
||||
URI certificate = URI.create(AcmeTestClient.text(finalized, "certificate"));
|
||||
List<X509Certificate> chain = certificates(client.postAsGet(certificate).bodyText());
|
||||
assertEquals(2, chain.size());
|
||||
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), false);
|
||||
assertEquals(200, client.rollover(rollover, replacement).status());
|
||||
String der = Base64.getUrlEncoder().withoutPadding().encodeToString(chain.get(0).getEncoded());
|
||||
assertEquals(200, client.kid(revoke,
|
||||
"{\"certificate\":\"" + der + "\",\"reason\":1}").status());
|
||||
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true);
|
||||
}
|
||||
System.out.println("...installed-launcher-flow=true");
|
||||
} finally {
|
||||
@@ -304,8 +327,8 @@ class AcmeEndToEndTest {
|
||||
}
|
||||
}
|
||||
|
||||
private Fixture fixture(HttpServerTestSupport.Fixture tls, int acmePort, int challengePort, KeyPair rootKey)
|
||||
throws Exception {
|
||||
private Fixture fixture(HttpServerTestSupport.Fixture tls, int acmePort, int publicPort, int challengePort,
|
||||
KeyPair rootKey) throws Exception {
|
||||
Path root = temporaryDirectory.resolve("server");
|
||||
PkiServerConfiguration base = HttpServerTestSupport.configuration(root, tls.clientCertificate());
|
||||
java.nio.file.Files.setPosixFilePermissions(root, java.nio.file.attribute.PosixFilePermissions
|
||||
@@ -341,8 +364,13 @@ class AcmeEndToEndTest {
|
||||
URI.create("https://localhost:" + acmePort), 16_384, 1_048_576, lane, lane,
|
||||
Duration.ofMinutes(5), 256, 32, 32, Duration.ofMinutes(1), 32, 32, 16, 32, 2,
|
||||
List.of(http01), List.of(new ProviderConfig(TestAcmeEabProvider.ID, Map.of())));
|
||||
PkiServerConfiguration.PublicListener publicListener = new PkiServerConfiguration.PublicListener(
|
||||
InetAddress.getByName("127.0.0.1"), publicPort,
|
||||
Optional.of(new ProviderConfig("test-tls", Map.of())), false, base.authentication(),
|
||||
16_384, 65_536, lane, Duration.ofSeconds(20), Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30), true);
|
||||
PkiServerConfiguration configuration = new PkiServerConfiguration(base.version(), base.serverName(), realm,
|
||||
base.listener(), base.authentication(), base.execution(), base.runtime(), Optional.empty(),
|
||||
base.listener(), base.authentication(), base.execution(), base.runtime(), Optional.of(publicListener),
|
||||
Optional.of(acme));
|
||||
PkiSessionRuntimeDependencies dependencies = PkiSessionRuntimeDependencies.withKeyringUnlockProvider(
|
||||
() -> new KeyringPassword(KEYRING_PASSWORD.clone()));
|
||||
@@ -365,6 +393,24 @@ class AcmeEndToEndTest {
|
||||
PkiId authority = context.session().authorities().orElseThrow().createRoot(new CaCreateCommand(
|
||||
root.definition().formatId(), new SubjectRef("CN=ZeroEcho ACME E2E Root"), "root-ca",
|
||||
Optional.of(new KeyRef("acme-test:root.prv")), new SimpleAttributeSet()));
|
||||
zeroecho.pki.api.ca.CaRecord authorityRecord = context.session().repository().authority(authority)
|
||||
.orElseThrow();
|
||||
zeroecho.pki.api.ca.IssuerGeneration issuer = context.session().repository()
|
||||
.issuer(authorityRecord.currentIssuanceIssuerId()).orElseThrow();
|
||||
OcspResponderService.Responder responder = context.ocspResponders().create("ocsp-e2e-root", "root",
|
||||
authority, issuer.issuerId(), OcspResponderService.SigningMode.ISSUER_SIGNED,
|
||||
issuer.credentialId(), issuer.signingKeyRef(), authorityRecord.issuanceChainPathId(),
|
||||
"SHA256withRSA", Optional.empty(), context.session().algorithmBindings().commitment(),
|
||||
zeroecho.pki.application.OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5),
|
||||
OcspResponderService.NoncePolicy.OPTIONAL_ECHO, 64,
|
||||
Set.of(zeroecho.pki.application.OcspResponseService.CertIdHash.SHA1,
|
||||
zeroecho.pki.application.OcspResponseService.CertIdHash.SHA256),
|
||||
16_384, 8, Duration.ofMinutes(1));
|
||||
context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true);
|
||||
registerNonceResponder(context, responder, "ocsp-e2e-reject", "reject",
|
||||
OcspResponderService.NoncePolicy.REJECT);
|
||||
registerNonceResponder(context, responder, "ocsp-e2e-required", "required",
|
||||
OcspResponderService.NoncePolicy.REQUIRED);
|
||||
ActiveCertificateProfile active = context.session().profiles().requireActiveProfile("server-tls");
|
||||
AcmeService service = new AcmeService(context, ServerTestSupport.CLOCK, HttpServerTestSupport.random(),
|
||||
Map.of(Http01ChallengeProvider.ID, new Http01ChallengeProvider()),
|
||||
@@ -389,13 +435,26 @@ class AcmeEndToEndTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerNonceResponder(ServerRealmContext context,
|
||||
OcspResponderService.Responder template, String responderId, String alias,
|
||||
OcspResponderService.NoncePolicy noncePolicy) {
|
||||
OcspResponderService.Responder responder = context.ocspResponders().create(responderId, alias,
|
||||
template.authorityId(), template.issuerId(), template.signingMode(),
|
||||
template.responderCredentialId(), template.signingKeyRef(), template.chainPathId(),
|
||||
template.signatureAlgorithm(), template.signatureBindingId(),
|
||||
template.signatureBindingCommitment(), template.responderIdForm(), template.responseValidity(),
|
||||
noncePolicy, template.maximumNonceBytes(), template.acceptedHashes(),
|
||||
template.maximumRequestBytes(), template.maximumEntries(), template.cacheLifetime());
|
||||
context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true);
|
||||
}
|
||||
|
||||
private PkiHttpsServer start(Fixture fixture) throws Exception {
|
||||
return PkiHttpsServer.start(fixture.configuration(), fixture.dependencies(), ServerTestSupport.CLOCK,
|
||||
HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader());
|
||||
}
|
||||
|
||||
private Fixture packagedFixture(Path root, HttpServerTestSupport.PackagedTls tls, int adminPort, int acmePort,
|
||||
int challengePort, KeyPair rootKey) throws Exception {
|
||||
int publicPort, int challengePort, KeyPair rootKey) throws Exception {
|
||||
Path keyring = root.resolve("signing-keyring.zek");
|
||||
try (KeyringPassword password = new KeyringPassword(KEYRING_PASSWORD.clone());
|
||||
KeyringStore store = KeyringStore.create(keyring, password)) {
|
||||
@@ -439,11 +498,16 @@ class AcmeEndToEndTest {
|
||||
URI.create("https://localhost:" + acmePort), 16_384, 1_048_576, lane, lane,
|
||||
Duration.ofMinutes(5), 256, 32, 32, Duration.ofMinutes(1), 32, 32, 16, 32, 2,
|
||||
List.of(http01), List.of());
|
||||
PkiServerConfiguration configuration = new PkiServerConfiguration(4, "packaged-acme", realm,
|
||||
PkiServerConfiguration.PublicListener publicListener = new PkiServerConfiguration.PublicListener(
|
||||
InetAddress.getByName("127.0.0.1"), publicPort, Optional.of(tlsProvider), false,
|
||||
authentication, 16_384, 65_536, lane, Duration.ofSeconds(20), Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30), true);
|
||||
PkiServerConfiguration configuration = new PkiServerConfiguration(PkiServerConfiguration.CURRENT_VERSION,
|
||||
"packaged-acme", realm,
|
||||
new PkiServerConfiguration.Listener(InetAddress.getByName("127.0.0.1"), adminPort, tlsProvider,
|
||||
true, 16_384, 1_048_576), authentication, lane,
|
||||
new PkiServerConfiguration.RuntimeCapabilities(Optional.of("ZEROECHO_TEST_KEYRING_PASSWORD")),
|
||||
Optional.empty(), Optional.of(acme));
|
||||
Optional.of(publicListener), Optional.of(acme));
|
||||
return new Fixture(configuration, PkiSessionRuntimeDependencies.withKeyringUnlockProvider(
|
||||
() -> new KeyringPassword(KEYRING_PASSWORD.clone())), http01);
|
||||
}
|
||||
@@ -464,7 +528,14 @@ class AcmeEndToEndTest {
|
||||
String workflow = providerJson(signing.workflow());
|
||||
String store = providerJson(realm.pkiSessionConfiguration().store());
|
||||
String audit = providerJson(realm.pkiSessionConfiguration().audit());
|
||||
return "{\"version\":4,\"serverName\":\"packaged-acme\",\"realm\":{"
|
||||
String authenticationJson = "{\"mode\":\"DIRECT_MTLS\",\"directClientMappings\":[{"
|
||||
+ "\"mappingId\":\"packaged-admin\",\"principalId\":\"administrator\","
|
||||
+ "\"certificateSha256\":\""
|
||||
+ configuration.authentication().directClientMappings().get(0).certificateSha256().orElseThrow()
|
||||
+ "\"}]}";
|
||||
PkiServerConfiguration.PublicListener publicListener = configuration.publicListener().orElseThrow();
|
||||
return "{\"version\":" + PkiServerConfiguration.CURRENT_VERSION
|
||||
+ ",\"serverName\":\"packaged-acme\",\"realm\":{"
|
||||
+ "\"realmId\":\"production\",\"displayName\":\"Packaged ACME\","
|
||||
+ "\"authorityExposure\":{\"mode\":\"ALL_REALM_AUTHORITIES\",\"authorityIds\":[],"
|
||||
+ "\"creationPermitted\":true},\"authorizationCommitment\":\"" + realm.authorizationCommitment()
|
||||
@@ -485,11 +556,14 @@ class AcmeEndToEndTest {
|
||||
+ "\"publishers\":[],\"bindingProviders\":[]}},\"listener\":{\"address\":\"127.0.0.1\","
|
||||
+ "\"port\":" + configuration.listener().port() + ",\"tlsProvider\":" + tlsJson
|
||||
+ ",\"clientCertificateRequired\":true,\"maximumHeaderBytes\":16384,"
|
||||
+ "\"maximumBodyBytes\":1048576},\"authentication\":{\"mode\":\"DIRECT_MTLS\","
|
||||
+ "\"directClientMappings\":[{\"mappingId\":\"packaged-admin\","
|
||||
+ "\"principalId\":\"administrator\",\"certificateSha256\":\""
|
||||
+ configuration.authentication().directClientMappings().get(0).certificateSha256().orElseThrow()
|
||||
+ "\"}]},\"execution\":" + executionJson() + ",\"publicListener\":{\"enabled\":false},"
|
||||
+ "\"maximumBodyBytes\":1048576},\"authentication\":" + authenticationJson
|
||||
+ ",\"execution\":" + executionJson() + ",\"publicListener\":{\"enabled\":true,"
|
||||
+ "\"address\":\"127.0.0.1\",\"port\":" + publicListener.port()
|
||||
+ ",\"tlsProvider\":" + tlsJson + ",\"allowPlaintextLoopback\":false,"
|
||||
+ "\"authentication\":" + authenticationJson
|
||||
+ ",\"maximumHeaderBytes\":16384,\"maximumBodyBytes\":65536,\"execution\":" + executionJson()
|
||||
+ ",\"maximumStreamDurationMillis\":20000,\"publicImmutableCacheMillis\":300000,"
|
||||
+ "\"publicAliasCacheMillis\":30000,\"authorityListExposed\":true},"
|
||||
+ "\"acmeListener\":{\"enabled\":true,\"listenerId\":\"packaged-acme\","
|
||||
+ "\"address\":\"127.0.0.1\",\"port\":" + configuration.acmeListener().orElseThrow().port()
|
||||
+ ",\"tlsProvider\":" + tlsJson + ",\"transportMode\":\"DIRECT_TLS\","
|
||||
@@ -576,6 +650,135 @@ class AcmeEndToEndTest {
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static void assertOcsp(HttpClient client, int publicPort, X509Certificate issuer,
|
||||
X509Certificate certificate, boolean revoked) throws Exception {
|
||||
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
|
||||
org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get(
|
||||
new org.bouncycastle.asn1.x509.AlgorithmIdentifier(
|
||||
org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256));
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber()));
|
||||
byte[] request = builder.build().getEncoded();
|
||||
URI endpoint = URI.create("https://localhost:" + publicPort + "/ocsp/root");
|
||||
java.net.http.HttpRequest wireRequest;
|
||||
if (revoked) {
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request);
|
||||
wireRequest = java.net.http.HttpRequest.newBuilder(URI.create(endpoint + "/" + encoded))
|
||||
.header("Accept", "application/ocsp-response").GET().build();
|
||||
} else {
|
||||
wireRequest = java.net.http.HttpRequest.newBuilder(endpoint)
|
||||
.header("Accept", "application/ocsp-response")
|
||||
.header("Content-Type", "application/ocsp-request")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(request)).build();
|
||||
}
|
||||
java.net.http.HttpResponse<byte[]> response = client.send(wireRequest,
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("application/ocsp-response", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
String etag = response.headers().firstValue("ETag").orElseThrow();
|
||||
OCSPResp parsed = new OCSPResp(response.body());
|
||||
assertEquals(0, parsed.getStatus());
|
||||
BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class, parsed.getResponseObject());
|
||||
assertEquals(1, basic.getResponses().length);
|
||||
assertTrue(basic.isSignatureValid(new JcaContentVerifierProviderBuilder().build(issuerHolder)));
|
||||
assertTrue(basic.getResponses()[0].getThisUpdate() != null);
|
||||
assertTrue(basic.getResponses()[0].getNextUpdate() != null);
|
||||
if (revoked) {
|
||||
assertInstanceOf(RevokedStatus.class, basic.getResponses()[0].getCertStatus());
|
||||
} else {
|
||||
assertEquals(null, basic.getResponses()[0].getCertStatus());
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request);
|
||||
java.net.http.HttpResponse<byte[]> conditional = client.send(java.net.http.HttpRequest.newBuilder(
|
||||
URI.create(endpoint + "/" + encoded)).header("Accept", "application/ocsp-response")
|
||||
.header("If-None-Match", etag).GET().build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(304, conditional.statusCode());
|
||||
assertEquals(0, conditional.body().length);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertOcspNonce(HttpClient client, int publicPort, X509Certificate issuer,
|
||||
X509Certificate certificate) throws Exception {
|
||||
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
|
||||
org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build()
|
||||
.get(CertificateID.HASH_SHA1);
|
||||
byte[] nonce = new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 };
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber()));
|
||||
builder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce,
|
||||
false, new DEROctetString(new DEROctetString(nonce).getEncoded()))));
|
||||
java.net.http.HttpResponse<byte[]> response = client.send(java.net.http.HttpRequest.newBuilder(
|
||||
URI.create("https://localhost:" + publicPort + "/ocsp/root"))
|
||||
.header("Accept", "application/ocsp-response")
|
||||
.header("Content-Type", "application/ocsp-request")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(builder.build().getEncoded())).build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
|
||||
assertTrue(response.headers().firstValue("ETag").isEmpty());
|
||||
BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class,
|
||||
new OCSPResp(response.body()).getResponseObject());
|
||||
Extension echoed = basic.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
|
||||
assertTrue(echoed != null);
|
||||
assertEquals(java.util.HexFormat.of().formatHex(nonce), java.util.HexFormat.of().formatHex(
|
||||
org.bouncycastle.asn1.ASN1OctetString.getInstance(echoed.getParsedValue()).getOctets()));
|
||||
}
|
||||
|
||||
private static void assertOcspUnknownAndMulti(HttpClient client, int publicPort, X509Certificate issuer,
|
||||
X509Certificate certificate) throws Exception {
|
||||
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
|
||||
org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build()
|
||||
.get(CertificateID.HASH_SHA1);
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber()));
|
||||
digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1);
|
||||
builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber().add(BigInteger.ONE)));
|
||||
java.net.http.HttpResponse<byte[]> response = client.send(java.net.http.HttpRequest.newBuilder(
|
||||
URI.create("https://localhost:" + publicPort + "/ocsp/root"))
|
||||
.header("Accept", "application/ocsp-response").header("Content-Type", "application/ocsp-request")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(builder.build().getEncoded())).build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class,
|
||||
new OCSPResp(response.body()).getResponseObject());
|
||||
assertEquals(2, basic.getResponses().length);
|
||||
assertEquals(null, basic.getResponses()[0].getCertStatus());
|
||||
assertInstanceOf(org.bouncycastle.cert.ocsp.UnknownStatus.class,
|
||||
basic.getResponses()[1].getCertStatus());
|
||||
}
|
||||
|
||||
private static void assertOcspNoncePolicies(HttpClient client, int publicPort, X509Certificate issuer,
|
||||
X509Certificate certificate) throws Exception {
|
||||
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
|
||||
byte[] nonce = new byte[] { 3, 1, 4, 1, 5, 9, 2, 6 };
|
||||
OCSPReqBuilder nonceBuilder = new OCSPReqBuilder();
|
||||
nonceBuilder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
|
||||
.get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber()));
|
||||
nonceBuilder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce,
|
||||
false, new DEROctetString(new DEROctetString(nonce).getEncoded()))));
|
||||
byte[] withNonce = nonceBuilder.build().getEncoded();
|
||||
OCSPReqBuilder plainBuilder = new OCSPReqBuilder();
|
||||
plainBuilder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
|
||||
.get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber()));
|
||||
byte[] withoutNonce = plainBuilder.build().getEncoded();
|
||||
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST,
|
||||
postOcsp(client, publicPort, "reject", withNonce).getStatus());
|
||||
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST,
|
||||
postOcsp(client, publicPort, "required", withoutNonce).getStatus());
|
||||
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL,
|
||||
postOcsp(client, publicPort, "required", withNonce).getStatus());
|
||||
}
|
||||
|
||||
private static OCSPResp postOcsp(HttpClient client, int publicPort, String alias, byte[] request)
|
||||
throws Exception {
|
||||
java.net.http.HttpResponse<byte[]> response = client.send(java.net.http.HttpRequest.newBuilder(
|
||||
URI.create("https://localhost:" + publicPort + "/ocsp/" + alias))
|
||||
.header("Accept", "application/ocsp-response").header("Content-Type", "application/ocsp-request")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(request)).build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(200, response.statusCode());
|
||||
return new OCSPResp(response.body());
|
||||
}
|
||||
|
||||
private static KeyPair rsa(byte seed) throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048, random(seed));
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/*******************************************************************************
|
||||
* 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 static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.atLeast;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.ca.IssuerGenerationState;
|
||||
import zeroecho.pki.application.OcspResponseService;
|
||||
import zeroecho.pki.application.PkiRepository;
|
||||
import zeroecho.pki.application.PkiRepositoryContent;
|
||||
import zeroecho.pki.impl.fs.PosixTransactionalMetadataStore;
|
||||
|
||||
/** Durable responder binding, activation, recovery and dependency-failure coverage. */
|
||||
class OcspResponderServiceTest {
|
||||
@TempDir Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment() throws Exception {
|
||||
System.out.println("persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment");
|
||||
PkiId credentialId = new PkiId("credential:ocsp-root");
|
||||
PkiId issuerId = IssuerGeneration.idFor(ServerTestSupport.AUTHORITY, credentialId);
|
||||
KeyRef keyRef = new KeyRef("kref:v1:keyring:ocsp-root");
|
||||
IssuerGeneration issuer = new IssuerGeneration(issuerId, ServerTestSupport.AUTHORITY, credentialId,
|
||||
keyRef, IssuerGenerationState.ACTIVE, ServerTestSupport.DIGEST, ServerTestSupport.DIGEST);
|
||||
IssuerChainPath path = IssuerChainPath.create(ServerTestSupport.AUTHORITY, issuerId,
|
||||
java.util.List.of(credentialId));
|
||||
PkiRepository repository = mock(PkiRepository.class);
|
||||
when(repository.issuer(issuerId)).thenReturn(Optional.of(issuer));
|
||||
when(repository.chainPath(path.pathId())).thenReturn(Optional.of(path));
|
||||
byte[] certificate = certificate();
|
||||
when(repository.openCredential(credentialId)).thenAnswer(ignored -> content(certificate));
|
||||
X509AlgorithmBindingRegistry bindings = mock(X509AlgorithmBindingRegistry.class);
|
||||
when(bindings.commitment()).thenReturn(ServerTestSupport.DIGEST);
|
||||
OcspResponseService signing = mock(OcspResponseService.class);
|
||||
|
||||
Path log;
|
||||
String responderId;
|
||||
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
|
||||
log = opened.log();
|
||||
OcspResponderService service = new OcspResponderService(ServerTestSupport.REALM, opened.store(),
|
||||
repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK);
|
||||
OcspResponderService.Registration registration = new OcspResponderService.Registration(
|
||||
"ocsp-root", "root", ServerTestSupport.AUTHORITY,
|
||||
issuerId, OcspResponderService.SigningMode.ISSUER_SIGNED, credentialId, keyRef, path.pathId(),
|
||||
"SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST,
|
||||
OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5),
|
||||
OcspResponderService.NoncePolicy.OPTIONAL_ECHO, 64,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1, OcspResponseService.CertIdHash.SHA256),
|
||||
16_384, 8, Duration.ofMinutes(1));
|
||||
responderId = service.register(registration).responderId();
|
||||
assertThrows(IllegalStateException.class, () -> service.requireActiveAlias("root"));
|
||||
OcspResponderService.Registration duplicateAlias = new OcspResponderService.Registration(
|
||||
"ocsp-root-duplicate", "root", ServerTestSupport.AUTHORITY, issuerId,
|
||||
OcspResponderService.SigningMode.ISSUER_SIGNED, credentialId, keyRef, path.pathId(),
|
||||
"SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST,
|
||||
OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5),
|
||||
OcspResponderService.NoncePolicy.REJECT, 64,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 16_384, 8, Duration.ofMinutes(1));
|
||||
assertThrows(IllegalStateException.class, () -> service.register(duplicateAlias));
|
||||
assertEquals(OcspResponderService.State.ACTIVE, service.setActive(responderId, true).state());
|
||||
assertEquals(responderId, service.requireActiveAlias("root").responderId());
|
||||
}
|
||||
|
||||
try (ServerControlStore reopened = new ServerControlStore(PosixTransactionalMetadataStore.open(log,
|
||||
OptionalLong.of(1_048_576)))) {
|
||||
OcspResponderService recovered = new OcspResponderService(ServerTestSupport.REALM, reopened,
|
||||
repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK);
|
||||
recovered.validateAll();
|
||||
assertEquals(responderId, recovered.requireActiveAlias("root").responderId());
|
||||
verify(signing, atLeast(3)).validateSigningBinding(credentialId, keyRef,
|
||||
"SHA256withRSA", Optional.empty());
|
||||
when(bindings.commitment()).thenReturn("f".repeat(64));
|
||||
assertThrows(IllegalStateException.class, recovered::validateAll);
|
||||
}
|
||||
System.out.println("...durable-recovery=true changed-binding-rejected=true");
|
||||
System.out.println("persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesDelegatedResponderIssuerEkuUsageAndSigningCapability() throws Exception {
|
||||
System.out.println("validatesDelegatedResponderIssuerEkuUsageAndSigningCapability");
|
||||
CertificatePair certificates = delegatedCertificates();
|
||||
PkiId issuerCredential = new PkiId("credential:delegated-issuer");
|
||||
PkiId responderCredential = new PkiId("credential:delegated-responder");
|
||||
PkiId issuerId = IssuerGeneration.idFor(ServerTestSupport.AUTHORITY, issuerCredential);
|
||||
IssuerGeneration issuer = new IssuerGeneration(issuerId, ServerTestSupport.AUTHORITY, issuerCredential,
|
||||
new KeyRef("kref:v1:keyring:delegated-issuer"), IssuerGenerationState.ACTIVE,
|
||||
ServerTestSupport.DIGEST, ServerTestSupport.DIGEST);
|
||||
IssuerChainPath path = IssuerChainPath.create(ServerTestSupport.AUTHORITY, issuerId,
|
||||
java.util.List.of(issuerCredential));
|
||||
PkiRepository repository = mock(PkiRepository.class);
|
||||
when(repository.issuer(issuerId)).thenReturn(Optional.of(issuer));
|
||||
when(repository.chainPath(path.pathId())).thenReturn(Optional.of(path));
|
||||
when(repository.openCredential(issuerCredential)).thenAnswer(ignored -> content(certificates.issuer()));
|
||||
when(repository.openCredential(responderCredential)).thenAnswer(
|
||||
ignored -> content(certificates.responder()));
|
||||
X509AlgorithmBindingRegistry bindings = mock(X509AlgorithmBindingRegistry.class);
|
||||
when(bindings.commitment()).thenReturn(ServerTestSupport.DIGEST);
|
||||
OcspResponseService signing = mock(OcspResponseService.class);
|
||||
KeyRef responderKey = new KeyRef("kref:v1:keyring:delegated-responder");
|
||||
Path delegatedDirectory = temporaryDirectory.resolve("delegated");
|
||||
Files.createDirectory(delegatedDirectory);
|
||||
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(delegatedDirectory)) {
|
||||
OcspResponderService service = new OcspResponderService(ServerTestSupport.REALM, opened.store(),
|
||||
repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK);
|
||||
OcspResponderService.Registration registration = new OcspResponderService.Registration(
|
||||
"ocsp-delegated", "delegated", ServerTestSupport.AUTHORITY, issuerId,
|
||||
OcspResponderService.SigningMode.DELEGATED_RESPONDER, responderCredential, responderKey,
|
||||
path.pathId(), "SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST,
|
||||
OcspResponseService.ResponderId.BY_NAME, Duration.ofMinutes(5),
|
||||
OcspResponderService.NoncePolicy.REJECT, 64,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA256), 16_384, 8, Duration.ofMinutes(1));
|
||||
OcspResponderService.Responder registered = service.register(registration);
|
||||
assertEquals(OcspResponderService.SigningMode.DELEGATED_RESPONDER, registered.signingMode());
|
||||
verify(signing).validateSigningBinding(responderCredential, responderKey,
|
||||
"SHA256withRSA", Optional.empty());
|
||||
}
|
||||
System.out.println("...delegated-eku-and-key-usage=true");
|
||||
System.out.println("validatesDelegatedResponderIssuerEkuUsageAndSigningCapability...ok");
|
||||
}
|
||||
|
||||
private static PkiRepositoryContent content(byte[] encoded) throws Exception {
|
||||
PkiRepositoryContent content = mock(PkiRepositoryContent.class);
|
||||
when(content.openStream()).thenAnswer(ignored -> new ByteArrayInputStream(encoded));
|
||||
return content;
|
||||
}
|
||||
|
||||
private static byte[] certificate() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair keys = generator.generateKeyPair();
|
||||
X500Name name = new X500Name("CN=OCSP Responder Root");
|
||||
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(name, BigInteger.ONE,
|
||||
Date.from(Instant.parse("2025-01-01T00:00:00Z")),
|
||||
Date.from(Instant.parse("2030-01-01T00:00:00Z")), name,
|
||||
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(keys.getPublic().getEncoded()));
|
||||
X509CertificateHolder certificate = builder.build(
|
||||
new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate()));
|
||||
return certificate.getEncoded();
|
||||
}
|
||||
|
||||
private static CertificatePair delegatedCertificates() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair issuerKeys = generator.generateKeyPair();
|
||||
KeyPair responderKeys = generator.generateKeyPair();
|
||||
X500Name issuerName = new X500Name("CN=Delegated OCSP Issuer");
|
||||
X509v3CertificateBuilder issuerBuilder = new X509v3CertificateBuilder(issuerName, BigInteger.ONE,
|
||||
Date.from(Instant.parse("2025-01-01T00:00:00Z")),
|
||||
Date.from(Instant.parse("2030-01-01T00:00:00Z")), issuerName,
|
||||
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(issuerKeys.getPublic().getEncoded()));
|
||||
X509CertificateHolder issuer = issuerBuilder.build(
|
||||
new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeys.getPrivate()));
|
||||
X509v3CertificateBuilder responderBuilder = new X509v3CertificateBuilder(issuerName, BigInteger.TWO,
|
||||
Date.from(Instant.parse("2025-01-01T00:00:00Z")),
|
||||
Date.from(Instant.parse("2030-01-01T00:00:00Z")), new X500Name("CN=Delegated OCSP Responder"),
|
||||
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(responderKeys.getPublic().getEncoded()));
|
||||
responderBuilder.addExtension(Extension.extendedKeyUsage, false,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning));
|
||||
responderBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature));
|
||||
X509CertificateHolder responder = responderBuilder.build(
|
||||
new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeys.getPrivate()));
|
||||
return new CertificatePair(issuer.getEncoded(), responder.getEncoded());
|
||||
}
|
||||
|
||||
private record CertificatePair(byte[] issuer, byte[] responder) {
|
||||
private CertificatePair { issuer = issuer.clone(); responder = responder.clone(); }
|
||||
@Override public byte[] issuer() { return issuer.clone(); }
|
||||
@Override public byte[] responder() { return responder.clone(); }
|
||||
}
|
||||
}
|
||||
@@ -63,15 +63,15 @@ class ServerControlOperationExecutorTest {
|
||||
void catalogContainsOneExplicitPairOfOperationFamilies() {
|
||||
System.out.println("catalogContainsOneExplicitPairOfOperationFamilies");
|
||||
OperationSecurityDescriptors catalog = new OperationSecurityDescriptors();
|
||||
assertEquals(62, catalog.descriptors().size());
|
||||
assertEquals(67, catalog.descriptors().size());
|
||||
assertEquals(16, catalog.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
|
||||
assertEquals(46, catalog.descriptors().values().stream()
|
||||
assertEquals(51, catalog.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION)
|
||||
.count());
|
||||
assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION,
|
||||
catalog.require(ServerControlOperation.IssueCapability.NAME).family());
|
||||
System.out.println("...control-operations=46");
|
||||
System.out.println("...control-operations=51");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class ServerOperationGatewayTest {
|
||||
void descriptorRegistryRejectsUnknownAndDuplicateOperations() {
|
||||
System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations");
|
||||
OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors();
|
||||
assertEquals(62, descriptors.descriptors().size());
|
||||
assertEquals(67, 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()));
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*******************************************************************************
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
|
||||
import org.bouncycastle.asn1.DEROctetString;
|
||||
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.Extensions;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.ocsp.CertificateID;
|
||||
import org.bouncycastle.cert.ocsp.OCSPReqBuilder;
|
||||
import org.bouncycastle.operator.DigestCalculator;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import zeroecho.pki.application.OcspResponseService;
|
||||
|
||||
/** Strict OCSP DER and GET request framing coverage. */
|
||||
class OcspRequestParserTest {
|
||||
@Test
|
||||
void parsesCanonicalSha1Sha256AndExactNonce() throws Exception {
|
||||
System.out.println("parsesCanonicalSha1Sha256AndExactNonce");
|
||||
X509CertificateHolder issuer = certificate();
|
||||
DigestCalculator sha1 = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1);
|
||||
DigestCalculator sha256 = new JcaDigestCalculatorProviderBuilder().build().get(
|
||||
new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256));
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(sha1, issuer, BigInteger.valueOf(17)));
|
||||
builder.addRequest(new CertificateID(sha256, issuer, BigInteger.valueOf(18)));
|
||||
byte[] nonce = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
builder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce,
|
||||
false, new DEROctetString(new DEROctetString(nonce).getEncoded()))));
|
||||
byte[] encoded = builder.build().getEncoded();
|
||||
|
||||
OcspRequestParser.Parsed parsed = OcspRequestParser.parse(encoded, 2,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1, OcspResponseService.CertIdHash.SHA256), 32);
|
||||
assertEquals(2, parsed.requests().size());
|
||||
assertEquals(OcspResponseService.CertIdHash.SHA1, parsed.requests().get(0).hash());
|
||||
assertEquals(OcspResponseService.CertIdHash.SHA256, parsed.requests().get(1).hash());
|
||||
assertArrayEquals(nonce, parsed.nonce().orElseThrow());
|
||||
String get = Base64.getUrlEncoder().withoutPadding().encodeToString(encoded);
|
||||
assertArrayEquals(encoded, OcspRequestParser.decodeGet(get, encoded.length));
|
||||
System.out.println("...entries=2 nonce=echoable");
|
||||
System.out.println("parsesCanonicalSha1Sha256AndExactNonce...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension() throws Exception {
|
||||
System.out.println("rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension");
|
||||
X509CertificateHolder issuer = certificate();
|
||||
DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1);
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuer, BigInteger.ONE));
|
||||
builder.setRequestExtensions(new Extensions(new Extension(
|
||||
new org.bouncycastle.asn1.ASN1ObjectIdentifier("1.3.6.1.4.1.55555.1"), true,
|
||||
new DEROctetString(new byte[] { 5 }))));
|
||||
byte[] critical = builder.build().getEncoded();
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(critical, 1,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(critical, 0,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
|
||||
String canonical = Base64.getUrlEncoder().withoutPadding().encodeToString(critical);
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.decodeGet(canonical + "=", 4096));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.decodeGet(canonical, 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(
|
||||
java.util.Arrays.copyOf(critical, critical.length + 1), 1,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
|
||||
System.out.println("...strict-get-and-der=true");
|
||||
System.out.println("rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority() throws Exception {
|
||||
System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority");
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair keys = generator.generateKeyPair();
|
||||
X509CertificateHolder issuer = certificate(keys);
|
||||
DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1);
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuer, BigInteger.ONE));
|
||||
builder.setRequestorName(new GeneralName(issuer.getSubject()));
|
||||
byte[] signed = builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate()),
|
||||
new X509CertificateHolder[] { issuer }).getEncoded();
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(signed, 1,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
|
||||
System.out.println("...signed-request-authority=false");
|
||||
System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority...ok");
|
||||
}
|
||||
|
||||
private static X509CertificateHolder certificate() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return certificate(generator.generateKeyPair());
|
||||
}
|
||||
|
||||
private static X509CertificateHolder certificate(KeyPair keys) throws Exception {
|
||||
X500Name name = new X500Name("CN=OCSP Parser Issuer");
|
||||
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(name, BigInteger.ONE,
|
||||
Date.from(Instant.parse("2025-01-01T00:00:00Z")),
|
||||
Date.from(Instant.parse("2030-01-01T00:00:00Z")), name,
|
||||
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(keys.getPublic().getEncoded()));
|
||||
return builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*******************************************************************************
|
||||
* 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.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.bouncycastle.asn1.DEROctetString;
|
||||
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.x509.CRLReason;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.Extensions;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
|
||||
import org.bouncycastle.cert.ocsp.BasicOCSPRespBuilder;
|
||||
import org.bouncycastle.cert.ocsp.CertificateID;
|
||||
import org.bouncycastle.cert.ocsp.CertificateStatus;
|
||||
import org.bouncycastle.cert.ocsp.OCSPResp;
|
||||
import org.bouncycastle.cert.ocsp.OCSPRespBuilder;
|
||||
import org.bouncycastle.cert.ocsp.SingleResp;
|
||||
import org.bouncycastle.cert.ocsp.RevokedStatus;
|
||||
import org.bouncycastle.cert.ocsp.RespID;
|
||||
import org.bouncycastle.cert.ocsp.UnknownStatus;
|
||||
import org.bouncycastle.operator.DigestCalculator;
|
||||
import org.bouncycastle.operator.DigestCalculatorProvider;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.api.revocation.RevocationState;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.impl.framework.x509.bc.PkiBusContentSigner;
|
||||
import zeroecho.pki.spi.store.PkiStore;
|
||||
import zeroecho.pki.spi.store.RevocationView;
|
||||
|
||||
/** Store-backed, signing-bus-confined OCSP response application service. */
|
||||
@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "PMD.CyclomaticComplexity",
|
||||
"PMD.PreserveStackTrace", "PMD.ExceptionAsFlowControl", "PMD.SignatureDeclareThrowsException",
|
||||
"PMD.NPathComplexity", "PMD.ControlStatementBraces", "PMD.UseVarargs" })
|
||||
final class DefaultOcspResponseService implements OcspResponseService {
|
||||
private final PkiStore store;
|
||||
private final PkiSigningBus bus;
|
||||
private final Duration signingTtl;
|
||||
private final Runnable requireOpen;
|
||||
|
||||
/* default */ DefaultOcspResponseService(PkiStore store, PkiSigningBus bus, Duration signingTtl,
|
||||
Runnable requireOpen) {
|
||||
this.store = Objects.requireNonNull(store, "store");
|
||||
this.bus = Objects.requireNonNull(bus, "bus");
|
||||
this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl");
|
||||
this.requireOpen = Objects.requireNonNull(requireOpen, "requireOpen");
|
||||
}
|
||||
|
||||
@Override public Response respond(Command command) {
|
||||
requireOpen.run();
|
||||
Objects.requireNonNull(command, "command");
|
||||
try {
|
||||
X509CertificateHolder issuer = certificate(command.issuerCredentialId());
|
||||
X509CertificateHolder responder = certificate(command.responderCredentialId());
|
||||
validateAuthority(command, issuer, responder);
|
||||
List<Resolved> resolved;
|
||||
long revision;
|
||||
String commitment;
|
||||
try (RevocationView view = store.openRevocationView()) {
|
||||
revision = view.revision();
|
||||
commitment = view.commitment();
|
||||
resolved = resolve(command, issuer, view);
|
||||
}
|
||||
int good = Math.toIntExact(resolved.stream().filter(value -> value.status() == CertificateStatus.GOOD)
|
||||
.count());
|
||||
int revoked = Math.toIntExact(resolved.stream().filter(value -> value.status() instanceof RevokedStatus)
|
||||
.count());
|
||||
int unknown = Math.subtractExact(resolved.size(), Math.addExact(good, revoked));
|
||||
return new Response(sign(command, responder, resolved), revision, commitment, good, revoked, unknown);
|
||||
} catch (Exception failure) {
|
||||
throw new PkiException("OCSP response generation failed: code=OCSP_RESPONSE_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void validateSigningBinding(zeroecho.pki.api.PkiId responderCredentialId,
|
||||
zeroecho.pki.api.KeyRef signingKeyRef, String signatureAlgorithm,
|
||||
Optional<String> signatureBindingId) {
|
||||
requireOpen.run();
|
||||
Objects.requireNonNull(responderCredentialId, "responderCredentialId");
|
||||
Objects.requireNonNull(signingKeyRef, "signingKeyRef");
|
||||
Objects.requireNonNull(signatureBindingId, "signatureBindingId");
|
||||
byte[] challenge = "ZeroEcho OCSP signing binding v1".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
try {
|
||||
X509CertificateHolder responder = certificate(responderCredentialId);
|
||||
AlgorithmIdentity identity = bus.authority().resolveIdentity(signatureAlgorithm);
|
||||
PkiBusContentSigner signer = signatureBindingId.isPresent()
|
||||
? new PkiBusContentSigner(bus, signingKeyRef, identity, signatureBindingId.orElseThrow(), signingTtl)
|
||||
: new PkiBusContentSigner(bus, signingKeyRef, identity, signingTtl);
|
||||
try (java.io.OutputStream output = signer.getOutputStream()) { output.write(challenge); }
|
||||
org.bouncycastle.operator.ContentVerifier verifier =
|
||||
new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder().build(responder)
|
||||
.get(signer.getAlgorithmIdentifier());
|
||||
try (java.io.OutputStream output = verifier.getOutputStream()) { output.write(challenge); }
|
||||
if (!verifier.verify(signer.getSignature())) {
|
||||
throw new IOException("OCSP signing binding differs");
|
||||
}
|
||||
} catch (Exception failure) {
|
||||
throw new PkiException("OCSP signing binding validation failed: code=OCSP_SIGNING_BINDING_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Resolved> resolve(Command command, X509CertificateHolder issuer, RevocationView view)
|
||||
throws Exception {
|
||||
List<Resolved> result = new ArrayList<>(command.requests().size());
|
||||
DigestCalculatorProvider digests = new JcaDigestCalculatorProviderBuilder().build();
|
||||
for (CertId request : command.requests()) {
|
||||
DigestCalculator digest = digests.get(CertificateID.HASH_SHA1);
|
||||
if (request.hash() == CertIdHash.SHA256) {
|
||||
digest = digests.get(new org.bouncycastle.asn1.x509.AlgorithmIdentifier(
|
||||
org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256));
|
||||
}
|
||||
CertificateID id = new CertificateID(digest, issuer, request.serial());
|
||||
if (!MessageDigest.isEqual(id.getIssuerNameHash(), request.issuerNameHash())
|
||||
|| !MessageDigest.isEqual(id.getIssuerKeyHash(), request.issuerKeyHash())) {
|
||||
result.add(new Resolved(id, new UnknownStatus()));
|
||||
continue;
|
||||
}
|
||||
Credential credential = store.getCredentialByIssuerAndSerial(command.issuerId(), request.serial())
|
||||
.orElse(null);
|
||||
if (credential == null) {
|
||||
result.add(new Resolved(id, new UnknownStatus()));
|
||||
continue;
|
||||
}
|
||||
CertificateStatus status = view.get(credential.credentialId())
|
||||
.map(record -> status(record.transition().state(), record.transition().time(),
|
||||
record.transition().permanentReason().orElse(RevocationReason.UNSPECIFIED)))
|
||||
.orElse(CertificateStatus.GOOD);
|
||||
result.add(new Resolved(id, status));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private byte[] sign(Command command, X509CertificateHolder responder, List<Resolved> resolved)
|
||||
throws Exception {
|
||||
DigestCalculatorProvider digests = new JcaDigestCalculatorProviderBuilder().build();
|
||||
BasicOCSPRespBuilder builder = command.responderId() == ResponderId.BY_NAME
|
||||
? new BasicOCSPRespBuilder(new RespID(responder.getSubject()))
|
||||
: new BasicOCSPRespBuilder(new RespID(responder.getSubjectPublicKeyInfo(),
|
||||
digests.get(CertificateID.HASH_SHA1)));
|
||||
for (Resolved item : resolved) {
|
||||
builder.addResponse(item.id(), item.status(), Date.from(command.thisUpdate()),
|
||||
Date.from(command.nextUpdate()), null);
|
||||
}
|
||||
if (command.nonce().isPresent()) {
|
||||
builder.setResponseExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce,
|
||||
false, new DEROctetString(new DEROctetString(command.nonce().orElseThrow()).getEncoded()))));
|
||||
}
|
||||
AlgorithmIdentity identity = bus.authority().resolveIdentity(command.signatureAlgorithm());
|
||||
PkiBusContentSigner signer = command.signatureBindingId().isPresent()
|
||||
? new PkiBusContentSigner(bus, command.signingKeyRef(), identity,
|
||||
command.signatureBindingId().orElseThrow(), signingTtl)
|
||||
: new PkiBusContentSigner(bus, command.signingKeyRef(), identity, signingTtl);
|
||||
X509CertificateHolder[] chain = new X509CertificateHolder[command.responseChain().size()];
|
||||
for (int index = 0; index < chain.length; index++) chain[index] = certificate(command.responseChain().get(index));
|
||||
BasicOCSPResp basic = builder.build(signer, chain, Date.from(command.producedAt()));
|
||||
OCSPResp outer = new OCSPRespBuilder().build(OCSPRespBuilder.SUCCESSFUL, basic);
|
||||
byte[] encoded = outer.getEncoded();
|
||||
OCSPResp decodedOuter = new OCSPResp(encoded);
|
||||
BasicOCSPResp decoded = (BasicOCSPResp) decodedOuter.getResponseObject();
|
||||
if (!Arrays.equals(encoded, decodedOuter.getEncoded())
|
||||
|| decodedOuter.getStatus() != OCSPRespBuilder.SUCCESSFUL
|
||||
|| decoded == null || !decoded.getProducedAt().equals(Date.from(command.producedAt()))
|
||||
|| !decoded.getResponderId().equals(basic.getResponderId())
|
||||
|| !decoded.getSignatureAlgorithmID().equals(signer.getAlgorithmIdentifier())
|
||||
|| !decoded.isSignatureValid(new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder()
|
||||
.build(responder))
|
||||
|| !matchesResponses(decoded.getResponses(), resolved, command)
|
||||
|| !matchesCertificates(decoded.getCerts(), chain)
|
||||
|| !matchesNonce(decoded, command.nonce())) {
|
||||
throw new IOException("Generated OCSP response validation failed");
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
private static boolean matchesResponses(SingleResp[] decoded, List<Resolved> expected, Command command) {
|
||||
if (decoded.length != expected.size()) return false;
|
||||
for (int index = 0; index < decoded.length; index++) {
|
||||
SingleResp actual = decoded[index]; Resolved wanted = expected.get(index);
|
||||
if (!actual.getCertID().equals(wanted.id())
|
||||
|| !actual.getThisUpdate().equals(Date.from(command.thisUpdate()))
|
||||
|| !actual.getNextUpdate().equals(Date.from(command.nextUpdate()))
|
||||
|| !sameStatus(actual.getCertStatus(), wanted.status())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean sameStatus(CertificateStatus actual, CertificateStatus expected) {
|
||||
if (actual == CertificateStatus.GOOD || expected == CertificateStatus.GOOD) {
|
||||
return actual == CertificateStatus.GOOD && expected == CertificateStatus.GOOD;
|
||||
}
|
||||
if (actual instanceof UnknownStatus && expected instanceof UnknownStatus) return true;
|
||||
if (actual instanceof RevokedStatus left && expected instanceof RevokedStatus right) {
|
||||
return left.getRevocationTime().equals(right.getRevocationTime())
|
||||
&& left.hasRevocationReason() == right.hasRevocationReason()
|
||||
&& (!left.hasRevocationReason() || left.getRevocationReason() == right.getRevocationReason());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean matchesCertificates(X509CertificateHolder[] decoded, X509CertificateHolder[] expected)
|
||||
throws IOException {
|
||||
if (decoded.length != expected.length) return false;
|
||||
for (int index = 0; index < decoded.length; index++) {
|
||||
if (!Arrays.equals(decoded[index].getEncoded(), expected[index].getEncoded())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean matchesNonce(BasicOCSPResp decoded, Optional<byte[]> expected) {
|
||||
Extension extension = decoded.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
|
||||
if (expected.isEmpty()) return extension == null;
|
||||
if (extension == null || extension.isCritical()) return false;
|
||||
byte[] actual = org.bouncycastle.asn1.ASN1OctetString.getInstance(extension.getParsedValue()).getOctets();
|
||||
return MessageDigest.isEqual(actual, expected.orElseThrow());
|
||||
}
|
||||
|
||||
private void validateAuthority(Command command, X509CertificateHolder issuer,
|
||||
X509CertificateHolder responder) {
|
||||
zeroecho.pki.api.ca.IssuerGeneration generation = store.getIssuerGeneration(command.issuerId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("OCSP issuer is unavailable"));
|
||||
if (!generation.authorityId().equals(command.authorityId())
|
||||
|| !generation.credentialId().equals(command.issuerCredentialId())
|
||||
|| !command.signingKeyRef().equals(generation.signingKeyRef())
|
||||
&& command.responderCredentialId().equals(command.issuerCredentialId())) {
|
||||
throw new IllegalArgumentException("OCSP responder authority mismatch");
|
||||
}
|
||||
if (issuer.getSerialNumber().signum() <= 0 || responder.getSerialNumber().signum() <= 0) {
|
||||
throw new IllegalArgumentException("OCSP responder certificate is invalid");
|
||||
}
|
||||
if (!responder.isValidOn(Date.from(command.producedAt()))
|
||||
|| command.nextUpdate().isAfter(responder.getNotAfter().toInstant())) {
|
||||
throw new IllegalArgumentException("OCSP response exceeds responder signing validity");
|
||||
}
|
||||
}
|
||||
|
||||
private X509CertificateHolder certificate(zeroecho.pki.api.PkiId credentialId) throws IOException {
|
||||
try (PkiRepositoryContent content = new DefaultPkiRepository(store, requireOpen)
|
||||
.openCredential(credentialId); InputStream input = content.openStream()) {
|
||||
byte[] encoded = input.readNBytes(1_048_577);
|
||||
if (encoded.length > 1_048_576 || input.read() != -1) {
|
||||
throw new IOException("OCSP certificate exceeds its finite bound");
|
||||
}
|
||||
return new X509CertificateHolder(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
private static CertificateStatus status(RevocationState state, java.time.Instant time,
|
||||
RevocationReason reason) {
|
||||
return switch (state) {
|
||||
case CLEAR -> CertificateStatus.GOOD;
|
||||
case HELD -> new RevokedStatus(Date.from(time), CRLReason.certificateHold);
|
||||
case PERMANENTLY_REVOKED -> new RevokedStatus(Date.from(time), reason(reason));
|
||||
};
|
||||
}
|
||||
|
||||
private static int reason(RevocationReason reason) {
|
||||
return switch (reason) {
|
||||
case KEY_COMPROMISE -> CRLReason.keyCompromise;
|
||||
case CA_COMPROMISE -> CRLReason.cACompromise;
|
||||
case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
|
||||
case SUPERSEDED -> CRLReason.superseded;
|
||||
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
|
||||
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
|
||||
case REMOVE_FROM_CRL -> CRLReason.removeFromCRL;
|
||||
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
|
||||
case AA_COMPROMISE -> CRLReason.aACompromise;
|
||||
case UNSPECIFIED -> CRLReason.unspecified;
|
||||
};
|
||||
}
|
||||
|
||||
private record Resolved(CertificateID id, CertificateStatus status) { }
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import org.bouncycastle.cert.X509CRLHolder;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
@@ -99,6 +100,12 @@ final class DefaultPkiRepository implements PkiRepository {
|
||||
return store.getCredential(Objects.requireNonNull(credentialId, "credentialId"));
|
||||
}
|
||||
|
||||
@Override public Optional<Credential> credential(PkiId issuerId, BigInteger serial) {
|
||||
requireOpen.run();
|
||||
return store.getCredentialByIssuerAndSerial(Objects.requireNonNull(issuerId, "issuerId"),
|
||||
Objects.requireNonNull(serial, "serial"));
|
||||
}
|
||||
|
||||
@Override public Optional<StatusObject> statusObject(PkiId statusObjectId) {
|
||||
requireOpen.run();
|
||||
return store.getStatusObject(Objects.requireNonNull(statusObjectId, "statusObjectId"));
|
||||
|
||||
@@ -110,6 +110,7 @@ final class DefaultPkiSession implements PkiSession {
|
||||
private final Optional<CertificationRequestService> requests;
|
||||
private final Optional<IssuanceService> issuance;
|
||||
private final Optional<StatusObjectService> statusObjects;
|
||||
private final Optional<OcspResponseService> ocsp;
|
||||
private final Optional<PublicationService> publications;
|
||||
private final Optional<PkiSigningBus> signingBus;
|
||||
private final Optional<SignatureWorkflow> signatureWorkflow;
|
||||
@@ -130,6 +131,8 @@ final class DefaultPkiSession implements PkiSession {
|
||||
this.requests = graph.requests();
|
||||
this.issuance = graph.issuance();
|
||||
this.statusObjects = graph.statusObjects();
|
||||
this.ocsp = graph.signingBus().map(bus -> new DefaultOcspResponseService(store, bus,
|
||||
configuration.signing().orElseThrow().signingTtl(), this::requireOpen));
|
||||
this.publications = graph.publications();
|
||||
this.signingBus = graph.signingBus();
|
||||
this.signatureWorkflow = graph.signatureWorkflow();
|
||||
@@ -155,6 +158,11 @@ final class DefaultPkiSession implements PkiSession {
|
||||
return open(configuration, dependencies, Clock.systemUTC(), ProductionBootstrap.INSTANCE);
|
||||
}
|
||||
|
||||
/* default */ static PkiSession open(PkiSessionConfiguration configuration,
|
||||
PkiSessionRuntimeDependencies dependencies, Clock clock) {
|
||||
return open(configuration, dependencies, clock, ProductionBootstrap.INSTANCE);
|
||||
}
|
||||
|
||||
/* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) {
|
||||
return open(configuration, PkiSessionRuntimeDependencies.none(), clock, bootstrap);
|
||||
}
|
||||
@@ -239,6 +247,8 @@ final class DefaultPkiSession implements PkiSession {
|
||||
return statusObjects;
|
||||
}
|
||||
|
||||
@Override public Optional<OcspResponseService> ocsp() { requireOpen(); return ocsp; }
|
||||
|
||||
@Override
|
||||
public Optional<PublicationService> publications() {
|
||||
requireOpen();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*******************************************************************************
|
||||
* 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.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
|
||||
/** Transport-neutral strict OCSP response generation through confined signing. */
|
||||
@SuppressWarnings("PMD.ControlStatementBraces")
|
||||
public interface OcspResponseService {
|
||||
/** Supported RFC CertID digest identities. */
|
||||
enum CertIdHash {
|
||||
SHA1(1), SHA256(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
CertIdHash(int code) { this.code = code; }
|
||||
|
||||
/** Stable persistence code. */
|
||||
public int code() { return code; }
|
||||
|
||||
/** Resolves an exact stable persistence code. */
|
||||
public static CertIdHash fromCode(int code) {
|
||||
return switch (code) { case 1 -> SHA1; case 2 -> SHA256;
|
||||
default -> throw new IllegalArgumentException("Unknown OCSP CertID hash code"); };
|
||||
}
|
||||
}
|
||||
/** Exact responder identifier representation. */
|
||||
enum ResponderId {
|
||||
BY_NAME(1), BY_KEY(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
ResponderId(int code) { this.code = code; }
|
||||
|
||||
/** Stable persistence code. */
|
||||
public int code() { return code; }
|
||||
|
||||
/** Resolves an exact stable persistence code. */
|
||||
public static ResponderId fromCode(int code) {
|
||||
return switch (code) { case 1 -> BY_NAME; case 2 -> BY_KEY;
|
||||
default -> throw new IllegalArgumentException("Unknown OCSP responder ID code"); };
|
||||
}
|
||||
}
|
||||
|
||||
/** One strictly parsed CertID. */
|
||||
record CertId(CertIdHash hash, byte[] issuerNameHash, byte[] issuerKeyHash, BigInteger serial) {
|
||||
/** Defensively owns all request values. */
|
||||
public CertId {
|
||||
Objects.requireNonNull(hash, "hash");
|
||||
issuerNameHash = Objects.requireNonNull(issuerNameHash, "issuerNameHash").clone();
|
||||
issuerKeyHash = Objects.requireNonNull(issuerKeyHash, "issuerKeyHash").clone();
|
||||
Objects.requireNonNull(serial, "serial");
|
||||
int length = hash == CertIdHash.SHA1 ? 20 : 32;
|
||||
if (issuerNameHash.length != length || issuerKeyHash.length != length
|
||||
|| serial.signum() <= 0 || serial.bitLength() > 160) {
|
||||
throw new IllegalArgumentException("OCSP CertID is invalid");
|
||||
}
|
||||
}
|
||||
@Override public byte[] issuerNameHash() { return issuerNameHash.clone(); }
|
||||
@Override public byte[] issuerKeyHash() { return issuerKeyHash.clone(); }
|
||||
}
|
||||
|
||||
/** Exact already-authorized responder generation command. */
|
||||
record Command(PkiId authorityId, PkiId issuerId, PkiId issuerCredentialId,
|
||||
PkiId responderCredentialId, KeyRef signingKeyRef, List<PkiId> responseChain,
|
||||
String signatureAlgorithm, Optional<String> signatureBindingId,
|
||||
ResponderId responderId, Instant producedAt, Instant thisUpdate, Instant nextUpdate,
|
||||
Optional<byte[]> nonce, List<CertId> requests) {
|
||||
/** Validates finite exact responder inputs. */
|
||||
public Command {
|
||||
Objects.requireNonNull(authorityId, "authorityId"); Objects.requireNonNull(issuerId, "issuerId");
|
||||
Objects.requireNonNull(issuerCredentialId, "issuerCredentialId");
|
||||
Objects.requireNonNull(responderCredentialId, "responderCredentialId");
|
||||
Objects.requireNonNull(signingKeyRef, "signingKeyRef");
|
||||
responseChain = List.copyOf(Objects.requireNonNull(responseChain, "responseChain"));
|
||||
if (responseChain.isEmpty() || responseChain.size() > 32) throw new IllegalArgumentException("OCSP chain is invalid");
|
||||
if (signatureAlgorithm == null || signatureAlgorithm.isBlank()) throw new IllegalArgumentException("OCSP algorithm is invalid");
|
||||
signatureBindingId = Objects.requireNonNull(signatureBindingId, "signatureBindingId");
|
||||
Objects.requireNonNull(responderId, "responderId"); Objects.requireNonNull(producedAt, "producedAt");
|
||||
Objects.requireNonNull(thisUpdate, "thisUpdate"); Objects.requireNonNull(nextUpdate, "nextUpdate");
|
||||
if (thisUpdate.isAfter(producedAt) || !nextUpdate.isAfter(thisUpdate)) throw new IllegalArgumentException("OCSP times are invalid");
|
||||
nonce = Objects.requireNonNull(nonce, "nonce").map(byte[]::clone);
|
||||
requests = List.copyOf(Objects.requireNonNull(requests, "requests"));
|
||||
if (requests.isEmpty()) throw new IllegalArgumentException("OCSP request is empty");
|
||||
}
|
||||
@Override public Optional<byte[]> nonce() { return nonce.map(byte[]::clone); }
|
||||
}
|
||||
|
||||
/** Signed canonical response and stable revocation provenance. */
|
||||
record Response(byte[] der, long revocationRevision, String revocationCommitment,
|
||||
int goodCount, int revokedCount, int unknownCount) {
|
||||
/** Defensively owns response DER. */
|
||||
public Response {
|
||||
der = Objects.requireNonNull(der, "der").clone();
|
||||
if (revocationRevision < 0 || revocationCommitment == null
|
||||
|| !revocationCommitment.matches("[0-9a-f]{64}") || goodCount < 0 || revokedCount < 0
|
||||
|| unknownCount < 0 || Math.addExact(Math.addExact(goodCount, revokedCount), unknownCount) == 0) {
|
||||
throw new IllegalArgumentException("OCSP provenance is invalid");
|
||||
}
|
||||
}
|
||||
@Override public byte[] der() { return der.clone(); }
|
||||
}
|
||||
|
||||
/** Resolves one stable view and signs exactly one response. */
|
||||
Response respond(Command command);
|
||||
|
||||
/**
|
||||
* Proves that one configured confined key capability signs as the exact
|
||||
* responder certificate before a durable responder is activated.
|
||||
*/
|
||||
void validateSigningBinding(PkiId responderCredentialId, KeyRef signingKeyRef,
|
||||
String signatureAlgorithm, Optional<String> signatureBindingId);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ package zeroecho.pki.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
@@ -55,6 +56,12 @@ public interface PkiRepository {
|
||||
Optional<IssuerChainPath> chainPath(PkiId pathId);
|
||||
/** Returns an exact credential metadata record. */
|
||||
Optional<Credential> credential(PkiId credentialId);
|
||||
/** Returns an exact credential through the issuer-generation/serial index. */
|
||||
default Optional<Credential> credential(PkiId issuerId, BigInteger serial) {
|
||||
java.util.Objects.requireNonNull(issuerId, "issuerId");
|
||||
java.util.Objects.requireNonNull(serial, "serial");
|
||||
return Optional.empty();
|
||||
}
|
||||
/** Returns an exact status-object metadata record. */
|
||||
Optional<StatusObject> statusObject(PkiId statusObjectId);
|
||||
/** Opens validated immutable certificate content. */
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.application;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.CaService;
|
||||
@@ -77,6 +78,20 @@ public interface PkiSession extends AutoCloseable {
|
||||
return DefaultPkiSession.open(configuration, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a production session with explicit process-local capabilities and a
|
||||
* lifecycle clock shared by every time-dependent backend service.
|
||||
*
|
||||
* @param configuration validated immutable provider configuration
|
||||
* @param dependencies process-local key-access capabilities
|
||||
* @param clock authoritative session clock
|
||||
* @return opened lifecycle-owned session
|
||||
*/
|
||||
static PkiSession open(PkiSessionConfiguration configuration, PkiSessionRuntimeDependencies dependencies,
|
||||
Clock clock) {
|
||||
return DefaultPkiSession.open(configuration, dependencies, clock);
|
||||
}
|
||||
|
||||
/** @return profile lifecycle service owned by this session */
|
||||
ProfileService profiles();
|
||||
|
||||
@@ -103,6 +118,9 @@ public interface PkiSession extends AutoCloseable {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** @return confined OCSP responder service, or empty when signing is unavailable */
|
||||
default Optional<OcspResponseService> ocsp() { return Optional.empty(); }
|
||||
|
||||
/** @return configured publication service, or empty when no destination is enabled */
|
||||
default Optional<PublicationService> publications() {
|
||||
return Optional.empty();
|
||||
|
||||
@@ -35,8 +35,12 @@ package zeroecho.pki.impl.fs;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
@@ -108,6 +112,7 @@ import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.content.DurableContentOwner;
|
||||
import zeroecho.pki.impl.ProfileLifecycleFailure;
|
||||
import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.spi.store.PkiStore;
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
@@ -122,6 +127,7 @@ import zeroecho.pki.spi.store.StagedContentStore;
|
||||
import zeroecho.pki.spi.store.SignWorkflowStore;
|
||||
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
|
||||
import zeroecho.pki.spi.store.RevocationSnapshot;
|
||||
import zeroecho.pki.spi.store.RevocationView;
|
||||
import zeroecho.pki.spi.store.RevocationHistory;
|
||||
|
||||
/**
|
||||
@@ -175,7 +181,9 @@ import zeroecho.pki.spi.store.RevocationHistory;
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods",
|
||||
"PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl",
|
||||
"PMD.PreserveStackTrace", "PMD.NcssCount", "PMD.AvoidDuplicateLiterals" })
|
||||
"PMD.PreserveStackTrace", "PMD.NcssCount", "PMD.AvoidDuplicateLiterals",
|
||||
"PMD.ControlStatementBraces", "PMD.CollapsibleIfStatements", "PMD.AvoidDeeplyNestedIfStmts",
|
||||
"PMD.AvoidLiteralsInIfCondition" })
|
||||
public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
|
||||
@@ -186,6 +194,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record";
|
||||
private static final String STATUS_OWNER_NAMESPACE = "io.zeroecho.pki.status-object-owner";
|
||||
private static final String PUBLICATION_RECORD_NAMESPACE = "io.zeroecho.pki.publication-record";
|
||||
private static final String CREDENTIAL_SERIAL_INDEX_NAMESPACE = "io.zeroecho.pki.credential-serial-index";
|
||||
private static final int CURRENT_SIGN_RECORD_VERSION = 2;
|
||||
private static final int SIGN_OWNER_VALUE_VERSION = 1;
|
||||
private static final int STATUS_OWNER_VALUE_VERSION = 1;
|
||||
@@ -294,6 +303,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
|
||||
this.historySeq = new AtomicLong(0L);
|
||||
recoverStagedContent();
|
||||
rebuildCredentialSerialIndex();
|
||||
recoverPublicationRecords();
|
||||
boolean snapshotRestore = requireSnapshotBoundary();
|
||||
openedRevocations = FilesystemRevocationAuthority.open(
|
||||
@@ -637,6 +647,10 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
writeOnce(paths.issuerGenerationPath(generation.issuerId()),
|
||||
FsCodec.encode(FsCodec.ISSUER_GENERATION, generation), "ISSUER_GENERATION",
|
||||
FsUtil.safeId(generation.issuerId()));
|
||||
x509Serial(credential).ifPresent(serial -> {
|
||||
requireSerialAvailable(generation.issuerId(), serial, generation.credentialId());
|
||||
ensureSerialIndex(generation.issuerId(), serial, generation.credentialId());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -687,7 +701,11 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
public void putCredential(final Credential credential) {
|
||||
requireStoreUsable();
|
||||
Objects.requireNonNull(credential, "credential");
|
||||
Optional<BigInteger> serial = indexableSerial(credential);
|
||||
serial.ifPresent(value -> requireSerialAvailable(credential.issuerRef().issuerId(), value, credential));
|
||||
credentialContentTransactions.put(credential);
|
||||
serial.ifPresent(value -> ensureSerialIndex(credential.issuerRef().issuerId(), value,
|
||||
credential.credentialId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -706,6 +724,184 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Credential> getCredentialByIssuerAndSerial(PkiId issuerId, BigInteger serial) {
|
||||
requireStoreUsable();
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
requirePositiveSerial(serial);
|
||||
MetadataKey key = serialIndexKey(issuerId, serial);
|
||||
try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
|
||||
Optional<MetadataSnapshot.Record> stored = snapshot.get(key);
|
||||
if (stored.isEmpty()) return Optional.empty();
|
||||
PkiId credentialId;
|
||||
try (MetadataSnapshot.Record record = stored.orElseThrow()) {
|
||||
credentialId = decodeSerialIndex(record, issuerId, serial);
|
||||
}
|
||||
Credential credential = getCredential(credentialId)
|
||||
.orElseThrow(() -> new IllegalStateException("Credential serial index target is missing"));
|
||||
if (!credential.issuerRef().issuerId().equals(issuerId)
|
||||
|| !x509Serial(credential).filter(serial::equals).isPresent()) {
|
||||
throw new IllegalStateException("Credential serial index authority mismatch");
|
||||
}
|
||||
return Optional.of(credential);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Credential serial index is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuildCredentialSerialIndex() throws IOException {
|
||||
Path root = paths.root().resolve("credentials").resolve("by-id");
|
||||
if (!Files.isDirectory(root)) return;
|
||||
try (Stream<Path> records = Files.list(root)) {
|
||||
java.util.Iterator<Path> iterator = records
|
||||
.filter(path -> path.getFileName().toString().endsWith(".bin"))
|
||||
.sorted(Comparator.comparing(path -> path.getFileName().toString())).iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL,
|
||||
FsOperations.readAll(iterator.next()), stagedContent);
|
||||
Optional<BigInteger> serial = indexableSerial(credential);
|
||||
if (serial.isPresent()) {
|
||||
requireSerialAvailable(credential.issuerRef().issuerId(), serial.orElseThrow(), credential);
|
||||
ensureSerialIndex(credential.issuerRef().issuerId(), serial.orElseThrow(),
|
||||
credential.credentialId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSerialAvailable(PkiId issuerId, BigInteger serial, PkiId credentialId) {
|
||||
Credential candidate = getCredential(credentialId).orElse(null);
|
||||
requireSerialAvailable(issuerId, serial, candidate);
|
||||
}
|
||||
|
||||
private void requireSerialAvailable(PkiId issuerId, BigInteger serial, Credential candidate) {
|
||||
Optional<Credential> existing = getCredentialByIssuerAndSerial(issuerId, serial);
|
||||
if (existing.isPresent() && (candidate == null
|
||||
|| !existing.orElseThrow().credentialId().equals(candidate.credentialId()))) {
|
||||
if (candidate == null || !sameImmutableContent(existing.orElseThrow(), candidate)) {
|
||||
throw new IllegalStateException("Duplicate issuer-generation certificate serial");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureSerialIndex(PkiId issuerId, BigInteger serial, PkiId credentialId) {
|
||||
MetadataKey key = serialIndexKey(issuerId, serial);
|
||||
try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
|
||||
Optional<MetadataSnapshot.Record> stored = snapshot.get(key);
|
||||
if (stored.isPresent()) {
|
||||
try (MetadataSnapshot.Record record = stored.orElseThrow()) {
|
||||
PkiId indexedId = decodeSerialIndex(record, issuerId, serial);
|
||||
if (!indexedId.equals(credentialId)) {
|
||||
Credential indexed = getCredential(indexedId).orElseThrow(
|
||||
() -> new IllegalStateException("Credential serial index target is missing"));
|
||||
Credential candidate = getCredential(credentialId).orElseThrow(
|
||||
() -> new IllegalStateException("Credential serial candidate is missing"));
|
||||
if (!sameImmutableContent(indexed, candidate)) {
|
||||
throw new IllegalStateException("Duplicate issuer-generation certificate serial");
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Credential serial index read failed");
|
||||
}
|
||||
try (MetadataTransaction transaction = metadataStore.beginTransaction()) {
|
||||
transaction.create(key, byteContent(encodeSerialIndex(issuerId, serial, credentialId)),
|
||||
CancellationSignal.NONE);
|
||||
MetadataCommitResult result = transaction.commit();
|
||||
if (result.outcome() == MetadataCommitResult.Outcome.COMMITTED) return;
|
||||
if (result.outcome() == MetadataCommitResult.Outcome.UNKNOWN) {
|
||||
durabilityUncertain.set(true);
|
||||
throw new PkiException("Store durability unconfirmed: code=STORE_DURABILITY_UNCONFIRMED");
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Credential serial index persistence failed");
|
||||
}
|
||||
requireSerialAvailable(issuerId, serial, credentialId);
|
||||
}
|
||||
|
||||
private static boolean sameImmutableContent(Credential left, Credential right) {
|
||||
return left.content().storeId().equals(right.content().storeId())
|
||||
&& left.content().contentId().equals(right.content().contentId())
|
||||
&& left.content().sha256().equals(right.content().sha256())
|
||||
&& left.content().length() == right.content().length();
|
||||
}
|
||||
|
||||
private static Optional<BigInteger> x509Serial(Credential credential) {
|
||||
if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())) return Optional.empty();
|
||||
try {
|
||||
BigInteger serial = new BigInteger(credential.serialOrUniqueId());
|
||||
if (serial.signum() <= 0 || serial.bitLength() > 160
|
||||
|| !serial.toString().equals(credential.serialOrUniqueId())) return Optional.empty();
|
||||
return Optional.of(serial);
|
||||
} catch (NumberFormatException failure) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<BigInteger> indexableSerial(Credential credential) {
|
||||
if (credential.issuerRef().issuerId().value().startsWith("issuer-unresolved:")
|
||||
|| getIssuerGeneration(credential.issuerRef().issuerId()).isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return x509Serial(credential);
|
||||
}
|
||||
|
||||
private static void requirePositiveSerial(BigInteger serial) {
|
||||
Objects.requireNonNull(serial, "serial");
|
||||
if (serial.signum() <= 0 || serial.bitLength() > 160) {
|
||||
throw new IllegalArgumentException("X.509 serial must be a positive value of at most 20 octets");
|
||||
}
|
||||
}
|
||||
|
||||
private static MetadataKey serialIndexKey(PkiId issuerId, BigInteger serial) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] issuer = issuerId.value().getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(issuer.length).array());
|
||||
digest.update(issuer);
|
||||
digest.update(serial.toByteArray());
|
||||
return new MetadataKey(CREDENTIAL_SERIAL_INDEX_NAMESPACE,
|
||||
HexFormat.of().formatHex(digest.digest()));
|
||||
} catch (java.security.NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encodeSerialIndex(PkiId issuerId, BigInteger serial, PkiId credentialId) {
|
||||
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(256);
|
||||
DataOutputStream output = new DataOutputStream(bytes)) {
|
||||
output.writeInt(1);
|
||||
output.writeUTF(issuerId.value());
|
||||
output.writeUTF(serial.toString());
|
||||
output.writeUTF(credentialId.value());
|
||||
output.flush();
|
||||
return bytes.toByteArray();
|
||||
} catch (IOException impossible) {
|
||||
throw new IllegalStateException("Credential serial index encoding failed", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static PkiId decodeSerialIndex(MetadataSnapshot.Record record, PkiId issuerId,
|
||||
BigInteger serial) throws IOException {
|
||||
byte[] encoded = readMetadataValue(record);
|
||||
if (encoded.length > 16_384) throw new IOException("Credential serial index exceeds its bound");
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) {
|
||||
if (input.readInt() != 1) throw new IOException("Credential serial index version is obsolete");
|
||||
PkiId storedIssuer = new PkiId(input.readUTF());
|
||||
String storedSerial = input.readUTF();
|
||||
PkiId credentialId = new PkiId(input.readUTF());
|
||||
if (input.read() != -1 || !storedIssuer.equals(issuerId)
|
||||
|| !serial.toString().equals(storedSerial)) {
|
||||
throw new IOException("Credential serial index authority mismatch");
|
||||
}
|
||||
return credentialId;
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Credential serial index is malformed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Credential> getCredentialByIssuanceIntent(IssuanceIntent intent) {
|
||||
requireStoreUsable();
|
||||
@@ -941,6 +1137,16 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RevocationView openRevocationView() {
|
||||
requireStoreUsable();
|
||||
try {
|
||||
return revocations.view();
|
||||
} catch (IOException failure) {
|
||||
throw corruptRevocationState();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putStatusObject(final StatusObject object) {
|
||||
requireStoreUsable();
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
/*******************************************************************************
|
||||
* 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.fs;
|
||||
|
||||
@@ -24,10 +53,11 @@ import zeroecho.pki.api.revocation.RevocationTransition;
|
||||
import zeroecho.pki.spi.store.MetadataStoreId;
|
||||
import zeroecho.pki.spi.store.RevocationHistory;
|
||||
import zeroecho.pki.spi.store.RevocationSnapshot;
|
||||
import zeroecho.pki.spi.store.RevocationView;
|
||||
|
||||
/** Store-owned coordination of the authoritative log and its derived state. */
|
||||
@SuppressWarnings({ "PMD.CloseResource", "PMD.UseTryWithResources", "PMD.AvoidSynchronizedAtMethodLevel",
|
||||
"PMD.UnusedAssignment" })
|
||||
"PMD.UnusedAssignment", "PMD.ControlStatementBraces" })
|
||||
final class FilesystemRevocationAuthority implements AutoCloseable {
|
||||
|
||||
private static final FilesystemRevocationCurrentIndex.Configuration INDEX_CONFIGURATION =
|
||||
@@ -183,6 +213,21 @@ final class FilesystemRevocationAuthority implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/* default */ RevocationView view() throws IOException {
|
||||
lifecycle.writeLock().lock();
|
||||
try {
|
||||
requireOperational();
|
||||
ensureIndex();
|
||||
FilesystemRevocationLog.RecoveryTarget head = log.recoveryTarget();
|
||||
if (index.coveredGlobalRevision() != head.globalRevision()) recoverIndex(head);
|
||||
DirectView view = new DirectView(this, head);
|
||||
view.acquire();
|
||||
return view;
|
||||
} finally {
|
||||
lifecycle.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/* default */ FilesystemRevocationLog.RecoveryTarget head() throws IOException {
|
||||
lifecycle.readLock().lock();
|
||||
try {
|
||||
@@ -389,6 +434,36 @@ final class FilesystemRevocationAuthority implements AutoCloseable {
|
||||
void closeFromOwner() throws IOException;
|
||||
}
|
||||
|
||||
/** Read-lock-backed direct view; callers close it before external signing. */
|
||||
private static final class DirectView implements RevocationView {
|
||||
private final FilesystemRevocationAuthority owner;
|
||||
private final FilesystemRevocationLog.RecoveryTarget head;
|
||||
private boolean closed;
|
||||
|
||||
private DirectView(FilesystemRevocationAuthority owner,
|
||||
FilesystemRevocationLog.RecoveryTarget head) {
|
||||
this.owner = owner;
|
||||
this.head = head;
|
||||
}
|
||||
|
||||
private void acquire() { owner.lifecycle.readLock().lock(); }
|
||||
@Override public long revision() { requireOpen(); return head.globalRevision(); }
|
||||
@Override public String commitment() { requireOpen(); return head.globalCommitment().value(); }
|
||||
@Override public Optional<RevocationRecord> get(PkiId credentialId) throws IOException {
|
||||
requireOpen();
|
||||
return owner.index.lookup(Objects.requireNonNull(credentialId, "credentialId"))
|
||||
.map(FilesystemRevocationAuthority::record);
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
owner.lifecycle.readLock().unlock();
|
||||
}
|
||||
private void requireOpen() {
|
||||
if (closed) throw new IllegalStateException("Revocation view is closed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable checkpoint-backed current-state view. */
|
||||
private static final class StableView implements RevocationSnapshot, OwnedResource {
|
||||
private final FilesystemRevocationAuthority owner;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
package zeroecho.pki.spi.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -194,6 +195,18 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
|
||||
*/
|
||||
Optional<Credential> getCredential(PkiId credentialId);
|
||||
|
||||
/**
|
||||
* Resolves one exact X.509 credential through the derived issuer-generation
|
||||
* and canonical positive serial index. Implementations must validate the
|
||||
* returned authoritative credential and must not scan the credential
|
||||
* population for a lookup.
|
||||
*
|
||||
* @param issuerId exact issuer-generation identity
|
||||
* @param serial canonical positive X.509 serial
|
||||
* @return matching authoritative credential, when issued
|
||||
*/
|
||||
Optional<Credential> getCredentialByIssuerAndSerial(PkiId issuerId, BigInteger serial);
|
||||
|
||||
/**
|
||||
* Resolves the exact credential atomically persisted with one issuance intent.
|
||||
* Implementations must reject duplicate matches and mismatched command
|
||||
@@ -270,6 +283,9 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
|
||||
*/
|
||||
RevocationSnapshot openRevocationSnapshot();
|
||||
|
||||
/** Opens a stable direct-lookup current-state view for finite protocol work. */
|
||||
RevocationView openRevocationView();
|
||||
|
||||
/**
|
||||
* Persists a status object.
|
||||
*
|
||||
|
||||
52
pki/src/main/java/zeroecho/pki/spi/store/RevocationView.java
Normal file
52
pki/src/main/java/zeroecho/pki/spi/store/RevocationView.java
Normal file
@@ -0,0 +1,52 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.spi.store;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.revocation.RevocationRecord;
|
||||
|
||||
/** Stable direct-lookup view of one authoritative revocation log revision. */
|
||||
public interface RevocationView extends AutoCloseable {
|
||||
/** @return captured global revision */
|
||||
long revision();
|
||||
/** @return captured global commitment */
|
||||
String commitment();
|
||||
/** Returns one current state without scanning the revocation population. */
|
||||
Optional<RevocationRecord> get(PkiId credentialId) throws IOException;
|
||||
/** Releases the stable view. */
|
||||
@Override void close() throws IOException;
|
||||
}
|
||||
@@ -128,6 +128,7 @@ import zeroecho.pki.api.publication.PublicationTargetType;
|
||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||
import zeroecho.pki.api.revocation.RevocationRecord;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
|
||||
import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
@@ -624,6 +625,39 @@ public final class FilesystemPkiStoreTest {
|
||||
System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart() throws Exception {
|
||||
System.out.println("issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart");
|
||||
Path root = tmp.resolve("store-issuer-serial-index");
|
||||
PkiId firstIssuer;
|
||||
PkiId secondIssuer;
|
||||
PkiId firstCredential;
|
||||
PkiId secondCredential;
|
||||
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
|
||||
CaRecord first = TestObjects.minimalCaRecord(store, "ca-index-one", CaState.ACTIVE);
|
||||
CaRecord second = TestObjects.minimalCaRecord(store, "ca-index-two", CaState.ACTIVE);
|
||||
store.putCa(first);
|
||||
store.putCa(second);
|
||||
firstIssuer = first.currentIssuanceIssuerId();
|
||||
secondIssuer = second.currentIssuanceIssuerId();
|
||||
firstCredential = store.getIssuerGeneration(firstIssuer).orElseThrow().credentialId();
|
||||
secondCredential = store.getIssuerGeneration(secondIssuer).orElseThrow().credentialId();
|
||||
assertEquals(firstCredential, store.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.ONE)
|
||||
.orElseThrow().credentialId());
|
||||
assertEquals(secondCredential, store.getCredentialByIssuerAndSerial(secondIssuer, BigInteger.ONE)
|
||||
.orElseThrow().credentialId());
|
||||
assertTrue(store.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.TWO).isEmpty());
|
||||
}
|
||||
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
|
||||
assertEquals(firstCredential, reopened.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.ONE)
|
||||
.orElseThrow().credentialId());
|
||||
assertEquals(secondCredential, reopened.getCredentialByIssuerAndSerial(secondIssuer, BigInteger.ONE)
|
||||
.orElseThrow().credentialId());
|
||||
}
|
||||
System.out.println("...same-serial-separated=true");
|
||||
System.out.println("issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldStoreVersionIsRejected() throws Exception {
|
||||
System.out.println("oldStoreVersionIsRejected");
|
||||
@@ -1336,9 +1370,9 @@ public final class FilesystemPkiStoreTest {
|
||||
.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"),
|
||||
return new Credential(credentialId, BcX509CredentialFramework.FORMAT_ID,
|
||||
new IssuerRef(authorityId, issuerId, pathId), subject,
|
||||
new Validity(notBefore, notAfter), "CA-" + authorityId.value(),
|
||||
new Validity(notBefore, notAfter), "1",
|
||||
new PkiId("pk-" + authorityId.value()),
|
||||
new CaProfileBinding(new CertificateProfileRef("profile-ca", 1, new byte[32])),
|
||||
CredentialStatus.ISSUED, content, emptyAttributes());
|
||||
|
||||
Reference in New Issue
Block a user