From 4f01c57360737f0323618b8d7bdcae70dedccf57 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Wed, 5 Aug 2026 19:06:13 +0200 Subject: [PATCH] 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. --- docs/pki-server-ocsp.md | 97 +++- .../pki/server/AuthorizationEngine.java | 2 + .../pki/server/OcspRequesterService.java | 447 ++++++++++++++++++ .../pki/server/OcspResponderService.java | 320 +++++++++++-- .../server/OperationSecurityDescriptors.java | 3 +- .../java/zeroecho/pki/server/Permission.java | 13 +- .../ServerControlOperationExecutor.java | 3 + .../pki/server/ServerControlStore.java | 6 +- .../pki/server/ServerRealmContext.java | 33 ++ .../pki/server/http/HttpOperationCodec.java | 55 ++- .../pki/server/http/OcspHttpHandler.java | 119 ++++- .../pki/server/http/OcspRequestParser.java | 92 +++- .../zeroecho/pki/server/AcmeEndToEndTest.java | 304 ++++++++++++ .../pki/server/AuthorizationAndRoleTest.java | 38 ++ .../server/http/OcspRequestParserTest.java | 17 +- .../api/algorithm/X509AlgorithmBinding.java | 4 +- .../framework/x509/StandardX509Bindings.java | 2 +- 17 files changed, 1468 insertions(+), 87 deletions(-) create mode 100644 pki-server/src/main/java/zeroecho/pki/server/OcspRequesterService.java diff --git a/docs/pki-server-ocsp.md b/docs/pki-server-ocsp.md index 2dc1b7f..4ead519 100644 --- a/docs/pki-server-ocsp.md +++ b/docs/pki-server-ocsp.md @@ -12,7 +12,9 @@ An OCSP responder is a durable server-control record. Its alias binds exactly one realm, logical authority, issuer generation, responder certificate, `KeyRef`, explicit chain path, signature algorithm/binding commitment, nonce policy, response-validity policy, accepted CertID hashes, and finite request -bounds. Activation revalidates those dependencies and freezes their +bounds. Each record also carries an explicit request-authentication mode and, +for signed modes, a finite immutable requester trust, mapping, signature, +revocation, and chain-bound policy. Activation revalidates those dependencies and freezes their commitments. There is no first/last issuer selection, filename inference, timestamp inference, runtime path building, or fallback signing algorithm. @@ -66,6 +68,77 @@ nonce echo: nonce-bearing responses use no-store ``` +## Request authentication + +Every responder selects exactly one mode: + +- `UNSIGNED_ONLY` is the default public responder policy. Unsigned requests are + accepted and syntactically signed requests receive OCSP `unauthorized`. +- `UNSIGNED_OR_AUTHORIZED_SIGNED` preserves anonymous requests, but any signed + request must authenticate and authorize completely; it never falls back to + anonymous handling. +- `AUTHORIZED_SIGNED_REQUIRED` returns OCSP `sigRequired` for unsigned requests + and accepts only authorized signed requests. + +Signed request authentication is protocol-scoped. ZeroEcho retains the exact +canonical `tbsRequest` DER, requires a canonical `directoryName` +`requestorName` equal to the signer certificate subject, validates the bounded +included signer chain against configured trust selectors, and verifies the +signature using an active `OCSP_REQUEST_SIGNATURE` binding. Current standard +bindings permit ECDSA/SHA-256, Ed25519, RSA PKCS#1/SHA-256 and strict +RSA-PSS/SHA-256 when explicitly listed. SHA-1 request signatures are rejected. + +Trust and authorization are independent. A cryptographic certificate, SPKI, or +managed issuer-generation-plus-serial commitment must map to exactly one enabled +persisted `SecurityPrincipal`. That principal needs an explicit +`OCSP_STATUS_QUERY` grant scoped to the exact realm, responder, authority, and +issuer generation. Explicit deny wins. `OCSP_ADMINISTER` grants no query right, +and query authority grants no administration, content, search, ownership, +disclosure, or PII right. Multi-entry authorization is all-or-nothing. + +Requester certificate revocation uses one explicit policy: + +- `LOCAL_AUTHORITATIVE_REQUIRED` requires a managed ZeroEcho credential and + current local revocation state. It is the recommended closed-enterprise mode. +- `LOCAL_AUTHORITATIVE_IF_MANAGED` checks managed credentials locally and uses + validity plus configured trust for external credentials. +- `VALIDITY_AND_TRUST_ONLY` performs no revocation check and must be selected + deliberately with its stale-credential risk understood. + +ZeroEcho does not follow AIA, CRL, or OCSP URLs while authenticating a requester +and never calls the responder recursively. A request signature authenticates +integrity and requester identity; it does not replace nonce freshness. Replay of +a permitted read-only request remains valid when nonce policy permits it. +`REQUIRED` nonce responders keep a finite derived process-local replay window; +restart may discard that operational window without changing PKI authority. + +Safe policy examples omit real identities and commitments: + +```text +public unsigned: + requestAuthenticationMode = UNSIGNED_ONLY + +mixed transition: + requestAuthenticationMode = UNSIGNED_OR_AUTHORIZED_SIGNED + requesterRevocationPolicy = LOCAL_AUTHORITATIVE_REQUIRED + +closed managed enterprise: + requestAuthenticationMode = AUTHORIZED_SIGNED_REQUIRED + trustSelectors = [managed issuer generations] + requiredPermission = OCSP_STATUS_QUERY + requesterRevocationPolicy = LOCAL_AUTHORITATIVE_REQUIRED + +closed external trust: + requestAuthenticationMode = AUTHORIZED_SIGNED_REQUIRED + trustSelectors = [configured trust anchors] + requesterRevocationPolicy = LOCAL_AUTHORITATIVE_IF_MANAGED +``` + +Migration is explicit: replace the pre-release responder record with its desired +mode and complete requester policy, validate scoped grants, and activate it +through the configured approval workflow. The obsolete schema is rejected; no +mode is inferred and no compatibility reader exists. + ## Requests and responses POST uses `Content-Type: application/ocsp-request`. GET uses an unpadded, @@ -76,10 +149,10 @@ canonical URL-safe Base64 request path: ``` Query-string requests and alternate Base64 normalization are rejected. The -strict DER parser accepts unsigned requests only, consumes the complete input, -rejects duplicate or unknown critical extensions, and enforces configured byte -and entry bounds. Signed OCSP requests are rejected in this release; a request -signature is never treated as administrative or certificate-owner authority. +strict DER parser consumes the complete input, rejects all per-entry extensions +and duplicate or unknown critical request extensions, and enforces configured +byte, entry, signer-certificate, and chain bounds. Signed requests additionally +require the exact canonical optional-signature structure described above. Configured CertID hashing supports SHA-1 and SHA-256. SHA-1 is permitted only for RFC-compatible issuer-name/key hashes. It is never enabled as a certificate @@ -98,18 +171,22 @@ recovery-required authority returns a transient protocol/transport failure, not Successful responses are strict signed DER with `application/ocsp-response`, `nosniff`, `producedAt`, `thisUpdate`, and -`nextUpdate`. Nonce-free responses carry a strong ETag and bounded public cache +`nextUpdate`. Nonce-free unsigned responses carry a strong ETag and bounded public cache policy. An echoed nonce is returned exactly and makes the response non-cacheable. +Every authenticated signed response uses `Cache-Control: private, no-store` and +`Pragma: no-cache`, even without a nonce; shared caches never store it. No request DER, response DER, serial, issuer hash, nonce, certificate identity, key reference, or parser/provider exception is logged or audited. ## Deployment topology and capacity Direct mode permits anonymous OCSP over the public TLS listener. An optional -public client certificate does not change certificate status, and forwarded -identity headers are rejected. In trusted-reverse-proxy mode the backend proxy -hop still requires mTLS and the dedicated forwarding permission; proxy and -forwarded end-client identities do not authorize or select OCSP status. Source +public client certificate neither replaces nor supplements the signed requester +and does not change certificate status; forwarded identity headers remain +rejected. In trusted-reverse-proxy mode the backend proxy hop still requires +mTLS and the dedicated forwarding permission. The proxy transport principal, +forwarded administrative principal, and signed OCSP requester remain distinct +and do not inherit one another's permissions or select OCSP status. Source IP, `Forwarded`, and `X-Forwarded-For` are never responder authority. OCSP has a separate bounded runtime lane even though it shares the public diff --git a/pki-server/src/main/java/zeroecho/pki/server/AuthorizationEngine.java b/pki-server/src/main/java/zeroecho/pki/server/AuthorizationEngine.java index 48bfa95..c037acf 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/AuthorizationEngine.java +++ b/pki-server/src/main/java/zeroecho/pki/server/AuthorizationEngine.java @@ -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()); diff --git a/pki-server/src/main/java/zeroecho/pki/server/OcspRequesterService.java b/pki-server/src/main/java/zeroecho/pki/server/OcspRequesterService.java new file mode 100644 index 0000000..db9c358 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/OcspRequesterService.java @@ -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 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 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 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 requests) { + try { + OcspResponderService.RequesterTrustPolicy policy = responder.requesterTrustPolicy().orElseThrow(); + List 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 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 chain, X509Certificate anchor) { + try { + Set 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 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 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 managedCredential(OcspResponderService.RequesterTrustPolicy policy, + X509Certificate signer) throws CertificateException { + BigInteger serial = positiveSerial(signer); + String commitment = digest(signer.getEncoded()); + List 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 certificates(List encoded) throws CertificateException { + if (encoded.isEmpty()) throw new CertificateException("Signer certificate is required"); + List 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(); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/OcspResponderService.java b/pki-server/src/main/java/zeroecho/pki/server/OcspResponderService.java index 6576c8b..382993a 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/OcspResponderService.java +++ b/pki-server/src/main/java/zeroecho/pki/server/OcspResponderService.java @@ -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 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 trustSelectors, + List principalMappings, List 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 signatureBindingId, String signatureBindingCommitment, OcspResponseService.ResponderId responderIdForm, Duration responseValidity, NoncePolicy noncePolicy, int maximumNonceBytes, Set acceptedHashes, int maximumRequestBytes, - int maximumEntries, Duration cacheLifetime) { + int maximumEntries, Duration cacheLifetime, RequestAuthenticationMode requestAuthenticationMode, + Optional 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 signatureBindingId, String signatureBindingCommitment, + OcspResponseService.ResponderId responderIdForm, Duration responseValidity, NoncePolicy noncePolicy, + int maximumNonceBytes, Set 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 acceptedHashes, int maximumRequestBytes, - int maximumEntries, Duration cacheLifetime, State state, Instant createdAt, + int maximumEntries, Duration cacheLifetime, RequestAuthenticationMode requestAuthenticationMode, + Optional 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 signing; private final Clock clock; + private final Map 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 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 aliases(String alias) { - List matches = new ArrayList<>(); - int offset = 0; - while (true) { - ServerControlStore.Page 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 recovered = new java.util.HashMap<>(); int offset = 0; while (true) { ServerControlStore.Page 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 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 signatureBindingId, String bindingCommitment, + OcspResponseService.ResponderId responderIdForm, Duration validity, NoncePolicy noncePolicy, + int maximumNonceBytes, Set hashes, int maximumRequestBytes, + int maximumEntries, Duration cacheLifetime, RequestAuthenticationMode requestAuthenticationMode, + Optional 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 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 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 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 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 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 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"); diff --git a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java index 41b4e49..33ac9c0 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java +++ b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java @@ -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(); diff --git a/pki-server/src/main/java/zeroecho/pki/server/Permission.java b/pki-server/src/main/java/zeroecho/pki/server/Permission.java index e519384..b7d601f 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/Permission.java +++ b/pki-server/src/main/java/zeroecho/pki/server/Permission.java @@ -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 authorityId, Optional issuerId, - Optional profileId) { + Optional profileId, Optional 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 authorityId, Optional issuerId, + Optional profileId) { + this(realmId, authorityId, issuerId, profileId, Optional.empty()); + } } /** diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java index 5480a2a..1f5ac47 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java @@ -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) { diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java index 81f2547..4ffee2f 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java @@ -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 { diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java index d5a5c54..4732171 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java @@ -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 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. diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java index bff6b89..e29e30a 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java @@ -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 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 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 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 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 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 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); diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/OcspHttpHandler.java b/pki-server/src/main/java/zeroecho/pki/server/http/OcspHttpHandler.java index 9190169..c5fad93 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/OcspHttpHandler.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/OcspHttpHandler.java @@ -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 transportPrincipal = Optional.empty(); + Optional 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 submitted = runtime.submit(cancellation -> { + ServerRuntime.Submitted submitted = runtime.submit(cancellation -> { cancellation.throwIfCancelled(); - return realm.session().ocsp().orElseThrow().respond(command); + Optional 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 validateTransport(HttpExchange exchange, String requestId) { Map> 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 context = tlsContext(exchange, requestId, headers); - if (context.isEmpty() || authenticator.authenticatePublicProxyTransport(context.orElseThrow()).isEmpty()) { + Optional transport = context.flatMap(authenticator::authenticatePublicProxyTransport); + if (transport.isEmpty()) { throw new IllegalArgumentException("Proxy transport is unauthenticated"); } + return transport; } private Optional 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 responder, - String classification) { + Optional transportPrincipal, Optional requesterPrincipal, String classification) { Map 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 encoded) { Route { encoded = encoded.map(byte[]::clone); } } + private record Execution(OcspResponseService.Response response, Optional requesterPrincipal) { + Execution { java.util.Objects.requireNonNull(response); requesterPrincipal = java.util.Objects.requireNonNull(requesterPrincipal); } + } + private static void requireAuthenticationMode(OcspResponderService.RequestAuthenticationMode mode, + Optional 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 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; + } + } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/OcspRequestParser.java b/pki-server/src/main/java/zeroecho/pki/server/http/OcspRequestParser.java index 810f15e..0add264 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/OcspRequestParser.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/OcspRequestParser.java @@ -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 requests, Optional nonce) { - Parsed { requests = List.copyOf(requests); nonce = nonce.map(byte[]::clone); } + /* default */ record Parsed(List requests, Optional nonce, + Optional signedRequest) { + Parsed { + requests = List.copyOf(requests); + nonce = nonce.map(byte[]::clone); + signedRequest = Objects.requireNonNull(signedRequest, "signedRequest"); + } @Override public Optional nonce() { return nonce.map(byte[]::clone); } } @@ -76,13 +87,21 @@ final class OcspRequestParser { /* default */ static Parsed parse(byte[] der, int maximumEntries, Set acceptedHashes, int maximumNonceBytes) { + return parse(der, maximumEntries, acceptedHashes, maximumNonceBytes, 0, 0); + } + + /* default */ static Parsed parse(byte[] der, int maximumEntries, + Set 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 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 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 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 certificates = new ArrayList<>(holders.length); + Set 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"); } diff --git a/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java b/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java index 27c5e4f..0a4ca52 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java @@ -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 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 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 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 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 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 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) diff --git a/pki-server/src/test/java/zeroecho/pki/server/AuthorizationAndRoleTest.java b/pki-server/src/test/java/zeroecho/pki/server/AuthorizationAndRoleTest.java index b05b024..7e12738 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/AuthorizationAndRoleTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/AuthorizationAndRoleTest.java @@ -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"); diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/OcspRequestParserTest.java b/pki-server/src/test/java/zeroecho/pki/server/http/OcspRequestParserTest.java index d6b4822..5b24f95 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/http/OcspRequestParserTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/http/OcspRequestParserTest.java @@ -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 { diff --git a/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java index 7151a90..25c42fc 100644 --- a/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java +++ b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java @@ -72,7 +72,9 @@ public record X509AlgorithmBinding(String bindingId, AlgorithmIdentity algorithm /** Certificate signature. */ CERTIFICATE_SIGNATURE, /** Certificate-revocation-list signature. */ - CRL_SIGNATURE + CRL_SIGNATURE, + /** Signed OCSP request signature. */ + OCSP_REQUEST_SIGNATURE } /** Authority that owns the OID assignment. */ diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java index 4bf776e..89f081b 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java @@ -402,7 +402,7 @@ public final class StandardX509Bindings { Optional.empty(), commitment)); } return List.of(X509AlgorithmBinding.Role.CSR_SIGNATURE, X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE, - X509AlgorithmBinding.Role.CRL_SIGNATURE).stream() + X509AlgorithmBinding.Role.CRL_SIGNATURE, X509AlgorithmBinding.Role.OCSP_REQUEST_SIGNATURE).stream() .map(bindingRole -> new X509AlgorithmBinding(id + "." + bindingRole.name().toLowerCase(java.util.Locale.ROOT), identity, bindingRole, identifier.oid(), X509AlgorithmBinding.Origin.STANDARD, 1, parameters, keyEncoding,