From 35480120f811e07420a89114739d202c7863ddba Mon Sep 17 00:00:00 2001 From: Mohsen Zainalpour Date: Tue, 27 Oct 2026 20:26:27 -0400 Subject: [PATCH] feat(dsl): present a client certificate from a proxy to an mTLS upstream rift 0.24.0 (rift#1609) sends a proxy's cert, key and passphrase to an upstream that asks for a client certificate, as Mountebank does. The typed builder had no way to set them; raw JSON could, but nothing enforced the pair, and an older engine drops all three silently, so the handshake fails at request time with no clue why. ProxySpec.clientCertificate(cert, key[, passphrase]) writes them as a pair, checks the PEM blocks up front, and creating such an imposter on an engine older than 0.24.0 is refused, for the raw wire keys too. The DSL already takes private keys this way (ImposterSpec.https). Closes #342 --- docs/design/sdk-api.md | 5 + .../conformance/ProxyClientCertificateIT.java | 128 ++++++++++++++++++ .../src/test/resources/tls/README.md | 10 +- .../resources/tls/client-key-encrypted.pem | 8 ++ .../src/test/resources/tls/client-key.pem | 5 + .../src/test/resources/tls/client.pem | 12 ++ .../io/github/achirdlabs/rift/RiftImpl.java | 16 +++ .../github/achirdlabs/rift/dsl/ProxySpec.java | 95 +++++++++++-- .../rift/ProxyClientCertificateGateTest.java | 98 ++++++++++++++ .../rift/dsl/ProxyClientCertificateTest.java | 96 +++++++++++++ 10 files changed, 462 insertions(+), 11 deletions(-) create mode 100644 rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/ProxyClientCertificateIT.java create mode 100644 rift-java-conformance/src/test/resources/tls/client-key-encrypted.pem create mode 100644 rift-java-conformance/src/test/resources/tls/client-key.pem create mode 100644 rift-java-conformance/src/test/resources/tls/client.pem create mode 100644 rift-java-core/src/test/java/io/github/achirdlabs/rift/ProxyClientCertificateGateTest.java create mode 100644 rift-java-core/src/test/java/io/github/achirdlabs/rift/dsl/ProxyClientCertificateTest.java diff --git a/docs/design/sdk-api.md b/docs/design/sdk-api.md index e6077d9..325f01b 100644 --- a/docs/design/sdk-api.md +++ b/docs/design/sdk-api.md @@ -682,6 +682,11 @@ ProxySpec generateBy(PredicateGeneratorSpec generator) // .matching(fields).ca // .predicateOperator(GeneratorOperator) .except(regex) ProxySpec addWaitBehavior() // proxy.addWaitBehavior: records upstream latency on recorded stubs ProxySpec decorateWith(String script) // proxy.addDecorateBehavior: decorates the stubs this proxy records +ProxySpec clientCertificate(String certPem, String keyPem[, String passphrase]) + // proxy.cert/key/passphrase: presented to an mTLS upstream + // (rift >= 0.24.0, rift#1609; gated: an older engine drops + // them silently). A pair by construction; PEM blocks checked + // up front, key/cert matching left to the engine (400). (#342) // not to be confused with BehaviorChain.waitMs/decorate (§7.3), which act on this proxy's own // response — before it is served and before it is recorded. ``` diff --git a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/ProxyClientCertificateIT.java b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/ProxyClientCertificateIT.java new file mode 100644 index 0000000..175c426 --- /dev/null +++ b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/ProxyClientCertificateIT.java @@ -0,0 +1,128 @@ +package io.github.achirdlabs.rift.conformance; + +import io.github.achirdlabs.rift.EmbeddedOptions; +import io.github.achirdlabs.rift.Imposter; +import io.github.achirdlabs.rift.Rift; +import io.github.achirdlabs.rift.SpawnOptions; +import io.github.achirdlabs.rift.UpstreamTrust; +import io.github.achirdlabs.rift.dsl.ProxySpec; +import io.github.achirdlabs.rift.json.JsonString; +import io.github.achirdlabs.rift.model.Response; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Objects; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import static io.github.achirdlabs.rift.conformance.LiveEngine.gated; +import static io.github.achirdlabs.rift.dsl.RiftDsl.imposter; +import static io.github.achirdlabs.rift.dsl.RiftDsl.onGet; +import static io.github.achirdlabs.rift.dsl.RiftDsl.onRequest; +import static io.github.achirdlabs.rift.dsl.RiftDsl.proxyTo; +import static io.github.achirdlabs.rift.dsl.RiftDsl.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * A proxy presents a client certificate to an upstream that requires one (#342, rift#1609), with real + * TLS handshakes. The upstream is a rift HTTPS imposter serving {@code tls/leaf.pem} and requiring a + * client certificate issued by {@code tls/ca.pem}; the engine trusts that CA for outbound TLS. Only + * the client certificate differs between the cases, so only it can explain a refused handshake. + * Needs {@code RIFT_IT=1}. + */ +class ProxyClientCertificateIT { + + private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + @TestFactory + Stream withoutACertificateTheUpstreamRefusesTheProxy() { + return gated("no clientCertificate: the mTLS upstream refuses the handshake", () -> { + try (Rift rift = engine()) { + assertNotEquals(200, proxyThroughToMutualTlsOrigin(rift, p -> p).statusCode()); + } + }); + } + + @TestFactory + Stream theCertificateAndKeyAreAccepted() { + return gated("clientCertificate(cert, key): the mTLS upstream is reached", () -> { + try (Rift rift = engine()) { + HttpResponse answer = proxyThroughToMutualTlsOrigin(rift, + p -> p.clientCertificate(resource("tls/client.pem"), resource("tls/client-key.pem"))); + assertEquals(200, answer.statusCode(), () -> "proxied response: " + answer.body()); + assertEquals("up", answer.body()); + } + }); + } + + @TestFactory + Stream anEncryptedKeyIsDecryptedWithThePassphrase() { + return gated("clientCertificate(cert, encrypted key, passphrase): the mTLS upstream is reached", () -> { + try (Rift rift = engine()) { + HttpResponse answer = proxyThroughToMutualTlsOrigin(rift, p -> p.clientCertificate( + resource("tls/client.pem"), resource("tls/client-key-encrypted.pem"), "changeit")); + assertEquals(200, answer.statusCode(), () -> "proxied response: " + answer.body()); + } + }); + } + + @TestFactory + Stream theKeysReadBackFromTheEngine() { + return gated("the engine echoes cert/key and the model keeps them", () -> { + try (Rift rift = engine()) { + Imposter proxy = rift.create(imposter("proxy-readback").protocol("http").stub(onRequest().willReturn( + proxyTo("https://127.0.0.1:1").clientCertificate(resource("tls/client.pem"), resource("tls/client-key.pem"))))); + Response.Proxy read = (Response.Proxy) proxy.definition().stubs().get(0).responses().get(0); + assertEquals(new JsonString(resource("tls/client.pem")), read.proxy().extra().get("cert")); + assertEquals(new JsonString(resource("tls/client-key.pem")), read.proxy().extra().get("key")); + } + }); + } + + private static Rift engine() throws URISyntaxException { + UpstreamTrust trust = new UpstreamTrust.CaFile(resourcePath("tls/ca.pem")); + return switch (ConformanceTransport.selected()) { + case SPAWN -> Rift.spawn(SpawnOptions.builder().upstreamTrust(trust).build()); + case EMBEDDED -> Rift.embedded(EmbeddedOptions.builder().upstreamTrust(trust).build()); + }; + } + + private static HttpResponse proxyThroughToMutualTlsOrigin(Rift rift, UnaryOperator client) + throws Exception { + Imposter origin = rift.create(imposter("mtls-origin") + .https(resource("tls/leaf.pem"), resource("tls/leaf-key.pem")) + .requireClientCertificate(resource("tls/ca.pem")) + .stub(onGet("/").willReturn(status(200).withTextBody("up")))); + Imposter proxy = rift.create(imposter("proxy").protocol("http").stub(onRequest().willReturn( + client.apply(proxyTo("https://127.0.0.1:" + origin.port()).proxyTransparent())))); + return HTTP.send(HttpRequest.newBuilder(URI.create(proxy.uri() + "/")).timeout(Duration.ofSeconds(20)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + } + + private static String resource(String name) { + try (InputStream in = Objects.requireNonNull( + ProxyClientCertificateIT.class.getClassLoader().getResourceAsStream(name), name)) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static Path resourcePath(String name) throws URISyntaxException { + URL url = Objects.requireNonNull(ProxyClientCertificateIT.class.getClassLoader().getResource(name), name); + return Path.of(url.toURI()); + } +} diff --git a/rift-java-conformance/src/test/resources/tls/README.md b/rift-java-conformance/src/test/resources/tls/README.md index 58f37df..843c54c 100644 --- a/rift-java-conformance/src/test/resources/tls/README.md +++ b/rift-java-conformance/src/test/resources/tls/README.md @@ -1,6 +1,6 @@ # Test TLS material -Throwaway certificates for the live-engine ITs (`UpstreamTrustIT`, `ImposterClientAuthIT`). They +Throwaway certificates for the live-engine ITs (`UpstreamTrustIT`, `ImposterClientAuthIT`, `ProxyClientCertificateIT`). They secure nothing: every private key is checked in, and every certificate is valid for 100 years so a test never fails on a calendar date. @@ -9,6 +9,8 @@ test never fails on a calendar date. | `ca.pem` | The test CA (`CN=rift-java test CA`). Its key was discarded after signing. | | `leaf.pem`, `leaf-key.pem` | Server certificate for `127.0.0.1`/`localhost`, issued by `ca.pem` | | `client.p12` | Client certificate issued by `ca.pem`, with its key (PKCS#12, password `changeit`) | +| `client.pem`, `client-key.pem` | The same client certificate and its key as PEM (unencrypted PKCS#8), for a `proxy`'s `cert`/`key` | +| `client-key-encrypted.pem` | The same key as encrypted PKCS#8, passphrase `changeit`, for a `proxy`'s `passphrase` | | `untrusted-client.p12` | Client certificate from an unrelated CA, with its key (PKCS#12, password `changeit`) | Regenerate with OpenSSL (P-256 keys): @@ -30,4 +32,10 @@ openssl req -new -key client-key.pem -subj "/CN=rift-java test client" -out clie printf "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n" > client.ext openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 36500 -sha256 -extfile client.ext -out client.pem openssl pkcs12 -export -in client.pem -inkey client-key.pem -name client -passout pass:changeit -out client.p12 + +# PEM forms of client.p12, for a proxy's cert/key/passphrase +openssl pkcs12 -in client.p12 -passin pass:changeit -clcerts -nokeys | openssl x509 -out client.pem +openssl pkcs12 -in client.p12 -passin pass:changeit -nocerts -nodes | openssl pkcs8 -topk8 -nocrypt -out client-key.pem +openssl pkcs8 -topk8 -in client-key.pem -v2 aes-256-cbc -v2prf hmacWithSHA256 -iter 2048 -passout pass:changeit \ + -out client-key-encrypted.pem ``` diff --git a/rift-java-conformance/src/test/resources/tls/client-key-encrypted.pem b/rift-java-conformance/src/test/resources/tls/client-key-encrypted.pem new file mode 100644 index 0000000..d20a488 --- /dev/null +++ b/rift-java-conformance/src/test/resources/tls/client-key-encrypted.pem @@ -0,0 +1,8 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDnvxJymwlXVkM0cRLo +xXbWAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQu/RzcRcYvm3zRQIE +9WZWewSBkPD1lRtc1Ih3IpTteMr7nKbMcZcKmOi+4WF+SHf99GRGw6oAS3L6IabB +A9vrQfO6RdosyVPaJyKh6flD3CgkIEbZ4ogCoSBhnaNwoRCTGADJzlssAI8jI2Un +M0b9j/caF4DjCyBVY1N9kUxFyiX0/jevDRY9vnAkIez5UWZT8I+zIMTLu4xghUPJ +NkwaU8C1LA== +-----END ENCRYPTED PRIVATE KEY----- diff --git a/rift-java-conformance/src/test/resources/tls/client-key.pem b/rift-java-conformance/src/test/resources/tls/client-key.pem new file mode 100644 index 0000000..60ee730 --- /dev/null +++ b/rift-java-conformance/src/test/resources/tls/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgINjyk4O5+zmaPXmH +aUoo0GH3OjqKzDncpED5WCjFZ5ShRANCAAQPEuteUH8ATFXcyGRo/HbdQlfyzo59 +rjGqfNNXfgr9+37exvZsvjjpMAmzDEcnVY6uvFe2C/uOIepPaM/ZwnwG +-----END PRIVATE KEY----- diff --git a/rift-java-conformance/src/test/resources/tls/client.pem b/rift-java-conformance/src/test/resources/tls/client.pem new file mode 100644 index 0000000..c85c9ee --- /dev/null +++ b/rift-java-conformance/src/test/resources/tls/client.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtjCCAVugAwIBAgIUOk04ER0BhdZyBSMVyBmyKGZT76EwCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRcmlmdC1qYXZhIHRlc3QgQ0EwIBcNMjYwOTIyMjM0OTI3WhgP +MjEyNjA4MjkyMzQ5MjdaMCAxHjAcBgNVBAMMFXJpZnQtamF2YSB0ZXN0IGNsaWVu +dDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA8S615QfwBMVdzIZGj8dt1CV/LO +jn2uMap801d+Cv37ft7G9my+OOkwCbMMRydVjq68V7YL+44h6k9oz9nCfAajdTBz +MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUF +BwMCMB0GA1UdDgQWBBQF0UAMgYICW5nrldq8LvGIktA2XTAfBgNVHSMEGDAWgBSI +0QYBoHX0dWdZwJes2Es5E7XyqjAKBggqhkjOPQQDAgNJADBGAiEA+ZNwuQBE5hmM +3bcFOCF2D3uVSWm+QN9NEF2HveDSmm0CIQCIJavewgwYWglP5IjhqXsQZTvC5tyd +z2OjvUDjuSEhMQ== +-----END CERTIFICATE----- diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/RiftImpl.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/RiftImpl.java index 9dcb18b..2e68f16 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/RiftImpl.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/RiftImpl.java @@ -50,6 +50,9 @@ final class RiftImpl implements Rift { /** The first engine release whose intercept serve action accepts a repeated header (rift#936). */ static final String INTERCEPT_MULTI_VALUE_HEADERS_SINCE = "0.18.0"; + /** The first engine release that presents a {@code proxy}'s {@code cert}/{@code key}/{@code passphrase} upstream (rift#1609). */ + static final String PROXY_CLIENT_CERT_SINCE = "0.24.0"; + /** The first engine release that answers conditional GETs from {@code _rift.conditional} (rift#1280). */ static final String CONDITIONAL_SINCE = "0.20.0"; @@ -277,9 +280,22 @@ private static List requirementsOf(ImposterDefinition def) { "ignores it and answers every request 200 without an ETag or Last-Modified", "remove the conditional")); } + if (hasProxyClientCertificate(def)) { + requirements.add(new EngineRequirement(PROXY_CLIENT_CERT_SINCE, + "a client certificate on a proxy (cert / key / passphrase)", "drops them silently, so an" + + " upstream that requires one refuses the handshake", "remove clientCertificate")); + } return requirements; } + private static boolean hasProxyClientCertificate(ImposterDefinition def) { + return def.stubs().stream() + .flatMap(stub -> stub.responses().stream()) + // The wire keys, not the DSL setter, so a hand-built ProxyResponse is held to it too. + .anyMatch(r -> r instanceof Response.Proxy p && (p.proxy().extra().containsKey("cert") + || p.proxy().extra().containsKey("key") || p.proxy().extra().containsKey("passphrase"))); + } + private static boolean hasFaultOrScriptBehaviors(ImposterDefinition def) { return def.stubs().stream() .flatMap(stub -> stub.responses().stream()) diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/dsl/ProxySpec.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/dsl/ProxySpec.java index caaff2d..0fec23d 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/dsl/ProxySpec.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/dsl/ProxySpec.java @@ -2,6 +2,7 @@ import io.github.achirdlabs.rift.json.JsonBool; import io.github.achirdlabs.rift.json.JsonObject; +import io.github.achirdlabs.rift.json.JsonString; import io.github.achirdlabs.rift.json.JsonValue; import io.github.achirdlabs.rift.model.Behavior; import io.github.achirdlabs.rift.model.Behaviors; @@ -13,7 +14,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.regex.Pattern; import java.util.stream.Stream; /** @@ -38,6 +41,7 @@ public final class ProxySpec implements ResponseSpec, BehaviorChain { private final Optional addDecorateBehavior; private final Optional pathRewrite; private final List behaviors; + private final Optional clientCertificate; private ProxySpec( String to, @@ -47,7 +51,8 @@ private ProxySpec( Map injectHeaders, Optional addDecorateBehavior, Optional pathRewrite, - List behaviors) { + List behaviors, + Optional clientCertificate) { this.to = to; this.mode = mode; this.predicateGenerators = predicateGenerators; @@ -56,6 +61,7 @@ private ProxySpec( this.addDecorateBehavior = addDecorateBehavior; this.pathRewrite = pathRewrite; this.behaviors = behaviors; + this.clientCertificate = clientCertificate; } /** @@ -64,7 +70,7 @@ private ProxySpec( * when the proxy had no {@code predicateGenerators}; call {@link #proxyTransparent()} for that. */ static ProxySpec to(String url) { - return new ProxySpec(url, "", List.of(), false, Map.of(), Optional.empty(), Optional.empty(), List.of()); + return new ProxySpec(url, "", List.of(), false, Map.of(), Optional.empty(), Optional.empty(), List.of(), Optional.empty()); } /** @@ -86,13 +92,13 @@ public ProxySpec proxyTransparent() { } private ProxySpec withMode(String newMode) { - return new ProxySpec(to, newMode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite, behaviors); + return new ProxySpec(to, newMode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite, behaviors, clientCertificate); } /** Adds a predicate generator, controlling which parts of a proxied request become a recorded predicate. */ public ProxySpec withPredicateGenerator(JsonValue generator) { List next = Stream.concat(predicateGenerators.stream(), Stream.of(generator)).toList(); - return new ProxySpec(to, mode, next, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite, behaviors); + return new ProxySpec(to, mode, next, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite, behaviors, clientCertificate); } /** Adds a predicate generator, parsing {@code jsonText} as its JSON definition. */ @@ -119,12 +125,12 @@ public ProxySpec generateBy(PredicateGeneratorSpec generator) { public ProxySpec injectHeader(String name, String value) { Map next = new LinkedHashMap<>(injectHeaders); next.put(name, value); - return new ProxySpec(to, mode, predicateGenerators, addWaitBehavior, next, addDecorateBehavior, pathRewrite, behaviors); + return new ProxySpec(to, mode, predicateGenerators, addWaitBehavior, next, addDecorateBehavior, pathRewrite, behaviors, clientCertificate); } /** Rewrites the {@code from} substring of the proxied request's path to {@code to}. */ public ProxySpec rewritePath(String from, String to) { - return new ProxySpec(this.to, mode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, Optional.of(new PathRewrite(from, to)), behaviors); + return new ProxySpec(this.to, mode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, Optional.of(new PathRewrite(from, to)), behaviors, clientCertificate); } /** @@ -133,7 +139,7 @@ public ProxySpec rewritePath(String from, String to) { * records). Not the same as {@link #waitMs}, which delays this proxy's own response. */ public ProxySpec addWaitBehavior() { - return new ProxySpec(to, mode, predicateGenerators, true, injectHeaders, addDecorateBehavior, pathRewrite, behaviors); + return new ProxySpec(to, mode, predicateGenerators, true, injectHeaders, addDecorateBehavior, pathRewrite, behaviors, clientCertificate); } /** @@ -143,21 +149,90 @@ public ProxySpec addWaitBehavior() { * served and before it is recorded (rift ≥ 0.18.0). */ public ProxySpec decorateWith(String script) { - return new ProxySpec(to, mode, predicateGenerators, addWaitBehavior, injectHeaders, Optional.of(script), pathRewrite, behaviors); + return new ProxySpec(to, mode, predicateGenerators, addWaitBehavior, injectHeaders, Optional.of(script), pathRewrite, behaviors, clientCertificate); + } + + /** + * Presents this client certificate to an upstream that asks for one (mutual TLS) — the proxy's + * {@code cert} and {@code key}, as Mountebank's proxy takes them. Both PEM; the key unencrypted + * PKCS#8, PKCS#1 or SEC1. Needs rift ≥ 0.24.0 (rift#1609): an older engine drops both keys + * without a word, so creating the imposter there fails with + * {@link io.github.achirdlabs.rift.error.InvalidDefinition} when the engine's version can be + * checked. A key that does not match the certificate is refused by the engine with {@code 400}. + * + *

Which upstream certificates the proxy trusts is separate: {@code SpawnOptions.upstreamTrust} + * / {@code EmbeddedOptions.upstreamTrust}. As in Mountebank, the engine shows {@code cert}, + * {@code key} and {@code passphrase} in {@code GET /imposters} and its {@code --datadir} files, + * so protect the admin API; it never logs them. A later call replaces an earlier one. + * + * @throws IllegalArgumentException if {@code certPem} holds no {@code -----BEGIN CERTIFICATE-----} + * block or {@code keyPem} no {@code -----BEGIN ... PRIVATE KEY-----} + * block + */ + public ProxySpec clientCertificate(String certPem, String keyPem) { + return withClientCertificate(new ClientCertificate(certPem, keyPem, Optional.empty())); + } + + /** + * {@link #clientCertificate(String, String)} for a key in encrypted PKCS#8, decrypted with + * {@code passphrase} (the proxy's {@code passphrase}). + * + * @throws IllegalArgumentException as {@link #clientCertificate(String, String)}, or if + * {@code passphrase} is empty + */ + public ProxySpec clientCertificate(String certPem, String keyPem, String passphrase) { + Objects.requireNonNull(passphrase, "passphrase"); + if (passphrase.isEmpty()) { + throw new IllegalArgumentException( + "passphrase must not be empty; use clientCertificate(certPem, keyPem) for an unencrypted key"); + } + return withClientCertificate(new ClientCertificate(certPem, keyPem, Optional.of(passphrase))); + } + + private ProxySpec withClientCertificate(ClientCertificate certificate) { + return new ProxySpec(to, mode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite, behaviors, Optional.of(certificate)); } @Override public ProxySpec withBehavior(Behavior behavior) { List next = Stream.concat(behaviors.stream(), Stream.of(behavior)).toList(); - return new ProxySpec(to, mode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite, next); + return new ProxySpec(to, mode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite, next, clientCertificate); } /** Builds the immutable {@link Response.Proxy} this spec represents. */ @Override public Response build() { + ProxyResponse proxy = new ProxyResponse(to, mode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite); + if (clientCertificate.isPresent()) { + ClientCertificate c = clientCertificate.get(); + proxy = proxy.withExtra("cert", new JsonString(c.certPem())).withExtra("key", new JsonString(c.keyPem())); + if (c.passphrase().isPresent()) { + proxy = proxy.withExtra("passphrase", new JsonString(c.passphrase().get())); + } + } return new Response.Proxy( - new ProxyResponse(to, mode, predicateGenerators, addWaitBehavior, injectHeaders, addDecorateBehavior, pathRewrite), + proxy, new Behaviors(behaviors), Map.of()); } + + /** The proxy's {@code cert}/{@code key}/{@code passphrase}; a pair by construction, as the engine requires. */ + private record ClientCertificate(String certPem, String keyPem, Optional passphrase) { + ClientCertificate { + Objects.requireNonNull(certPem, "certPem"); + Objects.requireNonNull(keyPem, "keyPem"); + if (!certPem.contains("-----BEGIN CERTIFICATE-----")) { + throw new IllegalArgumentException( + "client certificate PEM contains no certificate: expected a -----BEGIN CERTIFICATE----- block"); + } + if (!PRIVATE_KEY_BLOCK.matcher(keyPem).find()) { + throw new IllegalArgumentException( + "client key PEM contains no private key: expected a -----BEGIN ... PRIVATE KEY----- block"); + } + } + + /** PKCS#8 ({@code PRIVATE KEY}, {@code ENCRYPTED PRIVATE KEY}), PKCS#1 ({@code RSA}) and SEC1 ({@code EC}). */ + private static final Pattern PRIVATE_KEY_BLOCK = + Pattern.compile("-----BEGIN [A-Z ]*PRIVATE KEY-----"); + } } diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/ProxyClientCertificateGateTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/ProxyClientCertificateGateTest.java new file mode 100644 index 0000000..c814fab --- /dev/null +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/ProxyClientCertificateGateTest.java @@ -0,0 +1,98 @@ +package io.github.achirdlabs.rift; + +import io.github.achirdlabs.rift.dsl.ImposterSpec; +import io.github.achirdlabs.rift.error.InvalidDefinition; +import io.github.achirdlabs.rift.json.JsonValue; +import io.github.achirdlabs.rift.model.ImposterDefinition; +import io.github.achirdlabs.rift.transport.RiftTransport; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.github.achirdlabs.rift.dsl.RiftDsl.imposter; +import static io.github.achirdlabs.rift.dsl.RiftDsl.onRequest; +import static io.github.achirdlabs.rift.dsl.RiftDsl.proxyTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A proxy's {@code cert}/{@code key}/{@code passphrase} need rift 0.24.0. An older engine drops all + * three without a word, so the upstream's mTLS handshake fails at request time with nothing pointing + * at the cause; a definition carrying them is refused there rather than sent (#342). + */ +class ProxyClientCertificateGateTest { + + private static final String CERT = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"; + private static final String KEY = "-----BEGIN PRIVATE KEY-----\nMIGH\n-----END PRIVATE KEY-----\n"; + private static final ImposterSpec MTLS_PROXY = imposter("p") + .stub(onRequest().willReturn(proxyTo("https://up").clientCertificate(CERT, KEY))); + + private final List posted = new ArrayList<>(); + private final AtomicInteger buildInfoCalls = new AtomicInteger(); + + @Test + void anOlderEngineIsRefusedBeforeAnythingIsSent() { + InvalidDefinition e = assertThrows(InvalidDefinition.class, () -> rift(VersionCheck.FAIL, "0.23.0").create(MTLS_PROXY)); + assertTrue(e.getMessage().contains("0.24.0"), e.getMessage()); + assertTrue(e.getMessage().contains("client certificate on a proxy"), e.getMessage()); + assertTrue(e.getMessage().contains("drops them silently"), "says what the old engine would do: " + e.getMessage()); + assertTrue(posted.isEmpty()); + } + + @Test + void aRawProxyResponseCarryingTheKeysIsCheckedToo() { + // Raw JSON, without the DSL setter: the gate reads the wire keys, so this path is covered as well. + ImposterDefinition raw = ImposterDefinition.fromJson("{\"protocol\":\"http\",\"stubs\":[{\"responses\":" + + "[{\"proxy\":{\"to\":\"https://up\",\"key\":\"k\"}}]}]}"); + assertThrows(InvalidDefinition.class, () -> rift(VersionCheck.FAIL, "0.23.0").create(raw)); + assertTrue(posted.isEmpty()); + } + + @Test + void replaceAllIsCheckedToo() { + assertThrows(InvalidDefinition.class, + () -> rift(VersionCheck.FAIL, "0.23.0").replaceAll(List.of(MTLS_PROXY.build()))); + assertTrue(posted.isEmpty()); + } + + @Test + void aCurrentEngineAccepts() { + rift(VersionCheck.FAIL, "0.24.0").create(MTLS_PROXY); + assertEquals(1, posted.size()); + } + + @Test + void aProxyWithoutACertificateNeverAsksTheEngine() { + rift(VersionCheck.FAIL, "0.23.0").create(imposter("plain").stub(onRequest().willReturn(proxyTo("https://up")))); + assertEquals(1, posted.size()); + assertEquals(0, buildInfoCalls.get()); + } + + private Rift rift(VersionCheck mode, String version) { + RiftTransport transport = (RiftTransport) Proxy.newProxyInstance( + RiftTransport.class.getClassLoader(), new Class[] {RiftTransport.class}, + (proxy, method, args) -> switch (method.getName()) { + case "buildInfo" -> { + buildInfoCalls.incrementAndGet(); + yield JsonValue.parse("{\"version\": \"" + version + "\"}"); + } + case "createImposter" -> { + posted.add((JsonValue) args[0]); + yield JsonValue.parse("{\"port\": 4545}"); + } + case "replaceAllImposters" -> { + posted.add((JsonValue) args[0]); + yield null; + } + case "close" -> null; + default -> throw new UnsupportedOperationException(method.getName()); + }); + ConnectOptions options = ConnectOptions.builder(URI.create("http://127.0.0.1:2525")).versionCheck(mode).build(); + return RiftImpl.spawned(transport, options, () -> { }); + } +} diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/dsl/ProxyClientCertificateTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/dsl/ProxyClientCertificateTest.java new file mode 100644 index 0000000..19a8125 --- /dev/null +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/dsl/ProxyClientCertificateTest.java @@ -0,0 +1,96 @@ +package io.github.achirdlabs.rift.dsl; + +import io.github.achirdlabs.rift.json.JsonString; +import io.github.achirdlabs.rift.json.JsonValue; +import io.github.achirdlabs.rift.model.ProxyResponse; +import io.github.achirdlabs.rift.model.Response; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static io.github.achirdlabs.rift.dsl.RiftDsl.proxyTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** A proxy's client certificate for an mTLS upstream (#342, rift#1609): {@code cert}, {@code key}, {@code passphrase}. */ +class ProxyClientCertificateTest { + + private static final String CERT = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"; + private static final String KEY = "-----BEGIN PRIVATE KEY-----\nMIGH\n-----END PRIVATE KEY-----\n"; + private static final String ENCRYPTED_KEY = "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIHs\n-----END ENCRYPTED PRIVATE KEY-----\n"; + + private static ProxyResponse proxyOf(ProxySpec spec) { + return ((Response.Proxy) spec.build()).proxy(); + } + + @Test + void certAndKeyGoOutAsTheProxysCertAndKey() { + ProxyResponse proxy = proxyOf(proxyTo("https://up").clientCertificate(CERT, KEY)); + assertEquals(Map.of("cert", new JsonString(CERT), "key", new JsonString(KEY)), proxy.extra()); + assertEquals(List.of("cert", "key"), List.copyOf(proxy.extra().keySet())); + } + + @Test + void aPassphraseFollowsTheKey() { + ProxyResponse proxy = proxyOf(proxyTo("https://up").clientCertificate(CERT, ENCRYPTED_KEY, "changeit")); + assertEquals(List.of("cert", "key", "passphrase"), List.copyOf(proxy.extra().keySet())); + assertEquals(new JsonString("changeit"), proxy.extra().get("passphrase")); + } + + @Test + void theWireFormCarriesTheThreeKeysAfterTheModeledOnes() { + String json = proxyTo("https://up").clientCertificate("-----BEGIN CERTIFICATE-----c", "-----BEGIN EC PRIVATE KEY-----k", "pw") + .build().toJsonValue().toJson(); + assertEquals(JsonValue.parse("{\"proxy\":{\"to\":\"https://up\",\"mode\":\"\",\"predicateGenerators\":[]," + + "\"cert\":\"-----BEGIN CERTIFICATE-----c\",\"key\":\"-----BEGIN EC PRIVATE KEY-----k\"," + + "\"passphrase\":\"pw\"}}"), + JsonValue.parse(json)); + } + + @Test + void aLaterCallReplacesAnEarlierOneIncludingItsPassphrase() { + ProxyResponse proxy = proxyOf(proxyTo("https://up") + .clientCertificate(CERT, ENCRYPTED_KEY, "changeit") + .clientCertificate(CERT, KEY)); + assertEquals(Map.of("cert", new JsonString(CERT), "key", new JsonString(KEY)), proxy.extra()); + } + + @Test + void theOtherChainersKeepTheCertificate() { + ProxyResponse proxy = proxyOf(proxyTo("https://up").clientCertificate(CERT, KEY).proxyAlways().injectHeader("X", "1")); + assertEquals(new JsonString(CERT), proxy.extra().get("cert")); + assertEquals("proxyAlways", proxy.mode()); + } + + @Test + void aCertificateWithoutACertificateBlockIsRefused() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> proxyTo("https://up").clientCertificate(KEY, KEY)); + assertEquals("client certificate PEM contains no certificate: expected a -----BEGIN CERTIFICATE----- block", + e.getMessage()); + } + + @Test + void aKeyWithoutAPrivateKeyBlockIsRefused() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> proxyTo("https://up").clientCertificate(CERT, CERT)); + assertEquals("client key PEM contains no private key: expected a -----BEGIN ... PRIVATE KEY----- block", + e.getMessage()); + } + + @Test + void anEmptyPassphraseIsRefused() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> proxyTo("https://up").clientCertificate(CERT, ENCRYPTED_KEY, "")); + assertEquals("passphrase must not be empty; use clientCertificate(certPem, keyPem) for an unencrypted key", + e.getMessage()); + } + + @Test + void nullsAreRefused() { + assertThrows(NullPointerException.class, () -> proxyTo("https://up").clientCertificate(null, KEY)); + assertThrows(NullPointerException.class, () -> proxyTo("https://up").clientCertificate(CERT, null)); + assertThrows(NullPointerException.class, () -> proxyTo("https://up").clientCertificate(CERT, KEY, null)); + } +}