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)); + } +}