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

@@ -189,6 +189,8 @@ public final class AuthorizationEngine {
&& !grant.scope().issuerId().equals(request.resource().scope().issuerId())) return false;
if (grant.scope().profileId().isPresent()
&& !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
|| request.relationship() == Permission.Relationship.OWN
&& 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.Objects;
import java.util.Optional;
import java.util.Map;
import java.util.Set;
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.PkiId;
import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.application.OcspResponseService;
@@ -72,6 +74,123 @@ import zeroecho.pki.application.PkiRepository;
"PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException", "PMD.UseEnumCollections",
"PMD.ExceptionAsFlowControl" })
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. */
public enum SigningMode {
ISSUER_SIGNED(1), DELEGATED_RESPONDER(2);
@@ -115,7 +234,8 @@ public final class OcspResponderService {
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) {
int maximumEntries, Duration cacheLifetime, RequestAuthenticationMode requestAuthenticationMode,
Optional<RequesterTrustPolicy> requesterTrustPolicy) {
/** Snapshots the typed finite registration input. */
public Registration {
Permission.requireId(responderId, "OCSP responder"); Permission.requireId(alias, "OCSP alias");
@@ -128,6 +248,22 @@ public final class OcspResponderService {
Objects.requireNonNull(responseValidity); Objects.requireNonNull(noncePolicy);
acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes));
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,
Duration responseValidity, NoncePolicy noncePolicy, int maximumNonceBytes,
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) {
/** Validates all finite immutable dependencies and the record commitment. */
public Responder {
@@ -157,6 +294,9 @@ public final class OcspResponderService {
acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes));
if (acceptedHashes.isEmpty()) throw new IllegalArgumentException("OCSP CertID hashes are empty");
positive(cacheLifetime, responseValidity, "cache lifetime"); Objects.requireNonNull(state);
Objects.requireNonNull(requestAuthenticationMode, "requestAuthenticationMode");
requesterTrustPolicy = Objects.requireNonNull(requesterTrustPolicy, "requesterTrustPolicy");
requirePolicyShape(requestAuthenticationMode, requesterTrustPolicy);
Objects.requireNonNull(createdAt); requireDigest(configurationCommitment);
}
}
@@ -167,6 +307,7 @@ public final class OcspResponderService {
private final X509AlgorithmBindingRegistry bindings;
private final Optional<OcspResponseService> signing;
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. */
public OcspResponderService(RealmId realmId, ServerControlStore control, PkiRepository repository,
@@ -185,7 +326,8 @@ public final class OcspResponderService {
supplied.chainPathId(), supplied.signatureAlgorithm(), supplied.signatureBindingId(),
supplied.signatureBindingCommitment(), supplied.responderIdForm(), supplied.responseValidity(),
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. */
@@ -196,12 +338,13 @@ public final class OcspResponderService {
if (!supplied.configurationCommitment().equals(commitment(supplied, true))) {
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");
}
validateDependencies(supplied, true);
Responder sealed = seal(supplied, State.INACTIVE);
control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(sealed), Optional.empty())));
aliasIndex.put(sealed.alias(), sealed);
return sealed;
}
@@ -217,15 +360,15 @@ public final class OcspResponderService {
if (result.state() != State.ACTIVE) {
throw new IllegalStateException("OCSP responder is inactive");
}
validateDependencies(result, false); return result;
return result;
}
/** Resolves one unique durable alias regardless of activation state. */
public Responder requireAlias(String alias) {
Permission.requireId(alias, "OCSP alias");
List<Responder> matches = aliases(alias);
if (matches.size() != 1) throw new IllegalArgumentException("OCSP responder alias is unavailable");
return matches.getFirst();
Responder indexed = aliasIndex.get(alias);
if (indexed == null) throw new IllegalArgumentException("OCSP responder alias is unavailable");
return indexed;
}
/** Lists one bounded deterministic responder page. */
@@ -236,33 +379,31 @@ public final class OcspResponderService {
page.nextOffset(), page.hasMore());
}
private List<Responder> aliases(String alias) {
List<Responder> matches = new ArrayList<>();
int offset = 0;
while (true) {
ServerControlStore.Page<Responder> page = list(offset, 256);
page.values().stream().filter(value -> value.alias().equals(alias)).forEach(matches::add);
if (!page.hasMore()) return List.copyOf(matches);
offset = page.nextOffset();
}
}
/** Atomically changes only responder activation after dependency revalidation. */
public Responder setActive(String responderId, boolean active) {
Responder prior = require(responderId); validateDependencies(prior, true);
Responder next = seal(prior, active ? State.ACTIVE : State.INACTIVE);
control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(next),
Optional.of(record(prior).commitment()))));
aliasIndex.put(next.alias(), next);
return next;
}
/** Revalidates every durable binding during realm recovery. */
public void validateAll() {
Map<String, Responder> recovered = new java.util.HashMap<>();
int offset = 0;
while (true) {
ServerControlStore.Page<Responder> page = list(offset, 256);
page.values().forEach(value -> validateDependencies(value, true));
if (!page.hasMore()) return;
page.values().forEach(value -> {
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();
}
}
@@ -274,17 +415,32 @@ public final class OcspResponderService {
OcspResponseService.ResponderId responderIdForm, Duration validity, NoncePolicy noncePolicy,
int maximumNonceBytes, Set<OcspResponseService.CertIdHash> hashes, int maximumRequestBytes,
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,
responderCredentialId, signingKeyRef, chainPathId, signatureAlgorithm, signatureBindingId,
bindingCommitment, responderIdForm, validity, noncePolicy, maximumNonceBytes, hashes,
maximumRequestBytes, maximumEntries, cacheLifetime, State.INACTIVE, clock.instant(),
"0".repeat(64));
maximumRequestBytes, maximumEntries, cacheLifetime, requestAuthenticationMode,
requesterTrustPolicy, State.INACTIVE, clock.instant(), "0".repeat(64));
return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(),
draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(),
draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(),
draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(),
draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(),
draft.state(), draft.createdAt(), commitment(draft, true));
draft.requestAuthenticationMode(), draft.requesterTrustPolicy(), draft.state(), draft.createdAt(),
commitment(draft, true));
}
private Responder seal(Responder value, State state) {
@@ -293,13 +449,15 @@ public final class OcspResponderService {
value.chainPathId(), value.signatureAlgorithm(), value.signatureBindingId(),
value.signatureBindingCommitment(), value.responderIdForm(), value.responseValidity(),
value.noncePolicy(), value.maximumNonceBytes(), value.acceptedHashes(), value.maximumRequestBytes(),
value.maximumEntries(), value.cacheLifetime(), state, value.createdAt(), value.configurationCommitment());
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(),
draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(),
draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(),
draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(),
draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(),
draft.state(), draft.createdAt(), commitment(draft, true));
draft.requestAuthenticationMode(), draft.requesterTrustPolicy(), draft.state(), draft.createdAt(),
commitment(draft, true));
}
private void validateDependencies(Responder value, boolean proveSigning) {
@@ -310,6 +468,26 @@ public final class OcspResponderService {
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();
IssuerChainPath path = repository.chainPath(value.chainPathId()).orElseThrow();
if (!issuer.authorityId().equals(value.authorityId()) || !path.authorityId().equals(value.authorityId())
@@ -399,11 +577,25 @@ public final class OcspResponderService {
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, boolean ignoreCommitment) {
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(1024);
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.issuerId().value()); output.writeInt(value.signingMode().code());
write(output, value.responderCredentialId().value()); write(output, value.signingKeyRef().value());
@@ -417,7 +609,11 @@ public final class OcspResponderService {
output.writeInt(hash.code());
}
output.writeInt(value.maximumRequestBytes()); output.writeInt(value.maximumEntries());
output.writeLong(value.cacheLifetime().toSeconds()); output.writeInt(value.state().code());
output.writeLong(value.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());
write(output, ignoreCommitment ? "0".repeat(64) : value.configurationCommitment()); output.flush();
return bytes.toByteArray();
@@ -427,7 +623,7 @@ public final class OcspResponderService {
private static Responder decode(byte[] payload) {
if (payload.length == 0 || payload.length > 65_536) throw new IllegalStateException("OCSP record bound differs");
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) {
if (input.readInt() != 1) throw new IOException("OCSP record schema is obsolete");
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));
PkiId authority = new PkiId(read(input)); PkiId issuer = 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()));
}
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());
String commitment = read(input); if (input.read() != -1) throw new IOException("Trailing OCSP record data");
return new Responder(id, alias, realm, authority, issuer, mode, credential, key, path, algorithm,
binding, bindingCommitment, form, validity, nonce, nonceBytes, hashes, requestBytes,
entries, cache, state, created, commitment);
entries, cache, authenticationMode, trustPolicy, state, created, commitment);
} catch (IOException | RuntimeException failure) {
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");
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 {
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");
@@ -472,6 +725,13 @@ public final class OcspResponderService {
private static void requireDigest(String value) {
if (value == null || !value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("OCSP commitment is invalid");
}
private static void 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) {
if (value == null || value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
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().authorityId().value()) + ";issuer="
+ 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.ListOcspResponders value -> "offset=" + value.offset()
+ ";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_REVOKE(80), CERTIFICATE_HOLD(81), CERTIFICATE_RELEASE_HOLD(82),
REVOCATION_HISTORY_READ(83), CRL_GENERATE(84), CRL_PUBLISH(85), CRL_DOWNLOAD(86),
OCSP_ADMINISTER(87), 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),
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),
@@ -181,17 +182,25 @@ public final class Permission {
* @param profileId explicit profile when applicable
*/
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. */
public Scope {
Objects.requireNonNull(realmId, "realmId");
authorityId = Objects.requireNonNull(authorityId, "authorityId");
issuerId = Objects.requireNonNull(issuerId, "issuerId");
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()) {
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()),
"signingMode", text(value.signingMode().name()), "responderCredentialId",
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()));
}
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";
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_STRING_BYTES = 16_384;
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 {
writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId());
writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId());
writeOptionalString(out, value.responderId());
}
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 {

View File

@@ -211,6 +211,39 @@ public final class ServerRealmContext implements AutoCloseable {
requireOpen();
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
* shared realm audit authority.

View File

@@ -36,6 +36,7 @@ package zeroecho.pki.server.http;
import java.time.Instant;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -243,9 +244,11 @@ final class HttpOperationCodec {
}
case ServerControlOperation.CreateGrant.NAME -> {
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,
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"),
fields.text("principalId"), Permission.Effect.valueOf(fields.text("effect")),
Permission.Action.valueOf(fields.text("action")),
@@ -432,11 +435,18 @@ final class HttpOperationCodec {
fields.exact("accountId"); yield new ServerControlOperation.DeactivateAcmeAccount(fields.text("accountId"));
}
case ServerControlOperation.RegisterOcspResponder.NAME -> {
fields.exact("responderId", "alias", "authorityId", "issuerId", "signingMode",
fields.allowed(Set.of("responderId", "alias", "authorityId", "issuerId", "signingMode",
"responderCredentialId", "signingKeyRef", "chainPathId", "signatureAlgorithm",
"signatureBindingId", "signatureBindingCommitment", "responderIdForm",
"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(
fields.text("responderId"), fields.text("alias"),
fields.pkiId("authorityId"), fields.pkiId("issuerId"),
@@ -450,7 +460,8 @@ final class HttpOperationCodec {
fields.integer("maximumNonceBytes"),
fields.enumSet("acceptedHashes", OcspResponseService.CertIdHash.class),
fields.integer("maximumRequestBytes"), fields.integer("maximumEntries"),
Duration.ofMillis(fields.longValue("cacheLifetimeMillis"))));
Duration.ofMillis(fields.longValue("cacheLifetimeMillis")), authenticationMode,
trustPolicy));
}
case ServerControlOperation.InspectOcspResponder.NAME -> {
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) {
try {
Duration result = Duration.parse(value);
@@ -587,6 +626,7 @@ final class HttpOperationCodec {
if (!(value instanceof PkiOperationValue.Text text)) throw type(); return text.value(); }
Optional<String> optionalText(String name) { return fields.containsKey(name)
? 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);
if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); }
int integer(String name) { return Math.toIntExact(longValue(name)); }
@@ -618,6 +658,11 @@ final class HttpOperationCodec {
return Set.copyOf(result);
}
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()))
throw new IllegalArgumentException("Request fields were not consumed"); }
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.server.AdministrativeAuthenticationMode;
import zeroecho.pki.server.OcspResponderService;
import zeroecho.pki.server.OcspRequesterService;
import zeroecho.pki.server.PkiServerConfiguration;
import zeroecho.pki.server.ServerRealmContext;
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
@@ -86,6 +87,8 @@ final class OcspHttpHandler implements HttpHandler {
private final Clock clock;
private final RequestIds requestIds;
private final BooleanSupplier ready;
private final OcspRequesterService requesters;
private final NonceReplayGuard nonceReplay = new NonceReplayGuard();
OcspHttpHandler(PkiServerConfiguration.PublicListener configuration, ServerRealmContext realm,
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.clock = java.util.Objects.requireNonNull(clock); this.requestIds = java.util.Objects.requireNonNull(requestIds);
this.ready = java.util.Objects.requireNonNull(ready);
this.requesters = new OcspRequesterService(realm, clock);
}
@Override public void handle(HttpExchange exchange) throws IOException {
String requestId = "unavailable-request"; boolean admitted = false;
OcspResponderService.Responder auditedResponder = null;
Optional<String> transportPrincipal = Optional.empty();
Optional<String> requesterPrincipal = Optional.empty();
try {
requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER));
if (!ready.getAsBoolean()) { transportFailure(exchange, 503); return; }
requireHeadersBounded(exchange.getRequestHeaders());
validateTransport(exchange, requestId);
transportPrincipal = validateTransport(exchange, requestId);
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());
auditedResponder = responder;
audit(requestId, Optional.of(responder), "ACCEPTED");
if (!runtime.tryAdmit()) { audit(requestId, Optional.of(responder), "OVERLOAD"); transportFailure(exchange, 429); return; }
admitted = true;
audit(requestId, Optional.of(responder), transportPrincipal, Optional.empty(), "ACCEPTED");
int maximumRequestBytes = Math.min(configuration.maximumBodyBytes(), responder.maximumRequestBytes());
byte[] request = route.encoded().orElseGet(() -> read(exchange, maximumRequestBytes));
if (request.length > maximumRequestBytes) { transportFailure(exchange, 413); return; }
OcspRequestParser.Parsed parsed = OcspRequestParser.parse(request, responder.maximumEntries(),
responder.acceptedHashes(), responder.maximumNonceBytes());
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());
OcspResponseService.Response response = execute(responder, parsed);
send(exchange, responder, response, parsed.nonce().isPresent(), requestId);
audit(requestId, Optional.of(responder), "RESPONDED_GOOD_" + response.goodCount()
requireAuthenticationMode(responder.requestAuthenticationMode(), parsed.signedRequest());
Execution execution = execute(responder, parsed);
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());
} catch (UnknownAlias unavailable) { audit(requestId, Optional.empty(), "UNKNOWN_RESPONDER"); transportFailure(exchange, 404);
} catch (InactiveResponder inactive) { audit(requestId, Optional.empty(), "INACTIVE_RESPONDER"); transportFailure(exchange, 503);
} catch (MethodFailure method) { audit(requestId, Optional.ofNullable(auditedResponder), "METHOD_REJECTED"); transportFailure(exchange, 405);
} catch (MediaFailure media) { audit(requestId, Optional.ofNullable(auditedResponder), "MEDIA_REJECTED"); transportFailure(exchange, 406);
} catch (IllegalArgumentException malformed) { audit(requestId, Optional.ofNullable(auditedResponder), "MALFORMED"); protocolFailure(exchange, org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST);
} catch (RejectedExecutionException overload) { audit(requestId, Optional.ofNullable(auditedResponder), "OVERLOAD"); transportFailure(exchange, 429);
} catch (TimeoutException deadline) { audit(requestId, Optional.ofNullable(auditedResponder), "DEADLINE"); transportFailure(exchange, 504);
} catch (RuntimeException failure) { audit(requestId, Optional.ofNullable(auditedResponder), "UNAVAILABLE"); transportFailure(exchange, 503);
} catch (SignatureRequired required) { audit(requestId, Optional.ofNullable(auditedResponder),
transportPrincipal, requesterPrincipal, "SIGNATURE_REQUIRED"); protocolFailure(exchange,
org.bouncycastle.cert.ocsp.OCSPRespBuilder.SIG_REQUIRED);
} catch (OcspRequesterService.UnauthorizedException unauthorized) { audit(requestId,
Optional.ofNullable(auditedResponder), transportPrincipal, requesterPrincipal,
"REQUESTER_UNAUTHORIZED_" + unauthorized.classification()); protocolFailure(exchange,
org.bouncycastle.cert.ocsp.OCSPRespBuilder.UNAUTHORIZED);
} 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(); }
}
@@ -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 {
IssuerGeneration issuer = realm.session().repository().issuer(responder.issuerId()).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.responderIdForm(), produced, produced, produced.plus(responder.responseValidity()),
parsed.nonce(), parsed.requests());
ServerRuntime.Submitted<OcspResponseService.Response> submitted = runtime.submit(cancellation -> {
ServerRuntime.Submitted<Execution> submitted = runtime.submit(cancellation -> {
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 {
return submitted.future().get(configuration.maximumStreamDuration().toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException failure) {
submitted.cancellation().cancel(); submitted.future().cancel(true); Thread.currentThread().interrupt();
throw new TimeoutException("OCSP operation interrupted");
} catch (TimeoutException failure) {
submitted.cancellation().cancel(); submitted.future().cancel(true);
throw failure;
} catch (ExecutionException failure) {
if (failure.getCause() instanceof RuntimeException runtimeFailure) throw runtimeFailure;
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());
boolean forwarded = ForwardedClientCertificateParser.containsForwardedIdentity(headers);
if (configuration.authentication().mode() == AdministrativeAuthenticationMode.DIRECT_MTLS) {
if (forwarded) throw new IllegalArgumentException("Forwarded identity is prohibited");
return;
return Optional.empty();
}
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");
}
return transport;
}
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,
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.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 {
long seconds = Math.min(responder.cacheLifetime().toSeconds(), responder.responseValidity().toSeconds());
String validator = etag(response.der());
@@ -315,19 +355,46 @@ final class OcspHttpHandler implements HttpHandler {
exchange.sendResponseHeaders(status, -1);
}
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<>();
details.put("request", requestId); details.put("classification", classification);
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) {
try { return '"' + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)) + '"'; }
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); }
}
private record Route(String alias, Optional<byte[]> encoded) { Route { encoded = encoded.map(byte[]::clone); } }
private 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 InactiveResponder extends RuntimeException { private static final long serialVersionUID = 1L; }
private static final class MethodFailure extends RuntimeException { private static final long serialVersionUID = 1L; }
private static final class MediaFailure extends RuntimeException { private static final long serialVersionUID = 1L; }
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.Base64;
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.ASN1OctetString;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.ocsp.OCSPRequest;
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
import org.bouncycastle.asn1.ocsp.Signature;
import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.cert.ocsp.CertificateID;
import org.bouncycastle.cert.ocsp.OCSPReq;
import org.bouncycastle.cert.ocsp.Req;
import zeroecho.pki.application.OcspResponseService;
import zeroecho.pki.server.OcspRequesterService;
/** Narrow canonical DER and unpadded Base64url OCSP request decoder. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.ExceptionAsFlowControl", "PMD.PreserveStackTrace",
"PMD.AvoidCatchingGenericException", "PMD.CyclomaticComplexity" })
"PMD.AvoidCatchingGenericException", "PMD.CyclomaticComplexity", "PMD.AvoidLiteralsInIfCondition" })
final class OcspRequestParser {
/* default */ record Parsed(List<OcspResponseService.CertId> requests, Optional<byte[]> nonce) {
Parsed { requests = List.copyOf(requests); nonce = nonce.map(byte[]::clone); }
/* default */ record Parsed(List<OcspResponseService.CertId> requests, Optional<byte[]> nonce,
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); }
}
@@ -76,13 +87,21 @@ final class OcspRequestParser {
/* default */ static Parsed parse(byte[] der, int maximumEntries,
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 {
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();
if (entries.length == 0 || entries.length > maximumEntries) throw malformed();
List<OcspResponseService.CertId> result = new ArrayList<>(entries.length);
for (Req entry : entries) {
if (entry.getSingleRequestExtensions() != null) throw malformed();
CertificateID id = entry.getCertID();
org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm = id.toASN1Primitive().getHashAlgorithm();
OcspResponseService.CertIdHash hash;
@@ -110,10 +129,73 @@ final class OcspRequestParser {
if (value.length == 0 || value.length > maximumNonceBytes) throw malformed();
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(); }
}
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() {
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.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
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.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
import org.bouncycastle.cert.ocsp.CertificateID;
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.KeyringStore;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
@@ -199,12 +203,18 @@ class AcmeEndToEndTest {
assertOcspNonce(wire, publicPort, chain.get(1), chain.get(0));
assertOcspUnknownAndMulti(wire, publicPort, chain.get(1), chain.get(0));
assertOcspNoncePolicies(wire, publicPort, chain.get(1), chain.get(0));
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.postAsGet(accountUri).status());
System.out.println("...https-jws-http01-issuance=true");
}
registerManagedRequester(fixture, leafDer, leafKey);
try (PkiHttpsServer restarted = start(fixture);
AcmeTestClient replacement = new AcmeTestClient(wire, directoryUri, replacementKey);
AcmeTestClient old = new AcmeTestClient(wire, directoryUri, accountKey)) {
@@ -215,12 +225,22 @@ class AcmeEndToEndTest {
assertEquals(403, old.postAsGet(accountUri).status());
assertEquals(200, replacement.postAsGet(orderUri).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"));
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(leafDer);
assertEquals(200, replacement.kid(revoke,
"{\"certificate\":\"" + encoded + "\",\"reason\":1}").status());
List<X509Certificate> chain = certificates(replacement.postAsGet(certificateUri).bodyText());
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true);
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,
"{\"certificate\":\"" + encoded + "\",\"reason\":1}").status() == 200);
System.out.println("...rollover-restart-revocation=true");
@@ -258,6 +278,8 @@ class AcmeEndToEndTest {
+ "/directory");
HttpClient wire = HttpClient.newBuilder().sslContext(tls.anonymousContext())
.connectTimeout(Duration.ofMillis(500)).build();
X509Certificate packagedIssuer = null;
X509Certificate packagedCertificate = null;
try {
awaitDirectory(wire, directoryUri, process, output);
KeyPair accountKey = ec((byte) 42);
@@ -292,7 +314,11 @@ class AcmeEndToEndTest {
URI certificate = URI.create(AcmeTestClient.text(finalized, "certificate"));
List<X509Certificate> chain = certificates(client.postAsGet(certificate).bodyText());
assertEquals(2, chain.size());
packagedCertificate = chain.get(0);
packagedIssuer = chain.get(1);
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());
String der = Base64.getUrlEncoder().withoutPadding().encodeToString(chain.get(0).getEncoded());
assertEquals(200, client.kid(revoke,
@@ -307,6 +333,21 @@ class AcmeEndToEndTest {
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");
}
@@ -407,6 +448,7 @@ class AcmeEndToEndTest {
zeroecho.pki.application.OcspResponseService.CertIdHash.SHA256),
16_384, 8, Duration.ofMinutes(1));
context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true);
registerSignedResponder(context, responder, authority, issuer.issuerId());
registerNonceResponder(context, responder, "ocsp-e2e-reject", "reject",
OcspResponderService.NoncePolicy.REJECT);
registerNonceResponder(context, responder, "ocsp-e2e-required", "required",
@@ -448,6 +490,162 @@ class AcmeEndToEndTest {
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 {
return PkiHttpsServer.start(fixture.configuration(), fixture.dependencies(), ServerTestSupport.CLOCK,
HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader());
@@ -766,6 +964,112 @@ class AcmeEndToEndTest {
postOcsp(client, publicPort, "required", withoutNonce).getStatus());
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL,
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)

View File

@@ -104,6 +104,44 @@ class AuthorizationAndRoleTest {
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
void loadsExactTemplatesAndRejectsUnknownSchemaFields() throws Exception {
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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigInteger;
@@ -119,8 +120,8 @@ class OcspRequestParserTest {
}
@Test
void rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority() throws Exception {
System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority");
void retainsExactSignedRequestAndRequiresExplicitSignerBounds() throws Exception {
System.out.println("retainsExactSignedRequestAndRequiresExplicitSignerBounds");
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keys = generator.generateKeyPair();
@@ -133,8 +134,16 @@ class OcspRequestParserTest {
new X509CertificateHolder[] { issuer }).getEncoded();
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(signed, 1,
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
System.out.println("...signed-request-authority=false");
System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority...ok");
OcspRequestParser.Parsed parsed = OcspRequestParser.parse(signed, 1,
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 {