feat(pki-server): authorize signed OCSP requests

Add per-responder signed-request policies, strict request-signature and
requester-certificate validation, cryptographic principal mapping and
scoped OCSP query authorization.

Preserve public unsigned responder behavior while isolating requester,
transport and administrative identities.
This commit is contained in:
2026-08-05 19:06:13 +02:00
parent 44cbb0a37d
commit 4f01c57360
17 changed files with 1468 additions and 87 deletions

View File

@@ -12,7 +12,9 @@ An OCSP responder is a durable server-control record. Its alias binds exactly
one realm, logical authority, issuer generation, responder certificate, one realm, logical authority, issuer generation, responder certificate,
`KeyRef`, explicit chain path, signature algorithm/binding commitment, nonce `KeyRef`, explicit chain path, signature algorithm/binding commitment, nonce
policy, response-validity policy, accepted CertID hashes, and finite request policy, response-validity policy, accepted CertID hashes, and finite request
bounds. Activation revalidates those dependencies and freezes their bounds. Each record also carries an explicit request-authentication mode and,
for signed modes, a finite immutable requester trust, mapping, signature,
revocation, and chain-bound policy. Activation revalidates those dependencies and freezes their
commitments. There is no first/last issuer selection, filename inference, commitments. There is no first/last issuer selection, filename inference,
timestamp inference, runtime path building, or fallback signing algorithm. timestamp inference, runtime path building, or fallback signing algorithm.
@@ -66,6 +68,77 @@ nonce echo:
nonce-bearing responses use no-store nonce-bearing responses use no-store
``` ```
## Request authentication
Every responder selects exactly one mode:
- `UNSIGNED_ONLY` is the default public responder policy. Unsigned requests are
accepted and syntactically signed requests receive OCSP `unauthorized`.
- `UNSIGNED_OR_AUTHORIZED_SIGNED` preserves anonymous requests, but any signed
request must authenticate and authorize completely; it never falls back to
anonymous handling.
- `AUTHORIZED_SIGNED_REQUIRED` returns OCSP `sigRequired` for unsigned requests
and accepts only authorized signed requests.
Signed request authentication is protocol-scoped. ZeroEcho retains the exact
canonical `tbsRequest` DER, requires a canonical `directoryName`
`requestorName` equal to the signer certificate subject, validates the bounded
included signer chain against configured trust selectors, and verifies the
signature using an active `OCSP_REQUEST_SIGNATURE` binding. Current standard
bindings permit ECDSA/SHA-256, Ed25519, RSA PKCS#1/SHA-256 and strict
RSA-PSS/SHA-256 when explicitly listed. SHA-1 request signatures are rejected.
Trust and authorization are independent. A cryptographic certificate, SPKI, or
managed issuer-generation-plus-serial commitment must map to exactly one enabled
persisted `SecurityPrincipal`. That principal needs an explicit
`OCSP_STATUS_QUERY` grant scoped to the exact realm, responder, authority, and
issuer generation. Explicit deny wins. `OCSP_ADMINISTER` grants no query right,
and query authority grants no administration, content, search, ownership,
disclosure, or PII right. Multi-entry authorization is all-or-nothing.
Requester certificate revocation uses one explicit policy:
- `LOCAL_AUTHORITATIVE_REQUIRED` requires a managed ZeroEcho credential and
current local revocation state. It is the recommended closed-enterprise mode.
- `LOCAL_AUTHORITATIVE_IF_MANAGED` checks managed credentials locally and uses
validity plus configured trust for external credentials.
- `VALIDITY_AND_TRUST_ONLY` performs no revocation check and must be selected
deliberately with its stale-credential risk understood.
ZeroEcho does not follow AIA, CRL, or OCSP URLs while authenticating a requester
and never calls the responder recursively. A request signature authenticates
integrity and requester identity; it does not replace nonce freshness. Replay of
a permitted read-only request remains valid when nonce policy permits it.
`REQUIRED` nonce responders keep a finite derived process-local replay window;
restart may discard that operational window without changing PKI authority.
Safe policy examples omit real identities and commitments:
```text
public unsigned:
requestAuthenticationMode = UNSIGNED_ONLY
mixed transition:
requestAuthenticationMode = UNSIGNED_OR_AUTHORIZED_SIGNED
requesterRevocationPolicy = LOCAL_AUTHORITATIVE_REQUIRED
closed managed enterprise:
requestAuthenticationMode = AUTHORIZED_SIGNED_REQUIRED
trustSelectors = [managed issuer generations]
requiredPermission = OCSP_STATUS_QUERY
requesterRevocationPolicy = LOCAL_AUTHORITATIVE_REQUIRED
closed external trust:
requestAuthenticationMode = AUTHORIZED_SIGNED_REQUIRED
trustSelectors = [configured trust anchors]
requesterRevocationPolicy = LOCAL_AUTHORITATIVE_IF_MANAGED
```
Migration is explicit: replace the pre-release responder record with its desired
mode and complete requester policy, validate scoped grants, and activate it
through the configured approval workflow. The obsolete schema is rejected; no
mode is inferred and no compatibility reader exists.
## Requests and responses ## Requests and responses
POST uses `Content-Type: application/ocsp-request`. GET uses an unpadded, POST uses `Content-Type: application/ocsp-request`. GET uses an unpadded,
@@ -76,10 +149,10 @@ canonical URL-safe Base64 request path:
``` ```
Query-string requests and alternate Base64 normalization are rejected. The Query-string requests and alternate Base64 normalization are rejected. The
strict DER parser accepts unsigned requests only, consumes the complete input, strict DER parser consumes the complete input, rejects all per-entry extensions
rejects duplicate or unknown critical extensions, and enforces configured byte and duplicate or unknown critical request extensions, and enforces configured
and entry bounds. Signed OCSP requests are rejected in this release; a request byte, entry, signer-certificate, and chain bounds. Signed requests additionally
signature is never treated as administrative or certificate-owner authority. require the exact canonical optional-signature structure described above.
Configured CertID hashing supports SHA-1 and SHA-256. SHA-1 is permitted only 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 for RFC-compatible issuer-name/key hashes. It is never enabled as a certificate
@@ -98,18 +171,22 @@ recovery-required authority returns a transient protocol/transport failure, not
Successful responses are strict signed DER with Successful responses are strict signed DER with
`application/ocsp-response`, `nosniff`, `producedAt`, `thisUpdate`, and `application/ocsp-response`, `nosniff`, `producedAt`, `thisUpdate`, and
`nextUpdate`. Nonce-free responses carry a strong ETag and bounded public cache `nextUpdate`. Nonce-free unsigned responses carry a strong ETag and bounded public cache
policy. An echoed nonce is returned exactly and makes the response non-cacheable. policy. An echoed nonce is returned exactly and makes the response non-cacheable.
Every authenticated signed response uses `Cache-Control: private, no-store` and
`Pragma: no-cache`, even without a nonce; shared caches never store it.
No request DER, response DER, serial, issuer hash, nonce, certificate identity, No request DER, response DER, serial, issuer hash, nonce, certificate identity,
key reference, or parser/provider exception is logged or audited. key reference, or parser/provider exception is logged or audited.
## Deployment topology and capacity ## Deployment topology and capacity
Direct mode permits anonymous OCSP over the public TLS listener. An optional Direct mode permits anonymous OCSP over the public TLS listener. An optional
public client certificate does not change certificate status, and forwarded public client certificate neither replaces nor supplements the signed requester
identity headers are rejected. In trusted-reverse-proxy mode the backend proxy and does not change certificate status; forwarded identity headers remain
hop still requires mTLS and the dedicated forwarding permission; proxy and rejected. In trusted-reverse-proxy mode the backend proxy hop still requires
forwarded end-client identities do not authorize or select OCSP status. Source mTLS and the dedicated forwarding permission. The proxy transport principal,
forwarded administrative principal, and signed OCSP requester remain distinct
and do not inherit one another's permissions or select OCSP status. Source
IP, `Forwarded`, and `X-Forwarded-For` are never responder authority. IP, `Forwarded`, and `X-Forwarded-For` are never responder authority.
OCSP has a separate bounded runtime lane even though it shares the public OCSP has a separate bounded runtime lane even though it shares the public

View File

@@ -189,6 +189,8 @@ public final class AuthorizationEngine {
&& !grant.scope().issuerId().equals(request.resource().scope().issuerId())) return false; && !grant.scope().issuerId().equals(request.resource().scope().issuerId())) return false;
if (grant.scope().profileId().isPresent() if (grant.scope().profileId().isPresent()
&& !grant.scope().profileId().equals(request.resource().scope().profileId())) return false; && !grant.scope().profileId().equals(request.resource().scope().profileId())) return false;
if (grant.scope().responderId().isPresent()
&& !grant.scope().responderId().equals(request.resource().scope().responderId())) return false;
return grant.relationship() == Permission.Relationship.ANY return grant.relationship() == Permission.Relationship.ANY
|| request.relationship() == Permission.Relationship.OWN || request.relationship() == Permission.Relationship.OWN
&& request.resource().ownedBy(request.principal()); && request.resource().ownedBy(request.principal());

View File

@@ -0,0 +1,447 @@
/*******************************************************************************
* 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.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.RSASSAPSSparams;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.operator.ContentVerifier;
import org.bouncycastle.operator.ContentVerifierProvider;
import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder;
import org.bouncycastle.operator.bc.BcECContentVerifierProviderBuilder;
import org.bouncycastle.operator.bc.BcEdDSAContentVerifierProviderBuilder;
import org.bouncycastle.operator.bc.BcRSAContentVerifierProviderBuilder;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.application.OcspResponseService;
import zeroecho.pki.application.PkiRepositoryContent;
/**
* Transport-neutral signed-OCSP requester authentication and scoped query
* authorization. Certificate trust proves eligibility only; an enabled persisted
* principal and an explicit {@link Permission.Action#OCSP_STATUS_QUERY} grant are
* independently required.
*/
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.CyclomaticComplexity",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
"PMD.ControlStatementBraces", "PMD.PreserveStackTrace", "PMD.AvoidLiteralsInIfCondition",
"PMD.DataClass" })
public final class OcspRequesterService {
private static final int MAXIMUM_CERTIFICATE_BYTES = 1_048_576;
/** Exact bounded cryptographic fields retained from one canonical request. */
public record SignedRequest(byte[] tbsRequest, byte[] requestorName, byte[] signatureAlgorithm,
byte[] signature, List<byte[]> certificates) {
/** Snapshots all security-sensitive input bytes. */
public SignedRequest {
tbsRequest = requireBytes(tbsRequest, "tbsRequest", 1_048_576);
requestorName = requireBytes(requestorName, "requestorName", 65_536);
signatureAlgorithm = requireBytes(signatureAlgorithm, "signatureAlgorithm", 4_096);
signature = requireBytes(signature, "signature", 65_536);
List<byte[]> exact = new ArrayList<>();
for (byte[] certificate : Objects.requireNonNull(certificates, "certificates")) {
exact.add(requireBytes(certificate, "certificate", MAXIMUM_CERTIFICATE_BYTES));
}
certificates = List.copyOf(exact);
}
@Override public byte[] tbsRequest() { return tbsRequest.clone(); }
@Override public byte[] requestorName() { return requestorName.clone(); }
@Override public byte[] signatureAlgorithm() { return signatureAlgorithm.clone(); }
@Override public byte[] signature() { return signature.clone(); }
@Override public List<byte[]> certificates() {
return certificates.stream().map(byte[]::clone).toList();
}
}
/** Safe successful authentication result. */
public record Authorized(String principalId) {
/** Validates the mapped principal identity. */
public Authorized { Permission.requirePrincipal(principalId); }
}
/** Safe authentication or authorization rejection. */
public static final class UnauthorizedException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final String classification;
/** Creates a redacted rejection. */
public UnauthorizedException() { this("UNSPECIFIED"); }
private UnauthorizedException(String classification) {
super("OCSP requester is unauthorized");
this.classification = classification;
}
/** @return safe non-secret internal rejection classification */
public String classification() { return classification; }
}
/** Dependency corruption or recovery-required authorization authority. */
public static final class UnavailableException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Creates a redacted unavailable result. */
public UnavailableException() { super("OCSP requester authority is unavailable"); }
}
private final ServerRealmContext realm;
private final Clock clock;
/** Creates one realm-bound requester authentication service. */
public OcspRequesterService(ServerRealmContext realm, Clock clock) {
this.realm = Objects.requireNonNull(realm, "realm");
this.clock = Objects.requireNonNull(clock, "clock");
}
/**
* Authenticates and authorizes every CertID in one signed request. Any failure
* rejects the whole request without revealing the failed entry.
*/
public Authorized authenticate(OcspResponderService.Responder responder, SignedRequest request,
List<OcspResponseService.CertId> requests) {
try {
OcspResponderService.RequesterTrustPolicy policy = responder.requesterTrustPolicy().orElseThrow();
List<X509Certificate> chain = certificates(request.certificates());
X509Certificate signer = chain.getFirst();
validateSigner(signer, request.requestorName());
validateTrust(policy, chain);
verifySignature(policy, signer, request);
String principalId = mapPrincipal(policy, signer);
SecurityPrincipal principal = realm.principal(principalId);
if (!principal.enabled()) throw new UnauthorizedException("PRINCIPAL_DISABLED");
validateRevocation(policy, signer);
for (OcspResponseService.CertId certId : requests) {
requireIssuer(responder, certId);
if (!realm.authorizeOcspQuery(principalId, responder).allowed()) {
throw new UnauthorizedException("QUERY_DENIED");
}
}
return new Authorized(principalId);
} catch (UnauthorizedException rejection) {
throw rejection;
} catch (IllegalStateException unavailable) {
throw new UnavailableException();
} catch (RuntimeException | CertificateException | IOException failure) {
throw new UnauthorizedException("VALIDATION_REJECTED");
}
}
private void validateSigner(X509Certificate signer, byte[] requestorName) throws CertificateException {
signer.checkValidity(Date.from(clock.instant()));
requireCanonical(signer);
if (signer.hasUnsupportedCriticalExtension()) throw new CertificateException("Unsupported critical extension");
boolean[] usage = signer.getKeyUsage();
if (usage != null && (usage.length == 0 || !usage[0])) throw new CertificateException("Signing usage denied");
if (!Arrays.equals(requestorName, signer.getSubjectX500Principal().getEncoded())) {
throw new UnauthorizedException("REQUESTOR_NAME");
}
}
private void validateTrust(OcspResponderService.RequesterTrustPolicy policy, List<X509Certificate> chain)
throws CertificateException, IOException {
int matches = 0;
String signerCommitment = digest(chain.getFirst().getEncoded());
for (OcspResponderService.TrustSelector selector : policy.trustSelectors()) {
if (selector.type() == OcspResponderService.TrustSelectorType.EXACT_REQUESTER_CERTIFICATE_COMMITMENTS) {
if (selector.identities().contains(signerCommitment) && chain.size() == 1) matches++;
continue;
}
for (String identity : selector.identities()) {
X509Certificate anchor = selector.type() == OcspResponderService.TrustSelectorType.MANAGED_ISSUER_GENERATIONS
? issuerCertificate(new PkiId(identity)) : credentialCertificate(new PkiId(identity));
if (validPath(chain, anchor)) matches++;
}
}
if (matches != 1) throw new UnauthorizedException("TRUST_PATH");
}
private boolean validPath(List<X509Certificate> chain, X509Certificate anchor) {
try {
Set<String> seen = new HashSet<>();
for (X509Certificate certificate : chain) {
certificate.checkValidity(Date.from(clock.instant()));
requireCanonical(certificate);
if (certificate.hasUnsupportedCriticalExtension()) return false;
if (!seen.add(digest(certificate.getEncoded()))) return false;
}
anchor.checkValidity(Date.from(clock.instant()));
requireCanonical(anchor);
if (anchor.hasUnsupportedCriticalExtension()) return false;
for (int index = 0; index + 1 < chain.size(); index++) {
if (chain.get(index + 1).getBasicConstraints() < 0) return false;
boolean[] usage = chain.get(index + 1).getKeyUsage();
if (usage != null && (usage.length <= 5 || !usage[5])) return false;
verifyIssuedBy(chain.get(index), chain.get(index + 1));
}
X509Certificate last = chain.getLast();
if (Arrays.equals(last.getEncoded(), anchor.getEncoded())) return true;
if (anchor.getBasicConstraints() < 0) return false;
boolean[] anchorUsage = anchor.getKeyUsage();
if (anchorUsage != null && (anchorUsage.length <= 5 || !anchorUsage[5])) return false;
verifyIssuedBy(last, anchor);
return true;
} catch (CertificateException | RuntimeException failure) {
return false;
}
}
private static void verifyIssuedBy(X509Certificate certificate, X509Certificate issuer)
throws CertificateException {
if (!Arrays.equals(certificate.getIssuerX500Principal().getEncoded(),
issuer.getSubjectX500Principal().getEncoded())) throw new CertificateException("Issuer mismatch");
try { certificate.verify(issuer.getPublicKey()); }
catch (java.security.GeneralSecurityException failure) { throw new CertificateException("Signature mismatch"); }
}
private void verifySignature(OcspResponderService.RequesterTrustPolicy policy, X509Certificate signer,
SignedRequest request) throws IOException {
AlgorithmIdentifier algorithm = AlgorithmIdentifier.getInstance(ASN1Primitive.fromByteArray(
request.signatureAlgorithm()));
X509AlgorithmBinding selected = null;
for (OcspResponderService.RequestSignatureBinding reference : policy.signatureBindings()) {
X509AlgorithmBinding binding = realm.session().algorithmBindings().require(reference.bindingId(),
reference.commitment());
if (binding.role() == X509AlgorithmBinding.Role.OCSP_REQUEST_SIGNATURE
&& binding.oid().equals(algorithm.getAlgorithm().getId()) && parametersMatch(binding, algorithm)) {
if (selected != null) throw new UnauthorizedException("BINDING_AMBIGUOUS");
selected = binding;
}
}
if (selected == null || !supported(selected)) throw new UnauthorizedException("BINDING_REJECTED");
try {
ContentVerifier verifier = verifier(selected, signer).get(algorithm);
verifier.getOutputStream().write(request.tbsRequest());
verifier.getOutputStream().close();
if (!verifier.verify(request.signature())) throw new UnauthorizedException("SIGNATURE_INVALID");
} catch (org.bouncycastle.operator.OperatorCreationException | CertificateException failure) {
throw new UnauthorizedException("SIGNATURE_PROVIDER");
}
}
private static ContentVerifierProvider verifier(X509AlgorithmBinding binding, X509Certificate signer)
throws CertificateException, IOException, org.bouncycastle.operator.OperatorCreationException {
X509CertificateHolder holder = new X509CertificateHolder(signer.getEncoded());
if (binding.algorithmIdentity().equals(BootstrapAlgorithmIdentities.ECDSA_SHA256)) {
return new BcECContentVerifierProviderBuilder(DefaultDigestAlgorithmIdentifierFinder.INSTANCE)
.build(holder);
}
if (binding.algorithmIdentity().equals(BootstrapAlgorithmIdentities.ED25519_SIGNATURE)) {
return new BcEdDSAContentVerifierProviderBuilder().build(holder);
}
return new BcRSAContentVerifierProviderBuilder(DefaultDigestAlgorithmIdentifierFinder.INSTANCE)
.build(holder);
}
private static boolean supported(X509AlgorithmBinding binding) {
return binding.algorithmIdentity().equals(BootstrapAlgorithmIdentities.ECDSA_SHA256)
|| binding.algorithmIdentity().equals(BootstrapAlgorithmIdentities.ED25519_SIGNATURE)
|| binding.algorithmIdentity().equals(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256)
|| binding.algorithmIdentity().equals(BootstrapAlgorithmIdentities.RSA_PSS_SHA256);
}
private static boolean parametersMatch(X509AlgorithmBinding binding, AlgorithmIdentifier algorithm) {
return switch (binding.parameterRule()) {
case ABSENT -> algorithm.getParameters() == null;
case DER_NULL -> DERNull.INSTANCE.equals(algorithm.getParameters());
case STRUCTURED_DER -> strictPss(binding, algorithm);
};
}
private static boolean strictPss(X509AlgorithmBinding binding, AlgorithmIdentifier algorithm) {
if (!binding.algorithmIdentity().equals(BootstrapAlgorithmIdentities.RSA_PSS_SHA256)
|| !PKCSObjectIdentifiers.id_RSASSA_PSS.equals(algorithm.getAlgorithm())
|| algorithm.getParameters() == null) return false;
try {
RSASSAPSSparams parameters = RSASSAPSSparams.getInstance(algorithm.getParameters());
AlgorithmIdentifier hash = parameters.getHashAlgorithm();
AlgorithmIdentifier mask = parameters.getMaskGenAlgorithm();
AlgorithmIdentifier maskHash = AlgorithmIdentifier.getInstance(mask.getParameters());
return NISTObjectIdentifiers.id_sha256.equals(hash.getAlgorithm())
&& DERNull.INSTANCE.equals(hash.getParameters())
&& PKCSObjectIdentifiers.id_mgf1.equals(mask.getAlgorithm())
&& NISTObjectIdentifiers.id_sha256.equals(maskHash.getAlgorithm())
&& DERNull.INSTANCE.equals(maskHash.getParameters())
&& BigInteger.valueOf(32).equals(parameters.getSaltLength())
&& BigInteger.ONE.equals(parameters.getTrailerField());
} catch (RuntimeException failure) { return false; }
}
private String mapPrincipal(OcspResponderService.RequesterTrustPolicy policy, X509Certificate signer)
throws CertificateException {
String certificate = digest(signer.getEncoded());
String spki = digest(signer.getPublicKey().getEncoded());
MessageDigest issuerSerial = sha256();
issuerSerial.update(signer.getIssuerX500Principal().getEncoded());
issuerSerial.update((byte) 0);
issuerSerial.update(positiveSerial(signer).toByteArray());
String exactIssuerSerial = HexFormat.of().formatHex(issuerSerial.digest());
List<OcspResponderService.PrincipalMapping> matches = policy.principalMappings().stream()
.filter(mapping -> switch (mapping.type()) {
case SHA256_CERTIFICATE_COMMITMENT -> mapping.commitment().equals(certificate);
case SHA256_SPKI_COMMITMENT -> mapping.commitment().equals(spki);
case ISSUER_AND_SERIAL_COMMITMENT -> mapping.commitment().equals(exactIssuerSerial);
}).toList();
if (matches.size() != 1) throw new UnauthorizedException("PRINCIPAL_MAPPING");
return matches.getFirst().principalId();
}
private void validateRevocation(OcspResponderService.RequesterTrustPolicy policy, X509Certificate signer)
throws CertificateException {
Optional<Credential> managed = managedCredential(policy, signer);
if (policy.revocationPolicy() == OcspResponderService.RequesterRevocationPolicy.LOCAL_AUTHORITATIVE_REQUIRED
&& managed.isEmpty()) throw new UnauthorizedException("LOCAL_SIGNER_REQUIRED");
if (policy.revocationPolicy() != OcspResponderService.RequesterRevocationPolicy.VALIDITY_AND_TRUST_ONLY
&& managed.isPresent()) {
RevocationState state = realm.session().revocations().get(managed.orElseThrow().credentialId())
.map(record -> record.transition().state()).orElse(RevocationState.CLEAR);
if (state != RevocationState.CLEAR) throw new UnauthorizedException("SIGNER_REVOKED");
}
}
private Optional<Credential> managedCredential(OcspResponderService.RequesterTrustPolicy policy,
X509Certificate signer) throws CertificateException {
BigInteger serial = positiveSerial(signer);
String commitment = digest(signer.getEncoded());
List<Credential> matches = new ArrayList<>();
for (OcspResponderService.TrustSelector selector : policy.trustSelectors()) {
if (selector.type() != OcspResponderService.TrustSelectorType.MANAGED_ISSUER_GENERATIONS) continue;
for (String identity : selector.identities()) {
realm.session().repository().credential(new PkiId(identity), serial)
.filter(credential -> credentialCommitment(credential).equals(commitment)).ifPresent(matches::add);
}
}
if (matches.size() > 1) throw new UnauthorizedException("MANAGED_SIGNER_AMBIGUOUS");
return matches.stream().findFirst();
}
private void requireIssuer(OcspResponderService.Responder responder, OcspResponseService.CertId certId) {
X509Certificate issuer = issuerCertificate(responder.issuerId());
try {
byte[] name = MessageDigest.getInstance(certId.hash() == OcspResponseService.CertIdHash.SHA1
? "SHA-1" : "SHA-256").digest(issuer.getSubjectX500Principal().getEncoded());
X509CertificateHolder holder = new X509CertificateHolder(issuer.getEncoded());
byte[] key = MessageDigest.getInstance(certId.hash() == OcspResponseService.CertIdHash.SHA1
? "SHA-1" : "SHA-256").digest(holder.getSubjectPublicKeyInfo().getPublicKeyData().getBytes());
if (!MessageDigest.isEqual(name, certId.issuerNameHash())
|| !MessageDigest.isEqual(key, certId.issuerKeyHash())) {
throw new UnauthorizedException("ISSUER_SCOPE");
}
} catch (CertificateException | IOException | NoSuchAlgorithmException failure) {
throw new UnavailableException();
}
}
private X509Certificate issuerCertificate(PkiId issuerId) {
PkiId credentialId = realm.session().repository().issuer(issuerId).orElseThrow().credentialId();
return credentialCertificate(credentialId);
}
private X509Certificate credentialCertificate(PkiId credentialId) {
try (PkiRepositoryContent content = realm.session().repository().openCredential(credentialId);
InputStream input = content.openStream()) {
byte[] encoded = input.readNBytes(MAXIMUM_CERTIFICATE_BYTES + 1);
if (encoded.length > MAXIMUM_CERTIFICATE_BYTES || input.read() != -1) throw new IllegalStateException();
return parseCertificate(encoded);
} catch (IOException | CertificateException failure) { throw new IllegalStateException(); }
}
private String credentialCommitment(Credential credential) {
try {
return digest(credentialCertificate(credential.credentialId()).getEncoded());
} catch (CertificateException failure) {
throw new IllegalStateException("Managed requester certificate is unavailable");
}
}
private static List<X509Certificate> certificates(List<byte[]> encoded) throws CertificateException {
if (encoded.isEmpty()) throw new CertificateException("Signer certificate is required");
List<X509Certificate> result = new ArrayList<>(encoded.size());
for (byte[] certificate : encoded) result.add(parseCertificate(certificate));
return List.copyOf(result);
}
private static X509Certificate parseCertificate(byte[] encoded) throws CertificateException {
ByteArrayInputStream input = new ByteArrayInputStream(encoded);
X509Certificate certificate = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(input);
if (input.available() != 0 || !Arrays.equals(encoded, certificate.getEncoded())) {
throw new CertificateException("Certificate is not canonical");
}
return certificate;
}
private static void requireCanonical(X509Certificate certificate) throws CertificateException {
parseCertificate(certificate.getEncoded());
}
private static BigInteger positiveSerial(X509Certificate certificate) {
if (certificate.getSerialNumber().signum() <= 0) throw new UnauthorizedException("SERIAL_INVALID");
return certificate.getSerialNumber();
}
private static String digest(byte[] value) { return HexFormat.of().formatHex(sha256().digest(value)); }
private static MessageDigest sha256() {
try { return MessageDigest.getInstance("SHA-256"); }
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable"); }
}
private static byte[] requireBytes(byte[] value, String field, int maximum) {
Objects.requireNonNull(value, field);
if (value.length == 0 || value.length > maximum) throw new IllegalArgumentException(field + " is invalid");
return value.clone();
}
}

View File

@@ -50,6 +50,7 @@ import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Map;
import java.util.Set; import java.util.Set;
import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.Extension;
@@ -60,6 +61,7 @@ import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
import zeroecho.pki.api.ca.IssuerChainPath; import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration; import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.application.OcspResponseService; import zeroecho.pki.application.OcspResponseService;
@@ -72,6 +74,123 @@ import zeroecho.pki.application.PkiRepository;
"PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException", "PMD.UseEnumCollections", "PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException", "PMD.UseEnumCollections",
"PMD.ExceptionAsFlowControl" }) "PMD.ExceptionAsFlowControl" })
public final class OcspResponderService { public final class OcspResponderService {
/** Closed signed-request authentication policy. */
public enum RequestAuthenticationMode {
UNSIGNED_ONLY(1), UNSIGNED_OR_AUTHORIZED_SIGNED(2), AUTHORIZED_SIGNED_REQUIRED(3);
private final int code;
RequestAuthenticationMode(int code) { this.code = code; }
/** Stable persistence code. */ public int code() { return code; }
/** Resolves an exact stable code. */
public static RequestAuthenticationMode fromCode(int code) {
return switch (code) { case 1 -> UNSIGNED_ONLY; case 2 -> UNSIGNED_OR_AUTHORIZED_SIGNED;
case 3 -> AUTHORIZED_SIGNED_REQUIRED;
default -> throw new IllegalArgumentException("Unknown OCSP request-authentication mode code"); };
}
}
/** Closed local requester-certificate revocation behavior. */
public enum RequesterRevocationPolicy {
LOCAL_AUTHORITATIVE_REQUIRED(1), LOCAL_AUTHORITATIVE_IF_MANAGED(2), VALIDITY_AND_TRUST_ONLY(3);
private final int code;
RequesterRevocationPolicy(int code) { this.code = code; }
/** Stable persistence code. */ public int code() { return code; }
/** Resolves an exact stable code. */
public static RequesterRevocationPolicy fromCode(int code) {
return switch (code) { case 1 -> LOCAL_AUTHORITATIVE_REQUIRED;
case 2 -> LOCAL_AUTHORITATIVE_IF_MANAGED; case 3 -> VALIDITY_AND_TRUST_ONLY;
default -> throw new IllegalArgumentException("Unknown OCSP requester revocation policy code"); };
}
}
/** Repository-backed requester trust selector. */
@SuppressWarnings("PMD.LongVariable")
public enum TrustSelectorType {
MANAGED_ISSUER_GENERATIONS(1), CONFIGURED_TRUST_ANCHORS(2), EXACT_REQUESTER_CERTIFICATE_COMMITMENTS(3);
private final int code;
TrustSelectorType(int code) { this.code = code; }
/** Stable persistence code. */ public int code() { return code; }
/** Resolves an exact stable code. */
public static TrustSelectorType fromCode(int code) {
return switch (code) { case 1 -> MANAGED_ISSUER_GENERATIONS; case 2 -> CONFIGURED_TRUST_ANCHORS;
case 3 -> EXACT_REQUESTER_CERTIFICATE_COMMITMENTS;
default -> throw new IllegalArgumentException("Unknown OCSP requester trust-selector code"); };
}
}
/** Cryptographic requester-to-principal mapping type. */
public enum PrincipalMappingType {
SHA256_CERTIFICATE_COMMITMENT(1), SHA256_SPKI_COMMITMENT(2), ISSUER_AND_SERIAL_COMMITMENT(3);
private final int code;
PrincipalMappingType(int code) { this.code = code; }
/** Stable persistence code. */ public int code() { return code; }
/** Resolves an exact stable code. */
public static PrincipalMappingType fromCode(int code) {
return switch (code) { case 1 -> SHA256_CERTIFICATE_COMMITMENT;
case 2 -> SHA256_SPKI_COMMITMENT; case 3 -> ISSUER_AND_SERIAL_COMMITMENT;
default -> throw new IllegalArgumentException("Unknown OCSP requester mapping code"); };
}
}
/** One explicit repository-backed trust selector. */
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
public record TrustSelector(TrustSelectorType type, Set<String> identities) {
/** Validates and snapshots finite identities. */
public TrustSelector {
Objects.requireNonNull(type, "type"); identities = Set.copyOf(Objects.requireNonNull(identities));
if (identities.isEmpty() || identities.size() > 64) {
throw new IllegalArgumentException("OCSP requester trust selector is empty or oversized");
}
for (String identity : identities) {
if (type == TrustSelectorType.EXACT_REQUESTER_CERTIFICATE_COMMITMENTS) requireDigest(identity);
else new PkiId(identity);
}
}
}
/** One exact active request-signature binding reference. */
public record RequestSignatureBinding(String bindingId, String commitment) {
/** Validates stable binding identity and commitment. */
public RequestSignatureBinding {
Permission.requireId(bindingId, "OCSP request-signature binding");
Permission.requireBounded(commitment, 512, "OCSP request-signature commitment");
}
}
/** One cryptographic requester-certificate mapping. */
public record PrincipalMapping(String mappingId, String principalId, PrincipalMappingType type,
String commitment) {
/** Validates stable mapping metadata and fixed commitment. */
public PrincipalMapping {
Permission.requireId(mappingId, "OCSP requester mapping"); Permission.requirePrincipal(principalId);
Objects.requireNonNull(type, "type"); requireDigest(commitment);
}
}
/** Finite immutable requester trust, mapping, signature and chain policy. */
public record RequesterTrustPolicy(List<TrustSelector> trustSelectors,
List<PrincipalMapping> principalMappings, List<RequestSignatureBinding> signatureBindings,
RequesterRevocationPolicy revocationPolicy, int maximumSignerCertificates,
int maximumSignerChainBytes) {
/** Validates and snapshots the complete closed policy. */
public RequesterTrustPolicy {
trustSelectors = List.copyOf(Objects.requireNonNull(trustSelectors));
principalMappings = List.copyOf(Objects.requireNonNull(principalMappings));
signatureBindings = List.copyOf(Objects.requireNonNull(signatureBindings));
Objects.requireNonNull(revocationPolicy, "revocationPolicy");
if (trustSelectors.isEmpty() || trustSelectors.size() > 16 || principalMappings.isEmpty()
|| principalMappings.size() > 256 || signatureBindings.isEmpty() || signatureBindings.size() > 16
|| maximumSignerCertificates < 1 || maximumSignerCertificates > 16
|| maximumSignerChainBytes < 1024 || maximumSignerChainBytes > 1_048_576) {
throw new IllegalArgumentException("OCSP requester trust policy bounds are invalid");
}
if (principalMappings.stream().map(PrincipalMapping::mappingId).distinct().count()
!= principalMappings.size()
|| signatureBindings.stream().map(RequestSignatureBinding::bindingId).distinct().count()
!= signatureBindings.size()) {
throw new IllegalArgumentException("OCSP requester policy identities are duplicated");
}
}
}
/** Exact signing authority. */ /** Exact signing authority. */
public enum SigningMode { public enum SigningMode {
ISSUER_SIGNED(1), DELEGATED_RESPONDER(2); ISSUER_SIGNED(1), DELEGATED_RESPONDER(2);
@@ -115,7 +234,8 @@ public final class OcspResponderService {
String signatureAlgorithm, Optional<String> signatureBindingId, String signatureBindingCommitment, String signatureAlgorithm, Optional<String> signatureBindingId, String signatureBindingCommitment,
OcspResponseService.ResponderId responderIdForm, Duration responseValidity, NoncePolicy noncePolicy, OcspResponseService.ResponderId responderIdForm, Duration responseValidity, NoncePolicy noncePolicy,
int maximumNonceBytes, Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumRequestBytes, int maximumNonceBytes, Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumRequestBytes,
int maximumEntries, Duration cacheLifetime) { int maximumEntries, Duration cacheLifetime, RequestAuthenticationMode requestAuthenticationMode,
Optional<RequesterTrustPolicy> requesterTrustPolicy) {
/** Snapshots the typed finite registration input. */ /** Snapshots the typed finite registration input. */
public Registration { public Registration {
Permission.requireId(responderId, "OCSP responder"); Permission.requireId(alias, "OCSP alias"); Permission.requireId(responderId, "OCSP responder"); Permission.requireId(alias, "OCSP alias");
@@ -128,6 +248,22 @@ public final class OcspResponderService {
Objects.requireNonNull(responseValidity); Objects.requireNonNull(noncePolicy); Objects.requireNonNull(responseValidity); Objects.requireNonNull(noncePolicy);
acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes)); acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes));
Objects.requireNonNull(cacheLifetime); Objects.requireNonNull(cacheLifetime);
Objects.requireNonNull(requestAuthenticationMode, "requestAuthenticationMode");
requesterTrustPolicy = Objects.requireNonNull(requesterTrustPolicy, "requesterTrustPolicy");
requirePolicyShape(requestAuthenticationMode, requesterTrustPolicy);
}
/** Creates the default public unsigned-only registration. */
public 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) {
this(responderId, alias, authorityId, issuerId, signingMode, responderCredentialId, signingKeyRef,
chainPathId, signatureAlgorithm, signatureBindingId, signatureBindingCommitment, responderIdForm,
responseValidity, noncePolicy, maximumNonceBytes, acceptedHashes, maximumRequestBytes,
maximumEntries, cacheLifetime, RequestAuthenticationMode.UNSIGNED_ONLY, Optional.empty());
} }
} }
@@ -138,7 +274,8 @@ public final class OcspResponderService {
String signatureBindingCommitment, OcspResponseService.ResponderId responderIdForm, String signatureBindingCommitment, OcspResponseService.ResponderId responderIdForm,
Duration responseValidity, NoncePolicy noncePolicy, int maximumNonceBytes, Duration responseValidity, NoncePolicy noncePolicy, int maximumNonceBytes,
Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumRequestBytes, Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumRequestBytes,
int maximumEntries, Duration cacheLifetime, State state, Instant createdAt, int maximumEntries, Duration cacheLifetime, RequestAuthenticationMode requestAuthenticationMode,
Optional<RequesterTrustPolicy> requesterTrustPolicy, State state, Instant createdAt,
String configurationCommitment) { String configurationCommitment) {
/** Validates all finite immutable dependencies and the record commitment. */ /** Validates all finite immutable dependencies and the record commitment. */
public Responder { public Responder {
@@ -157,6 +294,9 @@ public final class OcspResponderService {
acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes)); acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes));
if (acceptedHashes.isEmpty()) throw new IllegalArgumentException("OCSP CertID hashes are empty"); if (acceptedHashes.isEmpty()) throw new IllegalArgumentException("OCSP CertID hashes are empty");
positive(cacheLifetime, responseValidity, "cache lifetime"); Objects.requireNonNull(state); positive(cacheLifetime, responseValidity, "cache lifetime"); Objects.requireNonNull(state);
Objects.requireNonNull(requestAuthenticationMode, "requestAuthenticationMode");
requesterTrustPolicy = Objects.requireNonNull(requesterTrustPolicy, "requesterTrustPolicy");
requirePolicyShape(requestAuthenticationMode, requesterTrustPolicy);
Objects.requireNonNull(createdAt); requireDigest(configurationCommitment); Objects.requireNonNull(createdAt); requireDigest(configurationCommitment);
} }
} }
@@ -167,6 +307,7 @@ public final class OcspResponderService {
private final X509AlgorithmBindingRegistry bindings; private final X509AlgorithmBindingRegistry bindings;
private final Optional<OcspResponseService> signing; private final Optional<OcspResponseService> signing;
private final Clock clock; private final Clock clock;
private final Map<String, Responder> aliasIndex = new java.util.concurrent.ConcurrentHashMap<>();
/** Binds responder control to one realm and one authoritative PKI repository. */ /** Binds responder control to one realm and one authoritative PKI repository. */
public OcspResponderService(RealmId realmId, ServerControlStore control, PkiRepository repository, public OcspResponderService(RealmId realmId, ServerControlStore control, PkiRepository repository,
@@ -185,7 +326,8 @@ public final class OcspResponderService {
supplied.chainPathId(), supplied.signatureAlgorithm(), supplied.signatureBindingId(), supplied.chainPathId(), supplied.signatureAlgorithm(), supplied.signatureBindingId(),
supplied.signatureBindingCommitment(), supplied.responderIdForm(), supplied.responseValidity(), supplied.signatureBindingCommitment(), supplied.responderIdForm(), supplied.responseValidity(),
supplied.noncePolicy(), supplied.maximumNonceBytes(), supplied.acceptedHashes(), supplied.noncePolicy(), supplied.maximumNonceBytes(), supplied.acceptedHashes(),
supplied.maximumRequestBytes(), supplied.maximumEntries(), supplied.cacheLifetime())); supplied.maximumRequestBytes(), supplied.maximumEntries(), supplied.cacheLifetime(),
supplied.requestAuthenticationMode(), supplied.requesterTrustPolicy()));
} }
/** Canonically seals an already constructed internal draft. */ /** Canonically seals an already constructed internal draft. */
@@ -196,12 +338,13 @@ public final class OcspResponderService {
if (!supplied.configurationCommitment().equals(commitment(supplied, true))) { if (!supplied.configurationCommitment().equals(commitment(supplied, true))) {
throw new IllegalArgumentException("OCSP responder commitment differs"); throw new IllegalArgumentException("OCSP responder commitment differs");
} }
if (!aliases(supplied.alias()).isEmpty()) { if (aliasIndex.containsKey(supplied.alias())) {
throw new IllegalStateException("OCSP responder alias already exists"); throw new IllegalStateException("OCSP responder alias already exists");
} }
validateDependencies(supplied, true); validateDependencies(supplied, true);
Responder sealed = seal(supplied, State.INACTIVE); Responder sealed = seal(supplied, State.INACTIVE);
control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(sealed), Optional.empty()))); control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(sealed), Optional.empty())));
aliasIndex.put(sealed.alias(), sealed);
return sealed; return sealed;
} }
@@ -217,15 +360,15 @@ public final class OcspResponderService {
if (result.state() != State.ACTIVE) { if (result.state() != State.ACTIVE) {
throw new IllegalStateException("OCSP responder is inactive"); throw new IllegalStateException("OCSP responder is inactive");
} }
validateDependencies(result, false); return result; return result;
} }
/** Resolves one unique durable alias regardless of activation state. */ /** Resolves one unique durable alias regardless of activation state. */
public Responder requireAlias(String alias) { public Responder requireAlias(String alias) {
Permission.requireId(alias, "OCSP alias"); Permission.requireId(alias, "OCSP alias");
List<Responder> matches = aliases(alias); Responder indexed = aliasIndex.get(alias);
if (matches.size() != 1) throw new IllegalArgumentException("OCSP responder alias is unavailable"); if (indexed == null) throw new IllegalArgumentException("OCSP responder alias is unavailable");
return matches.getFirst(); return indexed;
} }
/** Lists one bounded deterministic responder page. */ /** Lists one bounded deterministic responder page. */
@@ -236,33 +379,31 @@ public final class OcspResponderService {
page.nextOffset(), page.hasMore()); 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. */ /** Atomically changes only responder activation after dependency revalidation. */
public Responder setActive(String responderId, boolean active) { public Responder setActive(String responderId, boolean active) {
Responder prior = require(responderId); validateDependencies(prior, true); Responder prior = require(responderId); validateDependencies(prior, true);
Responder next = seal(prior, active ? State.ACTIVE : State.INACTIVE); Responder next = seal(prior, active ? State.ACTIVE : State.INACTIVE);
control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(next), control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(next),
Optional.of(record(prior).commitment())))); Optional.of(record(prior).commitment()))));
aliasIndex.put(next.alias(), next);
return next; return next;
} }
/** Revalidates every durable binding during realm recovery. */ /** Revalidates every durable binding during realm recovery. */
public void validateAll() { public void validateAll() {
Map<String, Responder> recovered = new java.util.HashMap<>();
int offset = 0; int offset = 0;
while (true) { while (true) {
ServerControlStore.Page<Responder> page = list(offset, 256); ServerControlStore.Page<Responder> page = list(offset, 256);
page.values().forEach(value -> validateDependencies(value, true)); page.values().forEach(value -> {
if (!page.hasMore()) return; validateDependencies(value, true);
if (recovered.putIfAbsent(value.alias(), value) != null) {
throw new IllegalStateException("OCSP responder alias is duplicated");
}
});
if (!page.hasMore()) {
aliasIndex.clear(); aliasIndex.putAll(recovered); return;
}
offset = page.nextOffset(); offset = page.nextOffset();
} }
} }
@@ -274,17 +415,32 @@ public final class OcspResponderService {
OcspResponseService.ResponderId responderIdForm, Duration validity, NoncePolicy noncePolicy, OcspResponseService.ResponderId responderIdForm, Duration validity, NoncePolicy noncePolicy,
int maximumNonceBytes, Set<OcspResponseService.CertIdHash> hashes, int maximumRequestBytes, int maximumNonceBytes, Set<OcspResponseService.CertIdHash> hashes, int maximumRequestBytes,
int maximumEntries, Duration cacheLifetime) { int maximumEntries, Duration cacheLifetime) {
return create(responderId, alias, authorityId, issuerId, signingMode, responderCredentialId,
signingKeyRef, chainPathId, signatureAlgorithm, signatureBindingId, bindingCommitment,
responderIdForm, validity, noncePolicy, maximumNonceBytes, hashes, maximumRequestBytes,
maximumEntries, cacheLifetime, RequestAuthenticationMode.UNSIGNED_ONLY, Optional.empty());
}
/** Creates a canonical unsealed record with explicit signed-request policy. */
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, RequestAuthenticationMode requestAuthenticationMode,
Optional<RequesterTrustPolicy> requesterTrustPolicy) {
Responder draft = new Responder(responderId, alias, realmId, authorityId, issuerId, signingMode, Responder draft = new Responder(responderId, alias, realmId, authorityId, issuerId, signingMode,
responderCredentialId, signingKeyRef, chainPathId, signatureAlgorithm, signatureBindingId, responderCredentialId, signingKeyRef, chainPathId, signatureAlgorithm, signatureBindingId,
bindingCommitment, responderIdForm, validity, noncePolicy, maximumNonceBytes, hashes, bindingCommitment, responderIdForm, validity, noncePolicy, maximumNonceBytes, hashes,
maximumRequestBytes, maximumEntries, cacheLifetime, State.INACTIVE, clock.instant(), maximumRequestBytes, maximumEntries, cacheLifetime, requestAuthenticationMode,
"0".repeat(64)); requesterTrustPolicy, State.INACTIVE, clock.instant(), "0".repeat(64));
return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(), return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(),
draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(), draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(),
draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(), draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(),
draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(), draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(),
draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(), draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(),
draft.state(), draft.createdAt(), commitment(draft, true)); draft.requestAuthenticationMode(), draft.requesterTrustPolicy(), draft.state(), draft.createdAt(),
commitment(draft, true));
} }
private Responder seal(Responder value, State state) { private Responder seal(Responder value, State state) {
@@ -293,13 +449,15 @@ public final class OcspResponderService {
value.chainPathId(), value.signatureAlgorithm(), value.signatureBindingId(), value.chainPathId(), value.signatureAlgorithm(), value.signatureBindingId(),
value.signatureBindingCommitment(), value.responderIdForm(), value.responseValidity(), value.signatureBindingCommitment(), value.responderIdForm(), value.responseValidity(),
value.noncePolicy(), value.maximumNonceBytes(), value.acceptedHashes(), value.maximumRequestBytes(), value.noncePolicy(), value.maximumNonceBytes(), value.acceptedHashes(), value.maximumRequestBytes(),
value.maximumEntries(), value.cacheLifetime(), state, value.createdAt(), value.configurationCommitment()); value.maximumEntries(), value.cacheLifetime(), value.requestAuthenticationMode(),
value.requesterTrustPolicy(), state, value.createdAt(), value.configurationCommitment());
return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(), return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(),
draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(), draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(),
draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(), draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(),
draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(), draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(),
draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(), draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(),
draft.state(), draft.createdAt(), commitment(draft, true)); draft.requestAuthenticationMode(), draft.requesterTrustPolicy(), draft.state(), draft.createdAt(),
commitment(draft, true));
} }
private void validateDependencies(Responder value, boolean proveSigning) { private void validateDependencies(Responder value, boolean proveSigning) {
@@ -310,6 +468,26 @@ public final class OcspResponderService {
throw new IllegalStateException("OCSP algorithm registry commitment differs"); throw new IllegalStateException("OCSP algorithm registry commitment differs");
} }
}); });
value.requesterTrustPolicy().ifPresent(policy -> {
for (RequestSignatureBinding reference : policy.signatureBindings()) {
X509AlgorithmBinding binding = bindings.require(reference.bindingId(), reference.commitment());
if (binding.role() != X509AlgorithmBinding.Role.OCSP_REQUEST_SIGNATURE) {
throw new IllegalStateException("OCSP requester signature binding has the wrong role");
}
}
for (TrustSelector selector : policy.trustSelectors()) {
if (selector.type() == TrustSelectorType.MANAGED_ISSUER_GENERATIONS) {
selector.identities().forEach(identity -> repository.issuer(new PkiId(identity)).orElseThrow(
() -> new IllegalStateException("OCSP requester issuer trust is unavailable")));
} else if (selector.type() == TrustSelectorType.CONFIGURED_TRUST_ANCHORS) {
selector.identities().forEach(identity -> {
try { certificate(new PkiId(identity)); }
catch (IOException failure) { throw new IllegalStateException("OCSP requester trust anchor is unavailable"); }
});
}
}
policy.principalMappings().forEach(mapping -> control.requirePrincipal(mapping.principalId()));
});
IssuerGeneration issuer = repository.issuer(value.issuerId()).orElseThrow(); IssuerGeneration issuer = repository.issuer(value.issuerId()).orElseThrow();
IssuerChainPath path = repository.chainPath(value.chainPathId()).orElseThrow(); IssuerChainPath path = repository.chainPath(value.chainPathId()).orElseThrow();
if (!issuer.authorityId().equals(value.authorityId()) || !path.authorityId().equals(value.authorityId()) if (!issuer.authorityId().equals(value.authorityId()) || !path.authorityId().equals(value.authorityId())
@@ -399,11 +577,25 @@ public final class OcspResponderService {
return sha256(encode(value, ignoreStored)); return sha256(encode(value, ignoreStored));
} }
/** Returns a safe deterministic commitment to one request-authentication policy. */
public static String requestPolicyCommitment(Registration value) {
Objects.requireNonNull(value, "value");
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(512);
DataOutputStream output = new DataOutputStream(bytes)) {
output.writeInt(value.requestAuthenticationMode().code());
output.writeBoolean(value.requesterTrustPolicy().isPresent());
if (value.requesterTrustPolicy().isPresent()) writePolicy(output, value.requesterTrustPolicy().orElseThrow());
output.flush(); return sha256(bytes.toByteArray());
} catch (IOException impossible) {
throw new IllegalStateException("OCSP requester policy encoding failed", impossible);
}
}
private static byte[] encode(Responder value) { return encode(value, false); } private static byte[] encode(Responder value) { return encode(value, false); }
private static byte[] encode(Responder value, boolean ignoreCommitment) { private static byte[] encode(Responder value, boolean ignoreCommitment) {
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(1024); try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(1024);
DataOutputStream output = new DataOutputStream(bytes)) { DataOutputStream output = new DataOutputStream(bytes)) {
output.writeInt(1); write(output, value.responderId()); write(output, value.alias()); output.writeInt(2); write(output, value.responderId()); write(output, value.alias());
write(output, value.realmId().value()); write(output, value.authorityId().value()); write(output, value.realmId().value()); write(output, value.authorityId().value());
write(output, value.issuerId().value()); output.writeInt(value.signingMode().code()); write(output, value.issuerId().value()); output.writeInt(value.signingMode().code());
write(output, value.responderCredentialId().value()); write(output, value.signingKeyRef().value()); write(output, value.responderCredentialId().value()); write(output, value.signingKeyRef().value());
@@ -417,7 +609,11 @@ public final class OcspResponderService {
output.writeInt(hash.code()); output.writeInt(hash.code());
} }
output.writeInt(value.maximumRequestBytes()); output.writeInt(value.maximumEntries()); output.writeInt(value.maximumRequestBytes()); output.writeInt(value.maximumEntries());
output.writeLong(value.cacheLifetime().toSeconds()); output.writeInt(value.state().code()); output.writeLong(value.cacheLifetime().toSeconds());
output.writeInt(value.requestAuthenticationMode().code());
output.writeBoolean(value.requesterTrustPolicy().isPresent());
if (value.requesterTrustPolicy().isPresent()) writePolicy(output, value.requesterTrustPolicy().orElseThrow());
output.writeInt(value.state().code());
output.writeLong(value.createdAt().toEpochMilli()); output.writeLong(value.createdAt().toEpochMilli());
write(output, ignoreCommitment ? "0".repeat(64) : value.configurationCommitment()); output.flush(); write(output, ignoreCommitment ? "0".repeat(64) : value.configurationCommitment()); output.flush();
return bytes.toByteArray(); return bytes.toByteArray();
@@ -427,7 +623,7 @@ public final class OcspResponderService {
private static Responder decode(byte[] payload) { private static Responder decode(byte[] payload) {
if (payload.length == 0 || payload.length > 65_536) throw new IllegalStateException("OCSP record bound differs"); if (payload.length == 0 || payload.length > 65_536) throw new IllegalStateException("OCSP record bound differs");
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) { try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) {
if (input.readInt() != 1) throw new IOException("OCSP record schema is obsolete"); if (input.readInt() != 2) throw new IOException("OCSP record schema is obsolete");
String id = read(input); String alias = read(input); RealmId realm = new RealmId(read(input)); 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)); PkiId authority = new PkiId(read(input)); PkiId issuer = new PkiId(read(input));
SigningMode mode = SigningMode.fromCode(input.readInt()); PkiId credential = new PkiId(read(input)); SigningMode mode = SigningMode.fromCode(input.readInt()); PkiId credential = new PkiId(read(input));
@@ -443,11 +639,14 @@ public final class OcspResponderService {
hashes.add(OcspResponseService.CertIdHash.fromCode(input.readInt())); hashes.add(OcspResponseService.CertIdHash.fromCode(input.readInt()));
} }
int requestBytes = input.readInt(); int entries = input.readInt(); Duration cache = Duration.ofSeconds(input.readLong()); int requestBytes = input.readInt(); int entries = input.readInt(); Duration cache = Duration.ofSeconds(input.readLong());
RequestAuthenticationMode authenticationMode = RequestAuthenticationMode.fromCode(input.readInt());
Optional<RequesterTrustPolicy> trustPolicy = input.readBoolean()
? Optional.of(readPolicy(input)) : Optional.empty();
State state = State.fromCode(input.readInt()); Instant created = Instant.ofEpochMilli(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"); 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, return new Responder(id, alias, realm, authority, issuer, mode, credential, key, path, algorithm,
binding, bindingCommitment, form, validity, nonce, nonceBytes, hashes, requestBytes, binding, bindingCommitment, form, validity, nonce, nonceBytes, hashes, requestBytes,
entries, cache, state, created, commitment); entries, cache, authenticationMode, trustPolicy, state, created, commitment);
} catch (IOException | RuntimeException failure) { } catch (IOException | RuntimeException failure) {
throw new IllegalStateException("OCSP responder record is invalid"); throw new IllegalStateException("OCSP responder record is invalid");
} }
@@ -458,6 +657,60 @@ public final class OcspResponderService {
if (encoded.length > 16_384) throw new IOException("OCSP string bound differs"); if (encoded.length > 16_384) throw new IOException("OCSP string bound differs");
output.writeInt(encoded.length); output.write(encoded); output.writeInt(encoded.length); output.write(encoded);
} }
private static void writePolicy(DataOutputStream output, RequesterTrustPolicy policy) throws IOException {
output.writeInt(policy.trustSelectors().size());
for (TrustSelector selector : policy.trustSelectors()) {
output.writeInt(selector.type().code()); output.writeInt(selector.identities().size());
for (String identity : selector.identities().stream().sorted().toList()) write(output, identity);
}
output.writeInt(policy.principalMappings().size());
for (PrincipalMapping mapping : policy.principalMappings().stream()
.sorted(java.util.Comparator.comparing(PrincipalMapping::mappingId)).toList()) {
write(output, mapping.mappingId()); write(output, mapping.principalId());
output.writeInt(mapping.type().code()); write(output, mapping.commitment());
}
output.writeInt(policy.signatureBindings().size());
for (RequestSignatureBinding binding : policy.signatureBindings().stream()
.sorted(java.util.Comparator.comparing(RequestSignatureBinding::bindingId)).toList()) {
write(output, binding.bindingId()); write(output, binding.commitment());
}
output.writeInt(policy.revocationPolicy().code()); output.writeInt(policy.maximumSignerCertificates());
output.writeInt(policy.maximumSignerChainBytes());
}
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private static RequesterTrustPolicy readPolicy(DataInputStream input) throws IOException {
int selectorCount = boundedCount(input.readInt(), 16, "trust selector");
List<TrustSelector> selectors = new ArrayList<>(selectorCount);
for (int index = 0; index < selectorCount; index++) {
TrustSelectorType type = TrustSelectorType.fromCode(input.readInt());
int identityCount = boundedCount(input.readInt(), 64, "trust identity");
Set<String> identities = new java.util.HashSet<>();
for (int identity = 0; identity < identityCount; identity++) {
if (!identities.add(read(input))) throw new IOException("Duplicate OCSP trust identity");
}
selectors.add(new TrustSelector(type, identities));
}
int mappingCount = boundedCount(input.readInt(), 256, "principal mapping");
List<PrincipalMapping> mappings = new ArrayList<>(mappingCount);
for (int index = 0; index < mappingCount; index++) {
mappings.add(new PrincipalMapping(read(input), read(input),
PrincipalMappingType.fromCode(input.readInt()), read(input)));
}
int bindingCount = boundedCount(input.readInt(), 16, "signature binding");
List<RequestSignatureBinding> bindings = new ArrayList<>(bindingCount);
for (int index = 0; index < bindingCount; index++) {
bindings.add(new RequestSignatureBinding(read(input), read(input)));
}
return new RequesterTrustPolicy(selectors, mappings, bindings,
RequesterRevocationPolicy.fromCode(input.readInt()), input.readInt(), input.readInt());
}
private static int boundedCount(int value, int maximum, String label) throws IOException {
if (value < 1 || value > maximum) throw new IOException("OCSP " + label + " count differs");
return value;
}
private static String read(DataInputStream input) throws IOException { 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"); 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"); byte[] encoded = input.readNBytes(length); if (encoded.length != length) throw new IOException("OCSP record is truncated");
@@ -472,6 +725,13 @@ public final class OcspResponderService {
private static void requireDigest(String value) { private static void requireDigest(String value) {
if (value == null || !value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("OCSP commitment is invalid"); if (value == null || !value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("OCSP commitment is invalid");
} }
private static void requirePolicyShape(RequestAuthenticationMode mode,
Optional<RequesterTrustPolicy> policy) {
if (mode == RequestAuthenticationMode.UNSIGNED_ONLY && policy.isPresent()
|| mode != RequestAuthenticationMode.UNSIGNED_ONLY && policy.isEmpty()) {
throw new IllegalArgumentException("OCSP requester trust policy does not match authentication mode");
}
}
private static void positive(Duration value, Duration maximum, String name) { private static void positive(Duration value, Duration maximum, String name) {
if (value == null || value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { if (value == null || value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException("OCSP " + name + " is invalid"); throw new IllegalArgumentException("OCSP " + name + " is invalid");

View File

@@ -434,7 +434,8 @@ public final class OperationSecurityDescriptors {
+ atom(value.registration().responderId()) + ";authority=" + atom(value.registration().responderId()) + ";authority="
+ atom(value.registration().authorityId().value()) + ";issuer=" + atom(value.registration().authorityId().value()) + ";issuer="
+ atom(value.registration().issuerId().value()) + ";binding=" + atom(value.registration().issuerId().value()) + ";binding="
+ value.registration().signatureBindingCommitment(); + value.registration().signatureBindingCommitment() + ";requestPolicy="
+ OcspResponderService.requestPolicyCommitment(value.registration());
case ServerControlOperation.InspectOcspResponder value -> "responder=" + atom(value.responderId()); case ServerControlOperation.InspectOcspResponder value -> "responder=" + atom(value.responderId());
case ServerControlOperation.ListOcspResponders value -> "offset=" + value.offset() case ServerControlOperation.ListOcspResponders value -> "offset=" + value.offset()
+ ";limit=" + value.limit(); + ";limit=" + value.limit();

View File

@@ -65,7 +65,8 @@ public final class Permission {
CERTIFICATE_READ_PII(76), CERTIFICATE_DOWNLOAD(77), CERTIFICATE_PUBLICATION_CHANGE(78), CERTIFICATE_READ_PII(76), CERTIFICATE_DOWNLOAD(77), CERTIFICATE_PUBLICATION_CHANGE(78),
CERTIFICATE_REVOKE(80), CERTIFICATE_HOLD(81), CERTIFICATE_RELEASE_HOLD(82), CERTIFICATE_REVOKE(80), CERTIFICATE_HOLD(81), CERTIFICATE_RELEASE_HOLD(82),
REVOCATION_HISTORY_READ(83), CRL_GENERATE(84), CRL_PUBLISH(85), CRL_DOWNLOAD(86), REVOCATION_HISTORY_READ(83), CRL_GENERATE(84), CRL_PUBLISH(85), CRL_DOWNLOAD(86),
OCSP_ADMINISTER(87), OCSP_RESPONDER_READ(88), PUBLICATION_REGISTER(100), PUBLICATION_READ(101), OCSP_ADMINISTER(87), OCSP_RESPONDER_READ(88), OCSP_STATUS_QUERY(89),
PUBLICATION_REGISTER(100), PUBLICATION_READ(101),
PUBLICATION_PROCESS(102), PUBLICATION_RETRY(103), PUBLICATION_RECONCILE(104), 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_READ_REDACTED(120), AUDIT_READ_FULL(121), AUDIT_READ_PII(122), AUDIT_EXPORT(123),
AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142), AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142),
@@ -181,17 +182,25 @@ public final class Permission {
* @param profileId explicit profile when applicable * @param profileId explicit profile when applicable
*/ */
public record Scope(RealmId realmId, Optional<PkiId> authorityId, Optional<PkiId> issuerId, public record Scope(RealmId realmId, Optional<PkiId> authorityId, Optional<PkiId> issuerId,
Optional<String> profileId) { Optional<String> profileId, Optional<String> responderId) {
/** Validates the exact finite scope. */ /** Validates the exact finite scope. */
public Scope { public Scope {
Objects.requireNonNull(realmId, "realmId"); Objects.requireNonNull(realmId, "realmId");
authorityId = Objects.requireNonNull(authorityId, "authorityId"); authorityId = Objects.requireNonNull(authorityId, "authorityId");
issuerId = Objects.requireNonNull(issuerId, "issuerId"); issuerId = Objects.requireNonNull(issuerId, "issuerId");
profileId = Objects.requireNonNull(profileId, "profileId").map(Permission::requireProfile); profileId = Objects.requireNonNull(profileId, "profileId").map(Permission::requireProfile);
responderId = Objects.requireNonNull(responderId, "responderId")
.map(value -> { requireId(value, "OCSP responder"); return value; });
if (issuerId.isPresent() && authorityId.isEmpty()) { if (issuerId.isPresent() && authorityId.isEmpty()) {
throw new IllegalArgumentException("Issuer scope requires authority scope"); throw new IllegalArgumentException("Issuer scope requires authority scope");
} }
} }
/** Creates a scope without an OCSP responder dimension. */
public Scope(RealmId realmId, Optional<PkiId> authorityId, Optional<PkiId> issuerId,
Optional<String> profileId) {
this(realmId, authorityId, issuerId, profileId, Optional.empty());
}
} }
/** /**

View File

@@ -419,6 +419,9 @@ public final class ServerControlOperationExecutor {
"authorityId", text(value.authorityId().value()), "issuerId", text(value.issuerId().value()), "authorityId", text(value.authorityId().value()), "issuerId", text(value.issuerId().value()),
"signingMode", text(value.signingMode().name()), "responderCredentialId", "signingMode", text(value.signingMode().name()), "responderCredentialId",
text(value.responderCredentialId().value()), "state", text(value.state().name()), text(value.responderCredentialId().value()), "state", text(value.state().name()),
"requestAuthenticationMode", text(value.requestAuthenticationMode().name()),
"requesterRevocationPolicy", text(value.requesterTrustPolicy()
.map(policy -> policy.revocationPolicy().name()).orElse("NOT_APPLICABLE")),
"configurationCommitment", text(value.configurationCommitment())); "configurationCommitment", text(value.configurationCommitment()));
} }
private static PkiOperationValue template(RoleTemplateCatalog.Template value) { private static PkiOperationValue template(RoleTemplateCatalog.Template value) {

View File

@@ -107,7 +107,7 @@ public final class ServerControlStore implements AutoCloseable {
public static final String OCSP_RESPONDER = "io.zeroecho.server.ocsp-responder"; public static final String OCSP_RESPONDER = "io.zeroecho.server.ocsp-responder";
private static final int MAGIC = 0x5a455331; private static final int MAGIC = 0x5a455331;
private static final int SCHEMA = 5; private static final int SCHEMA = 6;
private static final int MAXIMUM_RECORD_BYTES = 1_048_576; private static final int MAXIMUM_RECORD_BYTES = 1_048_576;
private static final int MAXIMUM_STRING_BYTES = 16_384; private static final int MAXIMUM_STRING_BYTES = 16_384;
private static final int MAXIMUM_COLLECTION = 4_096; private static final int MAXIMUM_COLLECTION = 4_096;
@@ -925,10 +925,12 @@ public final class ServerControlStore implements AutoCloseable {
private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException { private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException {
writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId()); writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId());
writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId()); writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId());
writeOptionalString(out, value.responderId());
} }
private static Permission.Scope readScope(DataInputStream in) throws IOException { private static Permission.Scope readScope(DataInputStream in) throws IOException {
return new Permission.Scope(new RealmId(readString(in)), readOptionalPki(in), readOptionalPki(in), readOptionalString(in)); return new Permission.Scope(new RealmId(readString(in)), readOptionalPki(in), readOptionalPki(in),
readOptionalString(in), readOptionalString(in));
} }
private static void writeString(DataOutputStream out, String value) throws IOException { private static void writeString(DataOutputStream out, String value) throws IOException {

View File

@@ -211,6 +211,39 @@ public final class ServerRealmContext implements AutoCloseable {
requireOpen(); requireOpen();
return control.requirePrincipal(principalId); return control.requirePrincipal(principalId);
} }
/**
* Evaluates the protocol-only OCSP query permission for one exact responder
* and issuer generation. The signed-request identity remains independent of
* administrative and transport authentication.
*
* @param principalId cryptographically mapped requester principal
* @param responder exact active responder binding
* @return default-deny authorization decision
*/
public AuthorizationEngine.Decision authorizeOcspQuery(String principalId,
OcspResponderService.Responder responder) {
requireOpen();
SecurityPrincipal principal = control.requirePrincipal(principalId);
java.util.List<Permission.Grant> grants = new java.util.ArrayList<>(control.grantsFor(principalId));
for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principalId)) {
grants.addAll(roles.instantiate(assignment));
}
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principalId);
grants.addAll(emergency.grants());
Permission.Scope scope = new Permission.Scope(configuration.realmId(),
Optional.of(responder.authorityId()), Optional.of(responder.issuerId()), Optional.empty(),
Optional.of(responder.responderId()));
Permission.Resource resource = new Permission.Resource(Permission.ResourceType.OCSP_RESPONDER,
scope, Optional.empty(), Optional.empty());
AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(
configuration.realmId(), configuration.authorityExposure(), principal,
Permission.Action.OCSP_STATUS_QUERY, resource, Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED, Permission.Context.empty(), grants,
emergency.grantIds()));
if (decision.usedBreakGlass()) breakGlass.auditUse(principalId);
return decision;
}
/** /**
* Records one transport-safe lifecycle or request classification through the * Records one transport-safe lifecycle or request classification through the
* shared realm audit authority. * shared realm audit authority.

View File

@@ -36,6 +36,7 @@ package zeroecho.pki.server.http;
import java.time.Instant; import java.time.Instant;
import java.time.Duration; import java.time.Duration;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@@ -243,9 +244,11 @@ final class HttpOperationCodec {
} }
case ServerControlOperation.CreateGrant.NAME -> { case ServerControlOperation.CreateGrant.NAME -> {
fields.allowed(Set.of("grantId", "principalId", "effect", "action", "resourceType", fields.allowed(Set.of("grantId", "principalId", "effect", "action", "resourceType",
"issuerId", "profileId", "relationship", "dataView", "conditions", "expiresAt")); "issuerId", "profileId", "responderId", "relationship", "dataView", "conditions",
"expiresAt"));
Permission.Scope scope = new Permission.Scope(realmId, authority, Permission.Scope scope = new Permission.Scope(realmId, authority,
fields.optionalText("issuerId").map(PkiId::new), fields.optionalText("profileId")); fields.optionalText("issuerId").map(PkiId::new), fields.optionalText("profileId"),
fields.optionalText("responderId"));
yield new ServerControlOperation.CreateGrant(new Permission.Grant(fields.text("grantId"), yield new ServerControlOperation.CreateGrant(new Permission.Grant(fields.text("grantId"),
fields.text("principalId"), Permission.Effect.valueOf(fields.text("effect")), fields.text("principalId"), Permission.Effect.valueOf(fields.text("effect")),
Permission.Action.valueOf(fields.text("action")), Permission.Action.valueOf(fields.text("action")),
@@ -432,11 +435,18 @@ final class HttpOperationCodec {
fields.exact("accountId"); yield new ServerControlOperation.DeactivateAcmeAccount(fields.text("accountId")); fields.exact("accountId"); yield new ServerControlOperation.DeactivateAcmeAccount(fields.text("accountId"));
} }
case ServerControlOperation.RegisterOcspResponder.NAME -> { case ServerControlOperation.RegisterOcspResponder.NAME -> {
fields.exact("responderId", "alias", "authorityId", "issuerId", "signingMode", fields.allowed(Set.of("responderId", "alias", "authorityId", "issuerId", "signingMode",
"responderCredentialId", "signingKeyRef", "chainPathId", "signatureAlgorithm", "responderCredentialId", "signingKeyRef", "chainPathId", "signatureAlgorithm",
"signatureBindingId", "signatureBindingCommitment", "responderIdForm", "signatureBindingId", "signatureBindingCommitment", "responderIdForm",
"responseValidityMillis", "noncePolicy", "maximumNonceBytes", "acceptedHashes", "responseValidityMillis", "noncePolicy", "maximumNonceBytes", "acceptedHashes",
"maximumRequestBytes", "maximumEntries", "cacheLifetimeMillis"); "maximumRequestBytes", "maximumEntries", "cacheLifetimeMillis",
"requestAuthenticationMode", "requesterTrustPolicy"));
OcspResponderService.RequestAuthenticationMode authenticationMode =
OcspResponderService.RequestAuthenticationMode.valueOf(
fields.text("requestAuthenticationMode"));
Optional<OcspResponderService.RequesterTrustPolicy> trustPolicy = fields.contains(
"requesterTrustPolicy") ? Optional.of(requesterTrustPolicy(
fields.object("requesterTrustPolicy"))) : Optional.empty();
yield new ServerControlOperation.RegisterOcspResponder(new OcspResponderService.Registration( yield new ServerControlOperation.RegisterOcspResponder(new OcspResponderService.Registration(
fields.text("responderId"), fields.text("alias"), fields.text("responderId"), fields.text("alias"),
fields.pkiId("authorityId"), fields.pkiId("issuerId"), fields.pkiId("authorityId"), fields.pkiId("issuerId"),
@@ -450,7 +460,8 @@ final class HttpOperationCodec {
fields.integer("maximumNonceBytes"), fields.integer("maximumNonceBytes"),
fields.enumSet("acceptedHashes", OcspResponseService.CertIdHash.class), fields.enumSet("acceptedHashes", OcspResponseService.CertIdHash.class),
fields.integer("maximumRequestBytes"), fields.integer("maximumEntries"), fields.integer("maximumRequestBytes"), fields.integer("maximumEntries"),
Duration.ofMillis(fields.longValue("cacheLifetimeMillis")))); Duration.ofMillis(fields.longValue("cacheLifetimeMillis")), authenticationMode,
trustPolicy));
} }
case ServerControlOperation.InspectOcspResponder.NAME -> { case ServerControlOperation.InspectOcspResponder.NAME -> {
fields.exact("responderId"); yield new ServerControlOperation.InspectOcspResponder(fields.text("responderId")); fields.exact("responderId"); yield new ServerControlOperation.InspectOcspResponder(fields.text("responderId"));
@@ -553,6 +564,34 @@ final class HttpOperationCodec {
}; };
} }
private static OcspResponderService.RequesterTrustPolicy requesterTrustPolicy(Fields fields) {
fields.exact("trustSelectors", "principalMappings", "signatureBindings", "revocationPolicy",
"maximumSignerCertificates", "maximumSignerChainBytes");
List<OcspResponderService.TrustSelector> selectors = fields.objects("trustSelectors").stream()
.map(value -> {
value.exact("type", "identities");
return new OcspResponderService.TrustSelector(
OcspResponderService.TrustSelectorType.valueOf(value.text("type")),
value.stringSet("identities"));
}).toList();
List<OcspResponderService.PrincipalMapping> mappings = fields.objects("principalMappings").stream()
.map(value -> {
value.exact("mappingId", "principalId", "type", "commitment");
return new OcspResponderService.PrincipalMapping(value.text("mappingId"),
value.text("principalId"), OcspResponderService.PrincipalMappingType.valueOf(
value.text("type")), value.text("commitment"));
}).toList();
List<OcspResponderService.RequestSignatureBinding> bindings = fields.objects("signatureBindings").stream()
.map(value -> {
value.exact("bindingId", "commitment");
return new OcspResponderService.RequestSignatureBinding(value.text("bindingId"),
value.text("commitment"));
}).toList();
return new OcspResponderService.RequesterTrustPolicy(selectors, mappings, bindings,
OcspResponderService.RequesterRevocationPolicy.valueOf(fields.text("revocationPolicy")),
fields.integer("maximumSignerCertificates"), fields.integer("maximumSignerChainBytes"));
}
private static Duration duration(String value) { private static Duration duration(String value) {
try { try {
Duration result = Duration.parse(value); Duration result = Duration.parse(value);
@@ -587,6 +626,7 @@ final class HttpOperationCodec {
if (!(value instanceof PkiOperationValue.Text text)) throw type(); return text.value(); } if (!(value instanceof PkiOperationValue.Text text)) throw type(); return text.value(); }
Optional<String> optionalText(String name) { return fields.containsKey(name) Optional<String> optionalText(String name) { return fields.containsKey(name)
? Optional.of(text(name)) : Optional.empty(); } ? Optional.of(text(name)) : Optional.empty(); }
boolean contains(String name) { return fields.containsKey(name); }
long longValue(String name) { consumed.add(name); PkiOperationValue value = require(name); long longValue(String name) { consumed.add(name); PkiOperationValue value = require(name);
if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); } if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); }
int integer(String name) { return Math.toIntExact(longValue(name)); } int integer(String name) { return Math.toIntExact(longValue(name)); }
@@ -618,6 +658,11 @@ final class HttpOperationCodec {
return Set.copyOf(result); return Set.copyOf(result);
} }
Fields object(String name) { consumed.add(name); return of(require(name)); } Fields object(String name) { consumed.add(name); return of(require(name)); }
List<Fields> objects(String name) {
consumed.add(name); PkiOperationValue value = require(name);
if (!(value instanceof PkiOperationValue.ListValue list)) throw type();
return list.values().stream().map(Fields::of).toList();
}
void complete() { if (!Objects.equals(consumed, fields.keySet())) void complete() { if (!Objects.equals(consumed, fields.keySet()))
throw new IllegalArgumentException("Request fields were not consumed"); } throw new IllegalArgumentException("Request fields were not consumed"); }
private PkiOperationValue require(String name) { PkiOperationValue value = fields.get(name); private PkiOperationValue require(String name) { PkiOperationValue value = fields.get(name);

View File

@@ -70,6 +70,7 @@ import zeroecho.pki.application.OcspResponseService;
import zeroecho.pki.application.PkiRepositoryContent; import zeroecho.pki.application.PkiRepositoryContent;
import zeroecho.pki.server.AdministrativeAuthenticationMode; import zeroecho.pki.server.AdministrativeAuthenticationMode;
import zeroecho.pki.server.OcspResponderService; import zeroecho.pki.server.OcspResponderService;
import zeroecho.pki.server.OcspRequesterService;
import zeroecho.pki.server.PkiServerConfiguration; import zeroecho.pki.server.PkiServerConfiguration;
import zeroecho.pki.server.ServerRealmContext; import zeroecho.pki.server.ServerRealmContext;
import zeroecho.pki.server.spi.PkiServerAuthenticationContext; import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
@@ -86,6 +87,8 @@ final class OcspHttpHandler implements HttpHandler {
private final Clock clock; private final Clock clock;
private final RequestIds requestIds; private final RequestIds requestIds;
private final BooleanSupplier ready; private final BooleanSupplier ready;
private final OcspRequesterService requesters;
private final NonceReplayGuard nonceReplay = new NonceReplayGuard();
OcspHttpHandler(PkiServerConfiguration.PublicListener configuration, ServerRealmContext realm, OcspHttpHandler(PkiServerConfiguration.PublicListener configuration, ServerRealmContext realm,
AdministrativeAuthenticator authenticator, ServerRuntime runtime, Clock clock, AdministrativeAuthenticator authenticator, ServerRuntime runtime, Clock clock,
@@ -94,40 +97,63 @@ final class OcspHttpHandler implements HttpHandler {
this.authenticator = java.util.Objects.requireNonNull(authenticator); this.runtime = java.util.Objects.requireNonNull(runtime); 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.clock = java.util.Objects.requireNonNull(clock); this.requestIds = java.util.Objects.requireNonNull(requestIds);
this.ready = java.util.Objects.requireNonNull(ready); this.ready = java.util.Objects.requireNonNull(ready);
this.requesters = new OcspRequesterService(realm, clock);
} }
@Override public void handle(HttpExchange exchange) throws IOException { @Override public void handle(HttpExchange exchange) throws IOException {
String requestId = "unavailable-request"; boolean admitted = false; String requestId = "unavailable-request"; boolean admitted = false;
OcspResponderService.Responder auditedResponder = null; OcspResponderService.Responder auditedResponder = null;
Optional<String> transportPrincipal = Optional.empty();
Optional<String> requesterPrincipal = Optional.empty();
try { try {
requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER)); requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER));
if (!ready.getAsBoolean()) { transportFailure(exchange, 503); return; } if (!ready.getAsBoolean()) { transportFailure(exchange, 503); return; }
requireHeadersBounded(exchange.getRequestHeaders()); requireHeadersBounded(exchange.getRequestHeaders());
validateTransport(exchange, requestId); transportPrincipal = validateTransport(exchange, requestId);
Route route = route(exchange); Route route = route(exchange);
if (!runtime.tryAdmit()) { audit(requestId, Optional.empty(), transportPrincipal, Optional.empty(),
"OVERLOAD"); transportFailure(exchange, 429); return; }
admitted = true;
OcspResponderService.Responder responder = activeResponder(route.alias()); OcspResponderService.Responder responder = activeResponder(route.alias());
auditedResponder = responder; auditedResponder = responder;
audit(requestId, Optional.of(responder), "ACCEPTED"); audit(requestId, Optional.of(responder), transportPrincipal, Optional.empty(), "ACCEPTED");
if (!runtime.tryAdmit()) { audit(requestId, Optional.of(responder), "OVERLOAD"); transportFailure(exchange, 429); return; }
admitted = true;
int maximumRequestBytes = Math.min(configuration.maximumBodyBytes(), responder.maximumRequestBytes()); int maximumRequestBytes = Math.min(configuration.maximumBodyBytes(), responder.maximumRequestBytes());
byte[] request = route.encoded().orElseGet(() -> read(exchange, maximumRequestBytes)); byte[] request = route.encoded().orElseGet(() -> read(exchange, maximumRequestBytes));
if (request.length > maximumRequestBytes) { transportFailure(exchange, 413); return; } if (request.length > maximumRequestBytes) { transportFailure(exchange, 413); return; }
OcspRequestParser.Parsed parsed = OcspRequestParser.parse(request, responder.maximumEntries(), OcspRequestParser.Parsed parsed = OcspRequestParser.parse(request, responder.maximumEntries(),
responder.acceptedHashes(), responder.maximumNonceBytes()); responder.acceptedHashes(), responder.maximumNonceBytes(),
responder.requesterTrustPolicy().map(OcspResponderService.RequesterTrustPolicy
::maximumSignerCertificates).orElse(16),
responder.requesterTrustPolicy().map(OcspResponderService.RequesterTrustPolicy
::maximumSignerChainBytes).orElse(maximumRequestBytes));
validateNonce(responder.noncePolicy(), parsed.nonce()); validateNonce(responder.noncePolicy(), parsed.nonce());
OcspResponseService.Response response = execute(responder, parsed); requireAuthenticationMode(responder.requestAuthenticationMode(), parsed.signedRequest());
send(exchange, responder, response, parsed.nonce().isPresent(), requestId); Execution execution = execute(responder, parsed);
audit(requestId, Optional.of(responder), "RESPONDED_GOOD_" + response.goodCount() requesterPrincipal = execution.requesterPrincipal();
send(exchange, responder, execution.response(), parsed.nonce().isPresent(),
parsed.signedRequest().isPresent(), requestId);
OcspResponseService.Response response = execution.response();
audit(requestId, Optional.of(responder), transportPrincipal, requesterPrincipal,
"RESPONDED_GOOD_" + response.goodCount()
+ "_REVOKED_" + response.revokedCount() + "_UNKNOWN_" + response.unknownCount()); + "_REVOKED_" + response.revokedCount() + "_UNKNOWN_" + response.unknownCount());
} catch (UnknownAlias unavailable) { audit(requestId, Optional.empty(), "UNKNOWN_RESPONDER"); transportFailure(exchange, 404); } catch (SignatureRequired required) { audit(requestId, Optional.ofNullable(auditedResponder),
} catch (InactiveResponder inactive) { audit(requestId, Optional.empty(), "INACTIVE_RESPONDER"); transportFailure(exchange, 503); transportPrincipal, requesterPrincipal, "SIGNATURE_REQUIRED"); protocolFailure(exchange,
} catch (MethodFailure method) { audit(requestId, Optional.ofNullable(auditedResponder), "METHOD_REJECTED"); transportFailure(exchange, 405); org.bouncycastle.cert.ocsp.OCSPRespBuilder.SIG_REQUIRED);
} catch (MediaFailure media) { audit(requestId, Optional.ofNullable(auditedResponder), "MEDIA_REJECTED"); transportFailure(exchange, 406); } catch (OcspRequesterService.UnauthorizedException unauthorized) { audit(requestId,
} catch (IllegalArgumentException malformed) { audit(requestId, Optional.ofNullable(auditedResponder), "MALFORMED"); protocolFailure(exchange, org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST); Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal,
} catch (RejectedExecutionException overload) { audit(requestId, Optional.ofNullable(auditedResponder), "OVERLOAD"); transportFailure(exchange, 429); "REQUESTER_UNAUTHORIZED_" + unauthorized.classification()); protocolFailure(exchange,
} catch (TimeoutException deadline) { audit(requestId, Optional.ofNullable(auditedResponder), "DEADLINE"); transportFailure(exchange, 504); org.bouncycastle.cert.ocsp.OCSPRespBuilder.UNAUTHORIZED);
} catch (RuntimeException failure) { audit(requestId, Optional.ofNullable(auditedResponder), "UNAVAILABLE"); transportFailure(exchange, 503); } catch (OcspRequesterService.UnavailableException unavailable) { audit(requestId,
Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal,
"REQUESTER_AUTHORITY_UNAVAILABLE"); protocolFailure(exchange, org.bouncycastle.cert.ocsp.OCSPRespBuilder.TRY_LATER);
} catch (UnknownAlias unavailable) { audit(requestId, Optional.empty(), transportPrincipal, requesterPrincipal, "UNKNOWN_RESPONDER"); transportFailure(exchange, 404);
} catch (InactiveResponder inactive) { audit(requestId, Optional.empty(), transportPrincipal, requesterPrincipal, "INACTIVE_RESPONDER"); transportFailure(exchange, 503);
} catch (MethodFailure method) { audit(requestId, Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal, "METHOD_REJECTED"); transportFailure(exchange, 405);
} catch (MediaFailure media) { audit(requestId, Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal, "MEDIA_REJECTED"); transportFailure(exchange, 406);
} catch (IllegalArgumentException malformed) { audit(requestId, Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal, "MALFORMED"); protocolFailure(exchange, org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST);
} catch (RejectedExecutionException overload) { audit(requestId, Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal, "OVERLOAD"); transportFailure(exchange, 429);
} catch (TimeoutException deadline) { audit(requestId, Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal, "DEADLINE"); transportFailure(exchange, 504);
} catch (RuntimeException failure) { audit(requestId, Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal, "UNAVAILABLE"); transportFailure(exchange, 503);
} finally { if (admitted) runtime.releaseAdmission(); exchange.close(); } } finally { if (admitted) runtime.releaseAdmission(); exchange.close(); }
} }
@@ -141,7 +167,7 @@ final class OcspHttpHandler implements HttpHandler {
} }
} }
private OcspResponseService.Response execute(OcspResponderService.Responder responder, private Execution execute(OcspResponderService.Responder responder,
OcspRequestParser.Parsed parsed) throws TimeoutException { OcspRequestParser.Parsed parsed) throws TimeoutException {
IssuerGeneration issuer = realm.session().repository().issuer(responder.issuerId()).orElseThrow(); IssuerGeneration issuer = realm.session().repository().issuer(responder.issuerId()).orElseThrow();
IssuerChainPath path = realm.session().repository().chainPath(responder.chainPathId()).orElseThrow(); IssuerChainPath path = realm.session().repository().chainPath(responder.chainPathId()).orElseThrow();
@@ -156,15 +182,26 @@ final class OcspHttpHandler implements HttpHandler {
responder.signingKeyRef(), chain, responder.signatureAlgorithm(), responder.signatureBindingId(), responder.signingKeyRef(), chain, responder.signatureAlgorithm(), responder.signatureBindingId(),
responder.responderIdForm(), produced, produced, produced.plus(responder.responseValidity()), responder.responderIdForm(), produced, produced, produced.plus(responder.responseValidity()),
parsed.nonce(), parsed.requests()); parsed.nonce(), parsed.requests());
ServerRuntime.Submitted<OcspResponseService.Response> submitted = runtime.submit(cancellation -> { ServerRuntime.Submitted<Execution> submitted = runtime.submit(cancellation -> {
cancellation.throwIfCancelled(); cancellation.throwIfCancelled();
return realm.session().ocsp().orElseThrow().respond(command); Optional<String> requester = parsed.signedRequest().map(value -> requesters.authenticate(responder,
value, parsed.requests()).principalId());
cancellation.throwIfCancelled();
if (responder.noncePolicy() == OcspResponderService.NoncePolicy.REQUIRED
&& !nonceReplay.accept(responder.responderId(), parsed.nonce().orElseThrow(), clock.instant(),
responder.responseValidity())) {
throw new IllegalArgumentException("OCSP nonce was already used");
}
return new Execution(realm.session().ocsp().orElseThrow().respond(command), requester);
}); });
try { try {
return submitted.future().get(configuration.maximumStreamDuration().toMillis(), TimeUnit.MILLISECONDS); return submitted.future().get(configuration.maximumStreamDuration().toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException failure) { } catch (InterruptedException failure) {
submitted.cancellation().cancel(); submitted.future().cancel(true); Thread.currentThread().interrupt(); submitted.cancellation().cancel(); submitted.future().cancel(true); Thread.currentThread().interrupt();
throw new TimeoutException("OCSP operation interrupted"); throw new TimeoutException("OCSP operation interrupted");
} catch (TimeoutException failure) {
submitted.cancellation().cancel(); submitted.future().cancel(true);
throw failure;
} catch (ExecutionException failure) { } catch (ExecutionException failure) {
if (failure.getCause() instanceof RuntimeException runtimeFailure) throw runtimeFailure; if (failure.getCause() instanceof RuntimeException runtimeFailure) throw runtimeFailure;
throw new IllegalStateException("OCSP operation failed"); throw new IllegalStateException("OCSP operation failed");
@@ -193,17 +230,19 @@ final class OcspHttpHandler implements HttpHandler {
} }
} }
private void validateTransport(HttpExchange exchange, String requestId) { private Optional<String> validateTransport(HttpExchange exchange, String requestId) {
Map<String, List<String>> headers = Map.copyOf(exchange.getRequestHeaders()); Map<String, List<String>> headers = Map.copyOf(exchange.getRequestHeaders());
boolean forwarded = ForwardedClientCertificateParser.containsForwardedIdentity(headers); boolean forwarded = ForwardedClientCertificateParser.containsForwardedIdentity(headers);
if (configuration.authentication().mode() == AdministrativeAuthenticationMode.DIRECT_MTLS) { if (configuration.authentication().mode() == AdministrativeAuthenticationMode.DIRECT_MTLS) {
if (forwarded) throw new IllegalArgumentException("Forwarded identity is prohibited"); if (forwarded) throw new IllegalArgumentException("Forwarded identity is prohibited");
return; return Optional.empty();
} }
Optional<PkiServerAuthenticationContext> context = tlsContext(exchange, requestId, headers); Optional<PkiServerAuthenticationContext> context = tlsContext(exchange, requestId, headers);
if (context.isEmpty() || authenticator.authenticatePublicProxyTransport(context.orElseThrow()).isEmpty()) { Optional<String> transport = context.flatMap(authenticator::authenticatePublicProxyTransport);
if (transport.isEmpty()) {
throw new IllegalArgumentException("Proxy transport is unauthenticated"); throw new IllegalArgumentException("Proxy transport is unauthenticated");
} }
return transport;
} }
private Optional<PkiServerAuthenticationContext> tlsContext(HttpExchange exchange, String requestId, private Optional<PkiServerAuthenticationContext> tlsContext(HttpExchange exchange, String requestId,
@@ -282,10 +321,11 @@ final class OcspHttpHandler implements HttpHandler {
} }
private void send(HttpExchange exchange, OcspResponderService.Responder responder, private void send(HttpExchange exchange, OcspResponderService.Responder responder,
OcspResponseService.Response response, boolean nonce, String requestId) throws IOException { OcspResponseService.Response response, boolean nonce, boolean signed, String requestId) throws IOException {
Headers headers = exchange.getResponseHeaders(); headers.set("Content-Type", MEDIA_RESPONSE); Headers headers = exchange.getResponseHeaders(); headers.set("Content-Type", MEDIA_RESPONSE);
headers.set("X-Content-Type-Options", "nosniff"); headers.set(RequestIds.HEADER, requestId); headers.set("X-Content-Type-Options", "nosniff"); headers.set(RequestIds.HEADER, requestId);
if (nonce) { headers.set("Cache-Control", "no-store"); } if (signed) { headers.set("Cache-Control", "private, no-store"); headers.set("Pragma", "no-cache"); }
else if (nonce) { headers.set("Cache-Control", "no-store"); }
else { else {
long seconds = Math.min(responder.cacheLifetime().toSeconds(), responder.responseValidity().toSeconds()); long seconds = Math.min(responder.cacheLifetime().toSeconds(), responder.responseValidity().toSeconds());
String validator = etag(response.der()); String validator = etag(response.der());
@@ -315,19 +355,46 @@ final class OcspHttpHandler implements HttpHandler {
exchange.sendResponseHeaders(status, -1); exchange.sendResponseHeaders(status, -1);
} }
private void audit(String requestId, Optional<OcspResponderService.Responder> responder, private void audit(String requestId, Optional<OcspResponderService.Responder> responder,
String classification) { Optional<String> transportPrincipal, Optional<String> requesterPrincipal, String classification) {
Map<String, String> details = new java.util.LinkedHashMap<>(); Map<String, String> details = new java.util.LinkedHashMap<>();
details.put("request", requestId); details.put("classification", classification); details.put("request", requestId); details.put("classification", classification);
responder.ifPresent(value -> details.put("responder", value.responderId())); responder.ifPresent(value -> details.put("responder", value.responderId()));
realm.auditTransport("OCSP_REQUEST", "anonymous", Map.copyOf(details)); transportPrincipal.ifPresent(value -> details.put("transportPrincipal", value));
requesterPrincipal.ifPresent(value -> details.put("requesterPrincipal", value));
realm.auditTransport("OCSP_REQUEST", requesterPrincipal.orElse("anonymous"), Map.copyOf(details));
} }
private static String etag(byte[] value) { private static String etag(byte[] value) {
try { return '"' + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)) + '"'; } try { return '"' + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)) + '"'; }
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); } 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 record Route(String alias, Optional<byte[]> encoded) { Route { encoded = encoded.map(byte[]::clone); } }
private record Execution(OcspResponseService.Response response, Optional<String> requesterPrincipal) {
Execution { java.util.Objects.requireNonNull(response); requesterPrincipal = java.util.Objects.requireNonNull(requesterPrincipal); }
}
private static void requireAuthenticationMode(OcspResponderService.RequestAuthenticationMode mode,
Optional<OcspRequesterService.SignedRequest> signed) {
if (mode == OcspResponderService.RequestAuthenticationMode.AUTHORIZED_SIGNED_REQUIRED && signed.isEmpty()) {
throw new SignatureRequired();
}
if (mode == OcspResponderService.RequestAuthenticationMode.UNSIGNED_ONLY && signed.isPresent()) {
throw new OcspRequesterService.UnauthorizedException();
}
}
private static final class UnknownAlias extends RuntimeException { private static final long serialVersionUID = 1L; } 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 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 MethodFailure extends RuntimeException { private static final long serialVersionUID = 1L; }
private static final class MediaFailure extends RuntimeException { private static final long serialVersionUID = 1L; } private static final class MediaFailure extends RuntimeException { private static final long serialVersionUID = 1L; }
private static final class SignatureRequired extends RuntimeException { private static final long serialVersionUID = 1L; }
private static final class NonceReplayGuard {
private static final int MAXIMUM_ENTRIES = 4096;
private final java.util.LinkedHashMap<String, Instant> accepted = new java.util.LinkedHashMap<>();
synchronized boolean accept(String responderId, byte[] nonce, Instant now, java.time.Duration lifetime) {
accepted.entrySet().removeIf(entry -> !now.isBefore(entry.getValue()));
String key = etag((responderId + ':' + HexFormat.of().formatHex(nonce))
.getBytes(StandardCharsets.US_ASCII));
if (accepted.containsKey(key) || accepted.size() >= MAXIMUM_ENTRIES) return false;
accepted.put(key, now.plus(lifetime));
return true;
}
}
} }

View File

@@ -38,26 +38,37 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.ASN1OctetString; import org.bouncycastle.asn1.ASN1OctetString;
import org.bouncycastle.asn1.DERNull; import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers; import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.ocsp.OCSPRequest;
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
import org.bouncycastle.asn1.ocsp.Signature;
import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers; import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.cert.ocsp.CertificateID; import org.bouncycastle.cert.ocsp.CertificateID;
import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPReq;
import org.bouncycastle.cert.ocsp.Req; import org.bouncycastle.cert.ocsp.Req;
import zeroecho.pki.application.OcspResponseService; import zeroecho.pki.application.OcspResponseService;
import zeroecho.pki.server.OcspRequesterService;
/** Narrow canonical DER and unpadded Base64url OCSP request decoder. */ /** Narrow canonical DER and unpadded Base64url OCSP request decoder. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.ExceptionAsFlowControl", "PMD.PreserveStackTrace", @SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.ExceptionAsFlowControl", "PMD.PreserveStackTrace",
"PMD.AvoidCatchingGenericException", "PMD.CyclomaticComplexity" }) "PMD.AvoidCatchingGenericException", "PMD.CyclomaticComplexity", "PMD.AvoidLiteralsInIfCondition" })
final class OcspRequestParser { final class OcspRequestParser {
/* default */ record Parsed(List<OcspResponseService.CertId> requests, Optional<byte[]> nonce) { /* default */ record Parsed(List<OcspResponseService.CertId> requests, Optional<byte[]> nonce,
Parsed { requests = List.copyOf(requests); nonce = nonce.map(byte[]::clone); } Optional<OcspRequesterService.SignedRequest> signedRequest) {
Parsed {
requests = List.copyOf(requests);
nonce = nonce.map(byte[]::clone);
signedRequest = Objects.requireNonNull(signedRequest, "signedRequest");
}
@Override public Optional<byte[]> nonce() { return nonce.map(byte[]::clone); } @Override public Optional<byte[]> nonce() { return nonce.map(byte[]::clone); }
} }
@@ -76,13 +87,21 @@ final class OcspRequestParser {
/* default */ static Parsed parse(byte[] der, int maximumEntries, /* default */ static Parsed parse(byte[] der, int maximumEntries,
Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumNonceBytes) { Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumNonceBytes) {
return parse(der, maximumEntries, acceptedHashes, maximumNonceBytes, 0, 0);
}
/* default */ static Parsed parse(byte[] der, int maximumEntries,
Set<OcspResponseService.CertIdHash> acceptedHashes, int maximumNonceBytes,
int maximumSignerCertificates, int maximumSignerChainBytes) {
try { try {
OCSPReq request = new OCSPReq(der); OCSPReq request = new OCSPReq(der);
if (!Arrays.equals(der, request.getEncoded()) || request.isSigned()) throw malformed(); if (!Arrays.equals(der, request.getEncoded())) throw malformed();
byte[] exactTbsRequest = firstSequenceElement(der);
Req[] entries = request.getRequestList(); Req[] entries = request.getRequestList();
if (entries.length == 0 || entries.length > maximumEntries) throw malformed(); if (entries.length == 0 || entries.length > maximumEntries) throw malformed();
List<OcspResponseService.CertId> result = new ArrayList<>(entries.length); List<OcspResponseService.CertId> result = new ArrayList<>(entries.length);
for (Req entry : entries) { for (Req entry : entries) {
if (entry.getSingleRequestExtensions() != null) throw malformed();
CertificateID id = entry.getCertID(); CertificateID id = entry.getCertID();
org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm = id.toASN1Primitive().getHashAlgorithm(); org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm = id.toASN1Primitive().getHashAlgorithm();
OcspResponseService.CertIdHash hash; OcspResponseService.CertIdHash hash;
@@ -110,10 +129,73 @@ final class OcspRequestParser {
if (value.length == 0 || value.length > maximumNonceBytes) throw malformed(); if (value.length == 0 || value.length > maximumNonceBytes) throw malformed();
nonce = Optional.of(value); nonce = Optional.of(value);
} }
return new Parsed(result, nonce); Optional<OcspRequesterService.SignedRequest> signed = signedRequest(request, der, exactTbsRequest,
maximumSignerCertificates, maximumSignerChainBytes);
if (signed.isEmpty() && request.getRequestorName() != null) throw malformed();
return new Parsed(result, nonce, signed);
} catch (IOException | RuntimeException failure) { throw malformed(); } } catch (IOException | RuntimeException failure) { throw malformed(); }
} }
private static Optional<OcspRequesterService.SignedRequest> signedRequest(OCSPReq request, byte[] requestDer,
byte[] exactTbsRequest, int maximumSignerCertificates, int maximumSignerChainBytes) throws IOException {
if (!request.isSigned()) return Optional.empty();
if (maximumSignerCertificates <= 0 || maximumSignerChainBytes <= 0) throw malformed();
GeneralName requestor = request.getRequestorName();
if (requestor == null || requestor.getTagNo() != GeneralName.directoryName) throw malformed();
byte[] nameDer = requestor.getName().toASN1Primitive().getEncoded("DER");
OCSPRequest structure = OCSPRequest.getInstance(ASN1Primitive.fromByteArray(requestDer));
Signature signature = structure.getOptionalSignature();
if (signature == null || signature.getSignature().getPadBits() != 0
|| signature.getSignature().getOctets().length == 0) throw malformed();
byte[] algorithmDer = signature.getSignatureAlgorithm().getEncoded("DER");
org.bouncycastle.cert.X509CertificateHolder[] holders = request.getCerts();
if (holders.length == 0 || holders.length > maximumSignerCertificates) throw malformed();
int total = 0;
List<byte[]> certificates = new ArrayList<>(holders.length);
Set<String> unique = new java.util.HashSet<>();
for (org.bouncycastle.cert.X509CertificateHolder holder : holders) {
byte[] certificate = holder.getEncoded();
if (!Arrays.equals(certificate, ASN1Primitive.fromByteArray(certificate).getEncoded("DER"))) {
throw malformed();
}
total = Math.addExact(total, certificate.length);
if (total > maximumSignerChainBytes || !unique.add(Base64.getEncoder().encodeToString(certificate))) {
throw malformed();
}
certificates.add(certificate);
}
return Optional.of(new OcspRequesterService.SignedRequest(exactTbsRequest, nameDer, algorithmDer,
signature.getSignature().getOctets(), certificates));
}
private static byte[] firstSequenceElement(byte[] der) {
if (der.length < 4 || Byte.toUnsignedInt(der[0]) != 0x30) throw malformed();
Length outer = length(der, 1);
int outerEnd = Math.addExact(outer.offset(), outer.length());
if (outerEnd != der.length || outer.offset() >= outerEnd || Byte.toUnsignedInt(der[outer.offset()]) != 0x30) {
throw malformed();
}
Length child = length(der, outer.offset() + 1);
int childEnd = Math.addExact(child.offset(), child.length());
if (childEnd > outerEnd) throw malformed();
return Arrays.copyOfRange(der, outer.offset(), childEnd);
}
private static Length length(byte[] der, int offset) {
if (offset >= der.length) throw malformed();
int first = Byte.toUnsignedInt(der[offset]);
if (first < 128) return new Length(offset + 1, first);
int count = first & 0x7f;
if (count == 0 || count > 4 || offset + count >= der.length || der[offset + 1] == 0) throw malformed();
int value = 0;
for (int index = 0; index < count; index++) value = Math.addExact(Math.multiplyExact(value, 256),
Byte.toUnsignedInt(der[offset + index + 1]));
if (value < 128) throw malformed();
return new Length(offset + count + 1, value);
}
private record Length(int offset, int length) { }
private static IllegalArgumentException malformed() { private static IllegalArgumentException malformed() {
return new IllegalArgumentException("OCSP request is malformed"); return new IllegalArgumentException("OCSP request is malformed");
} }

View File

@@ -54,6 +54,7 @@ import java.security.SecureRandom;
import java.security.cert.CertificateFactory; import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.time.Duration; import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
@@ -75,7 +76,9 @@ import org.bouncycastle.asn1.x509.Extensions;
import org.bouncycastle.asn1.x509.ExtensionsGenerator; import org.bouncycastle.asn1.x509.ExtensionsGenerator;
import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames; import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.ocsp.BasicOCSPResp; import org.bouncycastle.cert.ocsp.BasicOCSPResp;
import org.bouncycastle.cert.ocsp.CertificateID; import org.bouncycastle.cert.ocsp.CertificateID;
import org.bouncycastle.cert.ocsp.OCSPReqBuilder; import org.bouncycastle.cert.ocsp.OCSPReqBuilder;
@@ -90,6 +93,7 @@ import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.storage.KeyringPassword; import zeroecho.core.storage.KeyringPassword;
import zeroecho.core.storage.KeyringStore; import zeroecho.core.storage.KeyringStore;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.SubjectRef;
@@ -199,12 +203,18 @@ class AcmeEndToEndTest {
assertOcspNonce(wire, publicPort, chain.get(1), chain.get(0)); assertOcspNonce(wire, publicPort, chain.get(1), chain.get(0));
assertOcspUnknownAndMulti(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)); assertOcspNoncePolicies(wire, publicPort, chain.get(1), chain.get(0));
assertSignedOcsp(wire, publicPort, chain.get(1), chain.get(0), rsa((byte) 81),
requesterCertificate(rsa((byte) 81)), "signed", true, false);
assertSignedOcsp(wire, publicPort, chain.get(1), chain.get(0), rsa((byte) 81),
requesterCertificate(rsa((byte) 81)), "mixed", false, false);
assertEquals(200, client.rollover(keyChange, replacementKey).status()); assertEquals(200, client.rollover(keyChange, replacementKey).status());
assertEquals(200, client.postAsGet(accountUri).status()); assertEquals(200, client.postAsGet(accountUri).status());
System.out.println("...https-jws-http01-issuance=true"); System.out.println("...https-jws-http01-issuance=true");
} }
registerManagedRequester(fixture, leafDer, leafKey);
try (PkiHttpsServer restarted = start(fixture); try (PkiHttpsServer restarted = start(fixture);
AcmeTestClient replacement = new AcmeTestClient(wire, directoryUri, replacementKey); AcmeTestClient replacement = new AcmeTestClient(wire, directoryUri, replacementKey);
AcmeTestClient old = new AcmeTestClient(wire, directoryUri, accountKey)) { AcmeTestClient old = new AcmeTestClient(wire, directoryUri, accountKey)) {
@@ -215,12 +225,22 @@ class AcmeEndToEndTest {
assertEquals(403, old.postAsGet(accountUri).status()); assertEquals(403, old.postAsGet(accountUri).status());
assertEquals(200, replacement.postAsGet(orderUri).status()); assertEquals(200, replacement.postAsGet(orderUri).status());
assertEquals(200, replacement.postAsGet(certificateUri).status()); assertEquals(200, replacement.postAsGet(certificateUri).status());
X509Certificate requesterCertificate = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(leafDer));
List<X509Certificate> preRevocationChain = certificates(
replacement.postAsGet(certificateUri).bodyText());
assertSignedOcsp(wire, publicPort, preRevocationChain.get(1), requesterCertificate, leafKey,
requesterCertificate, "managed", true, false);
URI revoke = URI.create(AcmeTestClient.text(replacement.directory(), "revokeCert")); URI revoke = URI.create(AcmeTestClient.text(replacement.directory(), "revokeCert"));
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(leafDer); String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(leafDer);
assertEquals(200, replacement.kid(revoke, assertEquals(200, replacement.kid(revoke,
"{\"certificate\":\"" + encoded + "\",\"reason\":1}").status()); "{\"certificate\":\"" + encoded + "\",\"reason\":1}").status());
List<X509Certificate> chain = certificates(replacement.postAsGet(certificateUri).bodyText()); List<X509Certificate> chain = certificates(replacement.postAsGet(certificateUri).bodyText());
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true); assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true);
assertSignedOcsp(wire, publicPort, chain.get(1), chain.get(0), rsa((byte) 81),
requesterCertificate(rsa((byte) 81)), "signed", true, true);
assertSignedOcspUnauthorized(wire, publicPort, chain.get(1), requesterCertificate, leafKey,
requesterCertificate, "managed");
assertFalse(replacement.kid(revoke, assertFalse(replacement.kid(revoke,
"{\"certificate\":\"" + encoded + "\",\"reason\":1}").status() == 200); "{\"certificate\":\"" + encoded + "\",\"reason\":1}").status() == 200);
System.out.println("...rollover-restart-revocation=true"); System.out.println("...rollover-restart-revocation=true");
@@ -258,6 +278,8 @@ class AcmeEndToEndTest {
+ "/directory"); + "/directory");
HttpClient wire = HttpClient.newBuilder().sslContext(tls.anonymousContext()) HttpClient wire = HttpClient.newBuilder().sslContext(tls.anonymousContext())
.connectTimeout(Duration.ofMillis(500)).build(); .connectTimeout(Duration.ofMillis(500)).build();
X509Certificate packagedIssuer = null;
X509Certificate packagedCertificate = null;
try { try {
awaitDirectory(wire, directoryUri, process, output); awaitDirectory(wire, directoryUri, process, output);
KeyPair accountKey = ec((byte) 42); KeyPair accountKey = ec((byte) 42);
@@ -292,7 +314,11 @@ class AcmeEndToEndTest {
URI certificate = URI.create(AcmeTestClient.text(finalized, "certificate")); URI certificate = URI.create(AcmeTestClient.text(finalized, "certificate"));
List<X509Certificate> chain = certificates(client.postAsGet(certificate).bodyText()); List<X509Certificate> chain = certificates(client.postAsGet(certificate).bodyText());
assertEquals(2, chain.size()); assertEquals(2, chain.size());
packagedCertificate = chain.get(0);
packagedIssuer = chain.get(1);
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), false); assertOcsp(wire, publicPort, chain.get(1), chain.get(0), false);
assertSignedOcsp(wire, publicPort, chain.get(1), chain.get(0), rsa((byte) 81),
requesterCertificate(rsa((byte) 81)), "signed", true, false);
assertEquals(200, client.rollover(rollover, replacement).status()); assertEquals(200, client.rollover(rollover, replacement).status());
String der = Base64.getUrlEncoder().withoutPadding().encodeToString(chain.get(0).getEncoded()); String der = Base64.getUrlEncoder().withoutPadding().encodeToString(chain.get(0).getEncoded());
assertEquals(200, client.kid(revoke, assertEquals(200, client.kid(revoke,
@@ -307,6 +333,21 @@ class AcmeEndToEndTest {
assertTrue(process.waitFor(5, TimeUnit.SECONDS)); assertTrue(process.waitFor(5, TimeUnit.SECONDS));
} }
} }
denyPackagedSignedRequester(fixture);
Process deniedProcess = builder.start();
try {
awaitDirectory(wire, directoryUri, deniedProcess, output);
assertSignedOcspUnauthorized(wire, publicPort,
java.util.Objects.requireNonNull(packagedIssuer),
java.util.Objects.requireNonNull(packagedCertificate), rsa((byte) 81),
requesterCertificate(rsa((byte) 81)), "signed");
} finally {
deniedProcess.destroy();
if (!deniedProcess.waitFor(10, TimeUnit.SECONDS)) {
deniedProcess.destroyForcibly();
assertTrue(deniedProcess.waitFor(5, TimeUnit.SECONDS));
}
}
System.out.println("...ok"); System.out.println("...ok");
} }
@@ -407,6 +448,7 @@ class AcmeEndToEndTest {
zeroecho.pki.application.OcspResponseService.CertIdHash.SHA256), zeroecho.pki.application.OcspResponseService.CertIdHash.SHA256),
16_384, 8, Duration.ofMinutes(1)); 16_384, 8, Duration.ofMinutes(1));
context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true); context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true);
registerSignedResponder(context, responder, authority, issuer.issuerId());
registerNonceResponder(context, responder, "ocsp-e2e-reject", "reject", registerNonceResponder(context, responder, "ocsp-e2e-reject", "reject",
OcspResponderService.NoncePolicy.REJECT); OcspResponderService.NoncePolicy.REJECT);
registerNonceResponder(context, responder, "ocsp-e2e-required", "required", registerNonceResponder(context, responder, "ocsp-e2e-required", "required",
@@ -448,6 +490,162 @@ class AcmeEndToEndTest {
context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true); context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true);
} }
private static void registerManagedRequester(Fixture fixture, byte[] requesterDer, KeyPair requesterKey)
throws Exception {
X509Certificate requester = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(requesterDer));
try (ServerRealmContext context = ServerRealmContext.open(fixture.configuration().realm(),
fixture.dependencies(), ServerTestSupport.CLOCK, HttpServerTestSupport.random())) {
OcspResponderService.Responder template = context.ocspResponders().requireAlias("root");
String commitment = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(requesterDer));
context.createPrincipal(new SecurityPrincipal("managed-ocsp-requester", SecurityPrincipal.Type.SERVICE,
"Managed OCSP requester", Optional.empty(), Map.of(), true), "system");
Permission.Scope scope = new Permission.Scope(context.configuration().realmId(),
Optional.of(template.authorityId()), Optional.of(template.issuerId()), Optional.empty(),
Optional.of("ocsp-e2e-managed"));
context.grant(new Permission.Grant("ocsp-query-managed-e2e", "managed-ocsp-requester",
Permission.Effect.ALLOW, Permission.Action.OCSP_STATUS_QUERY,
Permission.ResourceType.OCSP_RESPONDER, scope, Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED, Set.of(), Optional.empty(), true), "system");
zeroecho.pki.api.algorithm.X509AlgorithmBinding binding = context.session().algorithmBindings()
.standard(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256,
zeroecho.pki.api.algorithm.X509AlgorithmBinding.Role.OCSP_REQUEST_SIGNATURE)
.orElseThrow();
OcspResponderService.RequesterTrustPolicy policy = new OcspResponderService.RequesterTrustPolicy(
List.of(new OcspResponderService.TrustSelector(
OcspResponderService.TrustSelectorType.MANAGED_ISSUER_GENERATIONS,
Set.of(template.issuerId().value()))),
List.of(new OcspResponderService.PrincipalMapping("managed-requester-cert",
"managed-ocsp-requester",
OcspResponderService.PrincipalMappingType.SHA256_CERTIFICATE_COMMITMENT, commitment)),
List.of(new OcspResponderService.RequestSignatureBinding(binding.bindingId(),
binding.semanticCommitment())),
OcspResponderService.RequesterRevocationPolicy.LOCAL_AUTHORITATIVE_REQUIRED, 3, 131_072);
assertTrue(context.session().repository().credential(template.issuerId(), requester.getSerialNumber())
.isPresent());
OcspResponderService.Responder responder = context.ocspResponders().create("ocsp-e2e-managed",
"managed", template.authorityId(), template.issuerId(), template.signingMode(),
template.responderCredentialId(), template.signingKeyRef(), template.chainPathId(),
template.signatureAlgorithm(), template.signatureBindingId(),
template.signatureBindingCommitment(), template.responderIdForm(), template.responseValidity(),
template.noncePolicy(), template.maximumNonceBytes(), template.acceptedHashes(),
template.maximumRequestBytes(), template.maximumEntries(), template.cacheLifetime(),
OcspResponderService.RequestAuthenticationMode.AUTHORIZED_SIGNED_REQUIRED,
Optional.of(policy));
OcspResponderService.Responder active = context.ocspResponders().setActive(
context.ocspResponders().register(responder).responderId(), true);
assertRequesterService(context, active, requesterKey, requester);
}
}
private static void denyPackagedSignedRequester(Fixture fixture) throws Exception {
try (ServerRealmContext context = ServerRealmContext.open(fixture.configuration().realm(),
fixture.dependencies(), ServerTestSupport.CLOCK, HttpServerTestSupport.random())) {
OcspResponderService.Responder responder = context.ocspResponders().requireAlias("signed");
Permission.Scope scope = new Permission.Scope(context.configuration().realmId(),
Optional.of(responder.authorityId()), Optional.of(responder.issuerId()), Optional.empty(),
Optional.of(responder.responderId()));
context.grant(new Permission.Grant("ocsp-query-e2e-deny", "ocsp-requester",
Permission.Effect.DENY, Permission.Action.OCSP_STATUS_QUERY,
Permission.ResourceType.OCSP_RESPONDER, scope, Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED, Set.of(), Optional.empty(), true), "system");
}
}
private static void registerSignedResponder(ServerRealmContext context,
OcspResponderService.Responder template, PkiId authority, PkiId issuerId) throws Exception {
KeyPair requesterKey = rsa((byte) 81);
X509Certificate requesterCertificate = requesterCertificate(requesterKey);
String certificateCommitment = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(requesterCertificate.getEncoded()));
context.createPrincipal(new SecurityPrincipal("ocsp-requester", SecurityPrincipal.Type.SERVICE,
"OCSP requester", Optional.empty(), Map.of(), true), "system");
Permission.Scope scope = new Permission.Scope(context.configuration().realmId(), Optional.of(authority),
Optional.of(issuerId), Optional.empty(), Optional.of("ocsp-e2e-signed"));
context.grant(new Permission.Grant("ocsp-query-e2e", "ocsp-requester", Permission.Effect.ALLOW,
Permission.Action.OCSP_STATUS_QUERY, Permission.ResourceType.OCSP_RESPONDER, scope,
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED, Set.of(), Optional.empty(),
true), "system");
Permission.Scope mixedScope = new Permission.Scope(context.configuration().realmId(),
Optional.of(authority), Optional.of(issuerId), Optional.empty(), Optional.of("ocsp-e2e-mixed"));
context.grant(new Permission.Grant("ocsp-query-mixed-e2e", "ocsp-requester", Permission.Effect.ALLOW,
Permission.Action.OCSP_STATUS_QUERY, Permission.ResourceType.OCSP_RESPONDER, mixedScope,
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED, Set.of(), Optional.empty(),
true), "system");
zeroecho.pki.api.algorithm.X509AlgorithmBinding requestBinding = context.session().algorithmBindings()
.standard(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256,
zeroecho.pki.api.algorithm.X509AlgorithmBinding.Role.OCSP_REQUEST_SIGNATURE).orElseThrow();
OcspResponderService.RequesterTrustPolicy policy = new OcspResponderService.RequesterTrustPolicy(
List.of(new OcspResponderService.TrustSelector(
OcspResponderService.TrustSelectorType.EXACT_REQUESTER_CERTIFICATE_COMMITMENTS,
Set.of(certificateCommitment))),
List.of(new OcspResponderService.PrincipalMapping("ocsp-requester-cert", "ocsp-requester",
OcspResponderService.PrincipalMappingType.SHA256_CERTIFICATE_COMMITMENT,
certificateCommitment)),
List.of(new OcspResponderService.RequestSignatureBinding(requestBinding.bindingId(),
requestBinding.semanticCommitment())),
OcspResponderService.RequesterRevocationPolicy.VALIDITY_AND_TRUST_ONLY, 2, 65_536);
OcspResponderService.Responder responder = context.ocspResponders().create("ocsp-e2e-signed", "signed",
authority, issuerId, template.signingMode(), template.responderCredentialId(),
template.signingKeyRef(), template.chainPathId(), template.signatureAlgorithm(),
template.signatureBindingId(), template.signatureBindingCommitment(), template.responderIdForm(),
template.responseValidity(), template.noncePolicy(), template.maximumNonceBytes(),
template.acceptedHashes(), template.maximumRequestBytes(), template.maximumEntries(),
template.cacheLifetime(), OcspResponderService.RequestAuthenticationMode.AUTHORIZED_SIGNED_REQUIRED,
Optional.of(policy));
OcspResponderService.Responder active = context.ocspResponders().setActive(
context.ocspResponders().register(responder).responderId(), true);
assertTrue(context.authorizeOcspQuery("ocsp-requester", active).allowed());
assertRequesterService(context, active, requesterKey, requesterCertificate);
OcspResponderService.Responder mixed = context.ocspResponders().create("ocsp-e2e-mixed", "mixed",
authority, issuerId, template.signingMode(), template.responderCredentialId(),
template.signingKeyRef(), template.chainPathId(), template.signatureAlgorithm(),
template.signatureBindingId(), template.signatureBindingCommitment(), template.responderIdForm(),
template.responseValidity(), template.noncePolicy(), template.maximumNonceBytes(),
template.acceptedHashes(), template.maximumRequestBytes(), template.maximumEntries(),
template.cacheLifetime(),
OcspResponderService.RequestAuthenticationMode.UNSIGNED_OR_AUTHORIZED_SIGNED,
Optional.of(policy));
context.ocspResponders().setActive(context.ocspResponders().register(mixed).responderId(), true);
}
private static void assertRequesterService(ServerRealmContext context,
OcspResponderService.Responder responder, KeyPair requesterKey, X509Certificate requesterCertificate)
throws Exception {
X509Certificate issuerCertificate;
try (zeroecho.pki.application.PkiRepositoryContent content = context.session().repository()
.openCredential(context.session().repository().issuer(responder.issuerId()).orElseThrow()
.credentialId()); java.io.InputStream input = content.openStream()) {
issuerCertificate = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(input);
}
OCSPReqBuilder builder = new OCSPReqBuilder();
builder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
.get(CertificateID.HASH_SHA1), new JcaX509CertificateHolder(issuerCertificate), BigInteger.ONE));
builder.setRequestorName(new GeneralName(new JcaX509CertificateHolder(requesterCertificate).getSubject()));
org.bouncycastle.cert.ocsp.OCSPReq request = builder.build(new JcaContentSignerBuilder("SHA256withRSA")
.build(requesterKey.getPrivate()), new org.bouncycastle.cert.X509CertificateHolder[] {
new JcaX509CertificateHolder(requesterCertificate) });
org.bouncycastle.asn1.ocsp.OCSPRequest structure = org.bouncycastle.asn1.ocsp.OCSPRequest.getInstance(
org.bouncycastle.asn1.ASN1Primitive.fromByteArray(request.getEncoded()));
org.bouncycastle.asn1.ocsp.Signature signature = structure.getOptionalSignature();
org.bouncycastle.cert.ocsp.CertificateID certificateId = request.getRequestList()[0].getCertID();
OcspRequesterService.SignedRequest signed = new OcspRequesterService.SignedRequest(
structure.getTbsRequest().getEncoded(), requesterCertificate.getSubjectX500Principal().getEncoded(),
signature.getSignatureAlgorithm().getEncoded(), signature.getSignature().getOctets(),
List.of(requesterCertificate.getEncoded()));
try {
new OcspRequesterService(context, ServerTestSupport.CLOCK).authenticate(responder, signed,
List.of(new zeroecho.pki.application.OcspResponseService.CertId(
zeroecho.pki.application.OcspResponseService.CertIdHash.SHA1,
certificateId.getIssuerNameHash(), certificateId.getIssuerKeyHash(),
certificateId.getSerialNumber())));
} catch (OcspRequesterService.UnauthorizedException failure) {
throw new AssertionError("Requester service rejected fixture: " + failure.classification(), failure);
}
}
private PkiHttpsServer start(Fixture fixture) throws Exception { private PkiHttpsServer start(Fixture fixture) throws Exception {
return PkiHttpsServer.start(fixture.configuration(), fixture.dependencies(), ServerTestSupport.CLOCK, return PkiHttpsServer.start(fixture.configuration(), fixture.dependencies(), ServerTestSupport.CLOCK,
HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader()); HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader());
@@ -766,6 +964,112 @@ class AcmeEndToEndTest {
postOcsp(client, publicPort, "required", withoutNonce).getStatus()); postOcsp(client, publicPort, "required", withoutNonce).getStatus());
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL, assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL,
postOcsp(client, publicPort, "required", withNonce).getStatus()); postOcsp(client, publicPort, "required", withNonce).getStatus());
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST,
postOcsp(client, publicPort, "required", withNonce).getStatus());
}
private static void assertSignedOcsp(HttpClient client, int publicPort, X509Certificate issuer,
X509Certificate certificate, KeyPair requesterKey, X509Certificate requesterCertificate,
String alias, boolean signatureRequired, boolean revoked)
throws Exception {
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
OCSPReqBuilder builder = new OCSPReqBuilder();
builder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
.get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber()));
builder.setRequestorName(new GeneralName(new JcaX509CertificateHolder(requesterCertificate).getSubject()));
byte[] request = builder.build(new JcaContentSignerBuilder("SHA256withRSA")
.build(requesterKey.getPrivate()),
new org.bouncycastle.cert.X509CertificateHolder[] {
new JcaX509CertificateHolder(requesterCertificate) }).getEncoded();
URI endpoint = URI.create("https://localhost:" + publicPort + "/ocsp/" + alias);
java.net.http.HttpResponse<byte[]> post = client.send(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.BodyHandlers.ofByteArray());
assertEquals(200, post.statusCode());
OCSPResp decoded = new OCSPResp(post.body());
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL, decoded.getStatus());
BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class, decoded.getResponseObject());
assertEquals(1, basic.getResponses().length);
if (revoked) assertInstanceOf(org.bouncycastle.cert.ocsp.RevokedStatus.class,
basic.getResponses()[0].getCertStatus());
else assertEquals(null, basic.getResponses()[0].getCertStatus());
assertEquals("private, no-store", post.headers().firstValue("Cache-Control").orElseThrow());
assertEquals("no-cache", post.headers().firstValue("Pragma").orElseThrow());
assertTrue(post.headers().firstValue("ETag").isEmpty());
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request);
java.net.http.HttpResponse<byte[]> get = client.send(java.net.http.HttpRequest.newBuilder(
URI.create(endpoint + "/" + encoded)).header("Accept", "application/ocsp-response").GET().build(),
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL, new OCSPResp(get.body()).getStatus());
OCSPReqBuilder unsigned = new OCSPReqBuilder();
unsigned.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
.get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber()));
assertEquals(signatureRequired ? org.bouncycastle.cert.ocsp.OCSPRespBuilder.SIG_REQUIRED
: org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL,
postOcsp(client, publicPort, alias, unsigned.build().getEncoded()).getStatus());
if ("mixed".equals(alias)) {
byte[] invalidSignature = builder.build(new JcaContentSignerBuilder("SHA256withRSA")
.build(rsa((byte) 82).getPrivate()),
new org.bouncycastle.cert.X509CertificateHolder[] {
new JcaX509CertificateHolder(requesterCertificate) }).getEncoded();
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.UNAUTHORIZED,
postOcsp(client, publicPort, alias, invalidSignature).getStatus());
OCSPReqBuilder partlyForeign = new OCSPReqBuilder();
partlyForeign.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
.get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber()));
partlyForeign.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
.get(CertificateID.HASH_SHA1), new JcaX509CertificateHolder(requesterCertificate),
certificate.getSerialNumber().add(BigInteger.ONE)));
partlyForeign.setRequestorName(new GeneralName(
new JcaX509CertificateHolder(requesterCertificate).getSubject()));
byte[] partlyForeignRequest = partlyForeign.build(new JcaContentSignerBuilder("SHA256withRSA")
.build(requesterKey.getPrivate()),
new org.bouncycastle.cert.X509CertificateHolder[] {
new JcaX509CertificateHolder(requesterCertificate) }).getEncoded();
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.UNAUTHORIZED,
postOcsp(client, publicPort, alias, partlyForeignRequest).getStatus());
}
if (signatureRequired) {
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.UNAUTHORIZED,
postOcsp(client, publicPort, "root", request).getStatus());
}
}
private static void assertSignedOcspUnauthorized(HttpClient client, int publicPort, X509Certificate issuer,
X509Certificate certificate, KeyPair requesterKey, X509Certificate requesterCertificate,
String alias) throws Exception {
OCSPReqBuilder builder = new OCSPReqBuilder();
builder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
.get(CertificateID.HASH_SHA1), new JcaX509CertificateHolder(issuer),
certificate.getSerialNumber()));
builder.setRequestorName(new GeneralName(new JcaX509CertificateHolder(requesterCertificate).getSubject()));
byte[] request = builder.build(new JcaContentSignerBuilder("SHA256withRSA")
.build(requesterKey.getPrivate()), new org.bouncycastle.cert.X509CertificateHolder[] {
new JcaX509CertificateHolder(requesterCertificate) }).getEncoded();
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());
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.UNAUTHORIZED,
new OCSPResp(response.body()).getStatus());
}
private static X509Certificate requesterCertificate(KeyPair keys) throws Exception {
X500Name name = new X500Name("CN=ZeroEcho OCSP Requester Fixture");
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(name, BigInteger.valueOf(8801),
java.util.Date.from(Instant.parse("2025-01-01T00:00:00Z")),
java.util.Date.from(Instant.parse("2035-01-01T00:00:00Z")), name,
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(keys.getPublic().getEncoded()));
builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature));
byte[] encoded = builder.build(new JcaContentSignerBuilder("SHA256withRSA")
.build(keys.getPrivate())).getEncoded();
return (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(encoded));
} }
private static OCSPResp postOcsp(HttpClient client, int publicPort, String alias, byte[] request) private static OCSPResp postOcsp(HttpClient client, int publicPort, String alias, byte[] request)

View File

@@ -104,6 +104,44 @@ class AuthorizationAndRoleTest {
System.out.println("...ok"); System.out.println("...ok");
} }
@Test
void scopesOcspQuerySeparatelyFromResponderAdministration() {
System.out.println("scopesOcspQuerySeparatelyFromResponderAdministration");
AuthorizationEngine engine = new AuthorizationEngine(ServerTestSupport.CLOCK);
SecurityPrincipal principal = ServerTestSupport.principal("ocsp-client");
Permission.Scope exact = new Permission.Scope(ServerTestSupport.REALM,
Optional.of(ServerTestSupport.AUTHORITY), Optional.of(new zeroecho.pki.api.PkiId("issuer-a")),
Optional.empty(), Optional.of("responder-a"));
Permission.Resource resource = new Permission.Resource(Permission.ResourceType.OCSP_RESPONDER, exact,
Optional.empty(), Optional.empty());
Permission.Grant allow = new Permission.Grant("ocsp-allow", principal.principalId(),
Permission.Effect.ALLOW, Permission.Action.OCSP_STATUS_QUERY,
Permission.ResourceType.OCSP_RESPONDER, exact, Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED, Set.of(), Optional.empty(), true);
Permission.Grant administer = new Permission.Grant("ocsp-admin", principal.principalId(),
Permission.Effect.ALLOW, Permission.Action.OCSP_ADMINISTER,
Permission.ResourceType.OCSP_RESPONDER, exact, Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED, Set.of(), Optional.empty(), true);
AuthorizationEngine.Request request = new AuthorizationEngine.Request(ServerTestSupport.REALM,
new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), false),
principal, Permission.Action.OCSP_STATUS_QUERY, resource, Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED, Permission.Context.empty(), List.of(allow), Set.of());
assertTrue(engine.authorize(request).allowed());
assertEquals(AuthorizationEngine.Code.NO_MATCHING_GRANT, engine.authorize(new AuthorizationEngine.Request(
request.realmId(), request.exposure(), principal, request.action(), resource,
request.relationship(), request.dataView(), request.context(), List.of(administer), Set.of())).code());
Permission.Scope otherResponder = new Permission.Scope(ServerTestSupport.REALM,
Optional.of(ServerTestSupport.AUTHORITY), Optional.of(new zeroecho.pki.api.PkiId("issuer-a")),
Optional.empty(), Optional.of("responder-b"));
assertEquals(AuthorizationEngine.Code.NO_MATCHING_GRANT, engine.authorize(new AuthorizationEngine.Request(
request.realmId(), request.exposure(), principal, request.action(),
new Permission.Resource(Permission.ResourceType.OCSP_RESPONDER, otherResponder,
Optional.empty(), Optional.empty()), request.relationship(), request.dataView(),
request.context(), List.of(allow), Set.of())).code());
System.out.println("...query-admin-separation=true responder-scope=true");
System.out.println("...ok");
}
@Test @Test
void loadsExactTemplatesAndRejectsUnknownSchemaFields() throws Exception { void loadsExactTemplatesAndRejectsUnknownSchemaFields() throws Exception {
System.out.println("loadsExactTemplatesAndRejectsUnknownSchemaFields"); System.out.println("loadsExactTemplatesAndRejectsUnknownSchemaFields");

View File

@@ -35,6 +35,7 @@ package zeroecho.pki.server.http;
import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigInteger; import java.math.BigInteger;
@@ -119,8 +120,8 @@ class OcspRequestParserTest {
} }
@Test @Test
void rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority() throws Exception { void retainsExactSignedRequestAndRequiresExplicitSignerBounds() throws Exception {
System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority"); System.out.println("retainsExactSignedRequestAndRequiresExplicitSignerBounds");
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048); generator.initialize(2048);
KeyPair keys = generator.generateKeyPair(); KeyPair keys = generator.generateKeyPair();
@@ -133,8 +134,16 @@ class OcspRequestParserTest {
new X509CertificateHolder[] { issuer }).getEncoded(); new X509CertificateHolder[] { issuer }).getEncoded();
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(signed, 1, assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(signed, 1,
Set.of(OcspResponseService.CertIdHash.SHA1), 32)); Set.of(OcspResponseService.CertIdHash.SHA1), 32));
System.out.println("...signed-request-authority=false"); OcspRequestParser.Parsed parsed = OcspRequestParser.parse(signed, 1,
System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority...ok"); Set.of(OcspResponseService.CertIdHash.SHA1), 32, 2, 32_768);
assertTrue(parsed.signedRequest().isPresent());
assertArrayEquals(issuer.getSubject().getEncoded(),
parsed.signedRequest().orElseThrow().requestorName());
assertArrayEquals(issuer.getEncoded(),
parsed.signedRequest().orElseThrow().certificates().getFirst());
assertTrue(parsed.signedRequest().orElseThrow().tbsRequest().length > 0);
System.out.println("...exact-tbs-and-certificate-retained=true");
System.out.println("retainsExactSignedRequestAndRequiresExplicitSignerBounds...ok");
} }
private static X509CertificateHolder certificate() throws Exception { private static X509CertificateHolder certificate() throws Exception {

View File

@@ -72,7 +72,9 @@ public record X509AlgorithmBinding(String bindingId, AlgorithmIdentity algorithm
/** Certificate signature. */ /** Certificate signature. */
CERTIFICATE_SIGNATURE, CERTIFICATE_SIGNATURE,
/** Certificate-revocation-list signature. */ /** Certificate-revocation-list signature. */
CRL_SIGNATURE CRL_SIGNATURE,
/** Signed OCSP request signature. */
OCSP_REQUEST_SIGNATURE
} }
/** Authority that owns the OID assignment. */ /** Authority that owns the OID assignment. */

View File

@@ -402,7 +402,7 @@ public final class StandardX509Bindings {
Optional.empty(), commitment)); Optional.empty(), commitment));
} }
return List.of(X509AlgorithmBinding.Role.CSR_SIGNATURE, X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE, return List.of(X509AlgorithmBinding.Role.CSR_SIGNATURE, X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE,
X509AlgorithmBinding.Role.CRL_SIGNATURE).stream() X509AlgorithmBinding.Role.CRL_SIGNATURE, X509AlgorithmBinding.Role.OCSP_REQUEST_SIGNATURE).stream()
.map(bindingRole -> new X509AlgorithmBinding(id + "." .map(bindingRole -> new X509AlgorithmBinding(id + "."
+ bindingRole.name().toLowerCase(java.util.Locale.ROOT), identity, + bindingRole.name().toLowerCase(java.util.Locale.ROOT), identity,
bindingRole, identifier.oid(), X509AlgorithmBinding.Origin.STANDARD, 1, parameters, keyEncoding, bindingRole, identifier.oid(), X509AlgorithmBinding.Origin.STANDARD, 1, parameters, keyEncoding,