Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/design/sdk-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DynamicTest> withoutACertificateTheUpstreamRefusesTheProxy() {
return gated("no clientCertificate: the mTLS upstream refuses the handshake", () -> {
try (Rift rift = engine()) {
assertNotEquals(200, proxyThroughToMutualTlsOrigin(rift, p -> p).statusCode());
}
});
}

@TestFactory
Stream<DynamicTest> theCertificateAndKeyAreAccepted() {
return gated("clientCertificate(cert, key): the mTLS upstream is reached", () -> {
try (Rift rift = engine()) {
HttpResponse<String> 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<DynamicTest> anEncryptedKeyIsDecryptedWithThePassphrase() {
return gated("clientCertificate(cert, encrypted key, passphrase): the mTLS upstream is reached", () -> {
try (Rift rift = engine()) {
HttpResponse<String> 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<DynamicTest> 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<String> proxyThroughToMutualTlsOrigin(Rift rift, UnaryOperator<ProxySpec> 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());
}
}
10 changes: 9 additions & 1 deletion rift-java-conformance/src/test/resources/tls/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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):
Expand All @@ -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
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDnvxJymwlXVkM0cRLo
xXbWAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQu/RzcRcYvm3zRQIE
9WZWewSBkPD1lRtc1Ih3IpTteMr7nKbMcZcKmOi+4WF+SHf99GRGw6oAS3L6IabB
A9vrQfO6RdosyVPaJyKh6flD3CgkIEbZ4ogCoSBhnaNwoRCTGADJzlssAI8jI2Un
M0b9j/caF4DjCyBVY1N9kUxFyiX0/jevDRY9vnAkIez5UWZT8I+zIMTLu4xghUPJ
NkwaU8C1LA==
-----END ENCRYPTED PRIVATE KEY-----
5 changes: 5 additions & 0 deletions rift-java-conformance/src/test/resources/tls/client-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgINjyk4O5+zmaPXmH
aUoo0GH3OjqKzDncpED5WCjFZ5ShRANCAAQPEuteUH8ATFXcyGRo/HbdQlfyzo59
rjGqfNNXfgr9+37exvZsvjjpMAmzDEcnVY6uvFe2C/uOIepPaM/ZwnwG
-----END PRIVATE KEY-----
12 changes: 12 additions & 0 deletions rift-java-conformance/src/test/resources/tls/client.pem
Original file line number Diff line number Diff line change
@@ -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-----
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -277,9 +280,22 @@ private static List<EngineRequirement> 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())
Expand Down
Loading
Loading