diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/PartnerApi.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/PartnerApi.java new file mode 100644 index 00000000000..4d65507d684 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/PartnerApi.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend; + +import com.codename1.backend.Base64; +import com.codename1.backend.ClientCertificate; +import com.codename1.backend.Web; +import com.codename1.backend.WebClient; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Value; +import java.io.IOException; + +/** + * Calls a partner API that authenticates its clients with a certificate. + * Referenced from the backend chapter's section on calling other services. + */ +@Component +public class PartnerApi { + // tag::backend-web-client[] + private final WebClient partner; + + /** The PKCS#12 archive and its password, from configuration. */ + public PartnerApi(@Value("${partner.p12}") String p12Base64, + @Value("${partner.p12Password}") String password) { + byte[] archive = Base64.decode(p12Base64); + this.partner = new WebClient() + .setClientCertificate(ClientCertificate.pkcs12(archive, password)) + .setRequireHttp2(true) + .setRequestTimeoutMillis(10000); + } + + public String balance(String account) throws IOException { + Web.Result result = partner.getJson( + "https://api.partner.example/v1/accounts/" + account, null); + if (!result.isSuccess()) { + throw new IOException("The partner API answered " + result.getStatus()); + } + return result.getBodyAsString(); + } + // end::backend-web-client[] +} diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 31d547e79f2..890b84e76eb 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -727,6 +727,48 @@ Sections 12 and 13 are excluded while permessage-deflate is unimplemented. They' excluded rather than tolerated: accepting their result as a pass would also accept it for a case that used to work. +[[backend-web-client]] +=== Calling other services + +`Web` makes an outbound HTTP or HTTPS request: `Web.get`, `Web.getJson`, +`Web.postJson` and `Web.request`. The server's certificate and its host name are +always verified, and there is no option to turn that off. A `WebClient` is the +same request with settings of its own: + +* a client certificate, for a service that authenticates its callers with mutual + TLS. `ClientCertificate.pem` takes a PEM certificate and key, and + `ClientCertificate.pkcs12` takes a `.p12` archive and its password. +* `setRequireHttp2(true)`, which makes the request over HTTP/2 or fails it. A + server that only speaks HTTP/1.1 never receives the request. +* a connect timeout, a read timeout (how long the transfer may stall) and a + request timeout (the whole request). +* a pool of connections, so the next request to the same server skips the TCP + and TLS handshakes. `setReuseConnections(false)` opens a new connection for + each request. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/PartnerApi.java[tag=backend-web-client,indent=0] +---- + +Create a client once and share it between requests. Its settings are fixed by its +first request, and two clients never share a connection, so a connection +authenticated with one certificate is never used for a request made with another. +`close()` releases the pool. + +`Web.Result.getProtocol()` names the protocol the response used. Without the +requirement, the packaged server offers HTTP/2 and uses HTTP/1.1 when the other +side declines. + +The local Java SE runtime speaks HTTP/1.1 only, so there a client that requires +HTTP/2 fails every request with an exception that says so. It reads PKCS#12 +archives, and PEM keys in unencrypted PKCS#8 or RSA form. The packaged server also +reads encrypted PEM keys. A PKCS#12 archive works the same in both. + +A static binary in a `scratch` image has no certificate store. Copy a +`ca-certificates.crt` into the image and name it in `CURL_CA_BUNDLE` or +`SSL_CERT_FILE`. + [[backend-push]] === Sending push notifications diff --git a/vm/backend/demo/mtlscheck/com/demo/MtlsCheck.java b/vm/backend/demo/mtlscheck/com/demo/MtlsCheck.java new file mode 100644 index 00000000000..7b7194ea387 --- /dev/null +++ b/vm/backend/demo/mtlscheck/com/demo/MtlsCheck.java @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.demo; + +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import com.codename1.backend.Backend; +import com.codename1.backend.ClientCertificate; +import com.codename1.backend.Config; +import com.codename1.backend.HttpServer; +import com.codename1.backend.Tasks; +import com.codename1.backend.VirtualThread; +import com.codename1.backend.Web; +import com.codename1.backend.WebClient; +import java.util.Properties; + +/** + * Drives WebClient's mutual TLS, HTTP/2 requirement, timeouts and connection + * reuse against the local fixture servers of tests/web-mtls-h2.sh, on whichever + * runtime it was built for, and reports what came back. + * + * The servers answer "cn=NAME conn=PORT proto=VERSION": the client certificate's + * common name as the SERVER verified it, the client's source port (a reused + * connection keeps it) and the protocol the server spoke. So every check reads + * what arrived, not what the client believes it sent. + * + * Environment: CN1_MTLS_H2 (https base of the HTTP/2 server), CN1_MTLS_H1 (the + * HTTP/1.1-only one), CN1_MTLS_DIR (the credentials), CN1_MTLS_ARM ("native" or + * "javase", which decides what an HTTP/2 requirement must do). + */ +public class MtlsCheck { + private static int passed; + private static final List failures = new ArrayList(); + private static String dir; + + public static void main(String[] args) throws Exception { + String h2 = System.getenv("CN1_MTLS_H2"); + String h1 = System.getenv("CN1_MTLS_H1"); + dir = System.getenv("CN1_MTLS_DIR"); + boolean nativeArm = !"javase".equals(System.getenv("CN1_MTLS_ARM")); + if (h2 == null || h1 == null || dir == null) { + System.out.println("CN1_MTLS_H2, CN1_MTLS_H1 and CN1_MTLS_DIR must be set"); + System.out.println("MTLSCHECK FAILED"); + System.exit(1); + } + ClientCertificate alice = ClientCertificate.pem(read("alice.crt"), read("alice.key"), null); + ClientCertificate bob = ClientCertificate.pem(read("bob.crt"), read("bob.key"), null); + + // --- mutual TLS, HTTP/1.1 --- + WebClient a1 = new WebClient().setClientCertificate(alice); + check("PEM credential is accepted", "cn=alice", field(body(a1, h1 + "/who"), "cn")); + check("HTTP/1.1 server reports HTTP/1.1", "HTTP/1.1", protocol(a1, h1 + "/who")); + WebClient p12 = new WebClient().setClientCertificate( + ClientCertificate.pkcs12(read("alice.p12"), "p12-secret")); + check("PKCS#12 credential is accepted", "cn=alice", field(body(p12, h1 + "/who"), "cn")); + // An encrypted PEM key: OpenSSL reads it on the packaged arm; the Java SE + // arm refuses it with the conversion to make. + WebClient encrypted = new WebClient().setClientCertificate( + ClientCertificate.pem(read("alice.crt"), read("alice-enc.key"), "pem-secret")); + String enc = body(encrypted, h1 + "/who"); + if (nativeArm) { + check("an encrypted PEM key with its passphrase is accepted", "cn=alice", field(enc, "cn")); + } else { + check("an encrypted PEM key names the conversion", "true", + String.valueOf(enc.indexOf("openssl pkcs8") >= 0)); + } + + check("no credential is rejected", "failed", outcome(new WebClient(), h1 + "/who")); + check("a key that is not the certificate's is rejected", "failed", + outcome(new WebClient().setClientCertificate( + ClientCertificate.pem(read("alice.crt"), read("bob.key"), null)), h1 + "/who")); + check("an expired certificate is rejected", "failed", + outcome(new WebClient().setClientCertificate( + ClientCertificate.pem(read("expired.crt"), read("expired.key"), null)), h1 + "/who")); + check("a certificate from an untrusted CA is rejected", "failed", + outcome(new WebClient().setClientCertificate( + ClientCertificate.pem(read("rogue.crt"), read("rogue.key"), null)), h1 + "/who")); + check("a wrong PKCS#12 password is rejected", "failed", + outcome(new WebClient().setClientCertificate( + ClientCertificate.pkcs12(read("alice.p12"), "not-the-password")), h1 + "/who")); + check("the plain Web API presents no credential", "failed", outcome(null, h1 + "/who")); + + // --- verification of the SERVER stays on --- + // The server certificate names localhost only, so 127.0.0.1 -- the same server -- must fail verification. + check("a server certificate for another name is rejected", "failed", + outcome(new WebClient().setClientCertificate(alice), swapHost(h1, "127.0.0.1") + "/who")); + + // --- two identities never share a connection --- + WebClient b1 = new WebClient().setClientCertificate(bob); + String isolation = "ok"; + for (int iter = 0 ; iter < 4 ; iter++) { + String seenA = field(body(a1, h1 + "/who"), "cn"); + String seenB = field(body(b1, h1 + "/who"), "cn"); + if (!"cn=alice".equals(seenA) || !"cn=bob".equals(seenB)) { + isolation = "round " + iter + ": " + seenA + " / " + seenB; + } + } + check("interleaved clients keep their own identity", "ok", isolation); + + // --- connection reuse --- + WebClient reuse = new WebClient().setClientCertificate(alice); + String first = field(body(reuse, h1 + "/who"), "conn"); + String second = field(body(reuse, h1 + "/who"), "conn"); + check("a client reuses its connection", "true", String.valueOf(first != null && first.equals(second))); + WebClient fresh = new WebClient().setClientCertificate(alice).setReuseConnections(false); + first = field(body(fresh, h1 + "/who"), "conn"); + second = field(body(fresh, h1 + "/who"), "conn"); + check("setReuseConnections(false) opens a new connection", "true", + String.valueOf(first != null && second != null && !first.equals(second))); + check("a client's configuration is fixed by its first request", "IllegalStateException", + setterAfterUse(reuse)); + reuse.close(); + check("a closed client refuses requests", "failed", outcome(reuse, h1 + "/who")); + + // --- timeouts --- + WebClient quick = new WebClient().setClientCertificate(alice).setRequestTimeoutMillis(1000); + long started = System.currentTimeMillis(); + String slow = outcome(quick, h1 + "/slow"); + long took = System.currentTimeMillis() - started; + check("the request timeout ends a slow request", "failed", slow); + check("the request timeout ends it promptly", "true", String.valueOf(took < 2500)); + + // --- HTTP/2 --- + WebClient strict = new WebClient().setClientCertificate(alice).setRequireHttp2(true); + WebClient preferred = new WebClient().setClientCertificate(alice); + int before = count(a1, h1); + String refused = body(strict, h1 + "/who"); + // What makes the refusal safe: the HTTP/1.1 peer never saw the request. + check("the refused request never reached the HTTP/1.1 peer", String.valueOf(before), + String.valueOf(count(a1, h1))); + if (nativeArm) { + check("required HTTP/2 succeeds over HTTP/2", "proto=HTTP/2", + field(body(strict, h2 + "/who"), "proto")); + check("required HTTP/2 reports HTTP/2", "HTTP/2", protocol(strict, h2 + "/who")); + check("required HTTP/2 presents the credential", "cn=alice", + field(body(strict, h2 + "/who"), "cn")); + check("preferred HTTP/2 is negotiated", "HTTP/2", protocol(preferred, h2 + "/who")); + check("an HTTP/1.1-only peer is refused when HTTP/2 is required", "true", + String.valueOf(refused.indexOf("HTTP/2 is required") >= 0)); + } else { + check("required HTTP/2 is refused by the Java SE runtime", "true", + String.valueOf(refused.indexOf("cannot make an HTTP/2") >= 0)); + check("preferred HTTP/2 falls back to HTTP/1.1 on Java SE", "HTTP/1.1", + protocol(preferred, h1 + "/who")); + } + + onVirtualThreads(h1, h2, alice, bob, nativeArm); + + System.out.println("passed=" + passed + " failed=" + failures.size()); + for (int iter = 0 ; iter < failures.size() ; iter++) { + System.out.println("FAIL " + failures.get(iter)); + } + System.out.println(failures.isEmpty() ? "MTLSCHECK OK" : "MTLSCHECK FAILED"); + if (!failures.isEmpty()) { + System.exit(1); + } + } + + /** + * The same checks from inside virtual threads, which is where a request + * handler makes them: on the packaged arm a WebClient request there PARKS + * instead of blocking, and its pooled connections are driven through the + * parked wait rather than poll(). Everything above ran on the main thread, + * which cannot park. Two tasks on ONE host, so alice's and bob's requests + * interleave on the same pthread while each holds its own pool. + */ + private static void onVirtualThreads(final String h1, final String h2, + ClientCertificate alice, ClientCertificate bob, + final boolean nativeArm) throws Exception { + Backend backend = Backend.builder(Config.of(new Properties(), "test")) + .quiet().host("127.0.0.1").port(0).workers(1) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return HttpServer.Response.text(200, "ok"); + } + }).start(); + try { + final WebClient a = new WebClient().setClientCertificate(alice); + final WebClient b = new WebClient().setClientCertificate(bob) + .setRequireHttp2(nativeArm); + final Results results = new Results(); + Tasks.virtual(new Runnable() { + public void run() { + StringBuilder out = new StringBuilder(); + String conn = null; + boolean sameConn = true; + for (int iter = 0 ; iter < 4 ; iter++) { + String seen = body(a, h1 + "/who"); + out.append(field(seen, "cn")).append(' '); + String c = field(seen, "conn"); + if (conn != null && !conn.equals(c)) { + sameConn = false; + } + conn = c; + } + results.put("alice", out.toString().trim() + (sameConn ? " reused" : " new") + + (nativeArm && !VirtualThread.isVirtual() ? " NOT-VIRTUAL" : "")); + } + }); + Tasks.virtual(new Runnable() { + public void run() { + StringBuilder out = new StringBuilder(); + for (int iter = 0 ; iter < 4 ; iter++) { + String seen = body(b, (nativeArm ? h2 : h1) + "/who"); + out.append(field(seen, "cn")).append(' '); + if (nativeArm) { + out.append(field(seen, "proto")).append(' '); + } + } + results.put("bob", out.toString().trim()); + } + }); + results.await(2, 30000); + check("virtual thread: alice keeps identity and connection", + "cn=alice cn=alice cn=alice cn=alice reused", results.get("alice")); + check("virtual thread: bob keeps identity" + (nativeArm ? " over HTTP/2" : ""), + nativeArm ? "cn=bob proto=HTTP/2 cn=bob proto=HTTP/2 cn=bob proto=HTTP/2 cn=bob proto=HTTP/2" + : "cn=bob cn=bob cn=bob cn=bob", results.get("bob")); + a.close(); + b.close(); + } finally { + backend.stop(); + } + } + + private static final class Results { + private final java.util.Map values = new java.util.HashMap(); + + synchronized void put(String key, String value) { + values.put(key, value); + } + + synchronized String get(String key) { + Object value = values.get(key); + return value == null ? "no result" : (String) value; + } + + void await(int count, long millis) throws InterruptedException { + long deadline = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < deadline) { + synchronized (this) { + if (values.size() >= count) { + return; + } + } + Thread.sleep(10); + } + } + } + + private static byte[] read(String name) throws Exception { + InputStream in = new FileInputStream(dir + "/" + name); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int n; + while ((n = in.read(buffer)) > 0) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } finally { + in.close(); + } + } + + /** The body on a 200, otherwise the status or the exception message. */ + private static String body(WebClient client, String url) { + try { + Web.Result r = client == null ? Web.get(url) : client.get(url); + if (r.getStatus() != 200) { + return "status " + r.getStatus(); + } + return r.getBodyAsString(); + } catch (Exception err) { + return "threw " + err.getMessage(); + } + } + + private static String protocol(WebClient client, String url) { + try { + return client.get(url).getProtocol(); + } catch (Exception err) { + return "threw " + err.getMessage(); + } + } + + /** "ok" for a 200, "failed" for anything that did not authenticate. */ + private static String outcome(WebClient client, String url) { + String seen = body(client, url); + return seen.startsWith("cn=") ? "ok: " + seen : "failed"; + } + + /** One "name=value" field of the fixture's answer, or the whole answer. */ + private static String field(String answer, String name) { + int at = answer.indexOf(name + "="); + if (at < 0) { + return answer; + } + int end = answer.indexOf(' ', at); + return answer.substring(at, end < 0 ? answer.length() : end).trim(); + } + + /** How many requests the HTTP/1.1 server has handled, /count itself excluded. */ + private static int count(WebClient client, String h1) { + String seen = field(body(client, h1 + "/count"), "count"); + try { + return Integer.parseInt(seen.substring("count=".length())); + } catch (Exception err) { + return -1000; + } + } + + private static String setterAfterUse(WebClient client) { + try { + client.setRequestTimeoutMillis(5); + return "accepted"; + } catch (IllegalStateException expected) { + return "IllegalStateException"; + } + } + + private static String swapHost(String base, String host) { + int start = base.indexOf("://") + 3; + int colon = base.indexOf(':', start); + return base.substring(0, start) + host + base.substring(colon); + } + + private static void check(String name, String expected, String actual) { + if (expected.equals(actual)) { + passed++; + } else { + failures.add(name + ": expected <" + expected + "> but was <" + actual + ">"); + } + } +} diff --git a/vm/backend/docker/Containerfile.musl b/vm/backend/docker/Containerfile.musl index 63e54eb5a2b..28126aa22fe 100644 --- a/vm/backend/docker/Containerfile.musl +++ b/vm/backend/docker/Containerfile.musl @@ -7,7 +7,12 @@ # Alpine's prebuilt libcurl.a is compiled with brotli, libpsl, nghttp2 and c-ares, # whose static archives are LTO objects lld cannot resolve. Rather than chase # those, curl is built here with only what a server-side HTTP client needs: -# OpenSSL for TLS, zlib for content encoding, nothing else. Verification stays +# OpenSSL for TLS, zlib for content encoding, and nghttp2 for HTTP/2 -- the one +# of the four whose static archive links (link.sh always named -lnghttp2). +# Without it the static binary spoke HTTP/1.1 only while the glibc one, on +# Debian's libcurl, negotiated HTTP/2: the same program behaved differently by +# libc, and a WebClient that requires HTTP/2 could not work on musl at all. +# tests/web-mtls-h2.sh runs both and holds them to the same answers. Verification stays # curl's, which is the reason for using curl instead of hand-rolled TLS. # # Only lib/ and include/ are built: the curl COMMAND needs perl to generate its @@ -19,14 +24,14 @@ FROM alpine:3.20 ARG CURL_VER=8.7.1 RUN apk add --no-cache clang lld musl-dev openssl-dev openssl-libs-static \ - zlib-dev zlib-static nghttp2-dev nghttp2-static curl tar make + zlib-dev zlib-static nghttp2-dev nghttp2-static pkgconf curl tar make RUN cd /tmp \ && curl -fsSL "https://curl.se/download/curl-${CURL_VER}.tar.gz" -o curl.tar.gz \ && tar xzf curl.tar.gz \ && cd "curl-${CURL_VER}" \ - && ./configure --disable-shared --enable-static --with-openssl \ - --without-brotli --without-libpsl --without-nghttp2 --without-libidn2 \ + && ./configure --disable-shared --enable-static --with-openssl --with-nghttp2 \ + --without-brotli --without-libpsl --without-libidn2 \ --without-zstd --disable-ares --disable-ldap --disable-ldaps \ --disable-rtsp --disable-dict --disable-telnet --disable-tftp \ --disable-pop3 --disable-imap --disable-smtp --disable-gopher \ diff --git a/vm/backend/impl/javase/com/codename1/backend/ClientCredentials.java b/vm/backend/impl/javase/com/codename1/backend/ClientCredentials.java new file mode 100644 index 00000000000..fed8669ca18 --- /dev/null +++ b/vm/backend/impl/javase/com/codename1/backend/ClientCredentials.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.List; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; + +/// Turns a [ClientCertificate] into the SSLSocketFactory HttpURLConnection +/// presents it through. Java SE only: the packaged arm hands the same bytes to +/// libcurl. +/// +/// Trust is the JVM's default trust store, exactly as for a request without a +/// certificate -- the TrustManager argument is null -- so presenting a +/// credential changes what this side proves, never what it checks. +/// +/// No message here quotes a password or a byte of key material; a failure says +/// which step failed and what to do about it. +final class ClientCredentials { + private static final SecureRandom RANDOM = new SecureRandom(); + + private ClientCredentials() { + } + + static SSLSocketFactory socketFactory(ClientCertificate credential) throws IOException { + try { + KeyStore store; + char[] password; + if (credential.getType() == ClientCertificate.PKCS12) { + password = credential.getPassword() == null ? new char[0] + : credential.getPassword().toCharArray(); + store = KeyStore.getInstance("PKCS12"); + try { + store.load(new ByteArrayInputStream(credential.getCertificate()), password); + } catch (IOException err) { + // What KeyStore says for a wrong password AND for a damaged + // archive; it cannot tell them apart either. + throw new IOException("The PKCS#12 client certificate could not be " + + "read: the password is wrong or the archive is damaged", err); + } + // An archive with only certificates in it loads, and an empty + // key manager initialises from it, so the request would go out + // presenting nothing -- and succeed anonymously against a server + // that asks for a certificate without requiring one. + boolean hasKey = false; + for (java.util.Enumeration aliases = store.aliases(); aliases.hasMoreElements();) { + if (store.isKeyEntry((String) aliases.nextElement())) { + hasKey = true; + break; + } + } + if (!hasKey) { + throw new IOException("The PKCS#12 client certificate contains no " + + "private key"); + } + } else { + Certificate[] chain = certificates(credential.getCertificate()); + PrivateKey key = privateKey(credential.getKey(), credential.getPassword()); + // A throwaway in-memory store, protected by a random password + // that exists only for the KeyManagerFactory below. + password = randomPassword(); + store = KeyStore.getInstance("PKCS12"); + store.load(null, null); + store.setKeyEntry("client", key, password, chain); + } + KeyManagerFactory keys = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm()); + keys.init(store, password); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(keys.getKeyManagers(), null, null); + return context.getSocketFactory(); + } catch (GeneralSecurityException err) { + throw new IOException("The client certificate could not be used: " + + err.getClass().getSimpleName(), err); + } + } + + /// Default trust and no key, in a context of its own; see Web.TRANSPORTS. + static SSLSocketFactory defaultSocketFactory() throws IOException { + try { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, null, null); + return context.getSocketFactory(); + } catch (GeneralSecurityException err) { + throw new IOException("TLS is not available: " + err.getClass().getSimpleName(), err); + } + } + + private static Certificate[] certificates(byte[] pem) throws IOException, + GeneralSecurityException { + List blocks = blocks(pem, "CERTIFICATE"); + if (blocks.isEmpty()) { + throw new IOException("The PEM client certificate holds no " + + "-----BEGIN CERTIFICATE----- block"); + } + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + Certificate[] chain = new Certificate[blocks.size()]; + for (int iter = 0 ; iter < chain.length ; iter++) { + chain[iter] = factory.generateCertificate( + new ByteArrayInputStream((byte[]) blocks.get(iter))); + } + return chain; + } + + private static PrivateKey privateKey(byte[] pem, String passphrase) throws IOException, + GeneralSecurityException { + List pkcs8 = blocks(pem, "PRIVATE KEY"); + byte[] der = null; + if (!pkcs8.isEmpty()) { + der = (byte[]) pkcs8.get(0); + } else { + List rsa = blocks(pem, "RSA PRIVATE KEY"); + if (!rsa.isEmpty() && !text(pem).contains("ENCRYPTED")) { + der = pkcs1ToPkcs8((byte[]) rsa.get(0)); + } + } + if (der == null) { + // Encrypted PKCS#8, an encrypted traditional key, an EC or DSA key + // in OpenSSL's own format: the packaged arm reads all of them through + // OpenSSL, and the JDK this compiles against reads none reliably. + throw new IOException("The local Java SE runtime reads a PEM client key in " + + "unencrypted PKCS#8 (BEGIN PRIVATE KEY) or RSA (BEGIN RSA PRIVATE " + + "KEY) form only" + + (passphrase != null ? ", and not a passphrase-protected one" : "") + + ". Convert it with `openssl pkcs8 -topk8 -nocrypt`, or use a " + + "PKCS#12 archive, which both runtimes read."); + } + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + String[] algorithms = {"RSA", "EC", "EdDSA", "DSA"}; + for (String algorithm : algorithms) { + try { + return KeyFactory.getInstance(algorithm).generatePrivate(spec); + } catch (GeneralSecurityException notThisOne) { + // the next algorithm + } + } + throw new IOException("The PEM client key is not an RSA, EC, EdDSA or DSA key " + + "this JVM can read"); + } + + /// The DER of every `-----BEGIN