Last-mile Security for gRPC-connected mobile APIs
In Consider gRPC for Mobile APIs, we evaluated gRPC for use in mobile applications. We took a look at common operations such as:
I concluded that the RPC function call paradigm felt more natural to me than designing and implementing a fully-RESTful API implementation, and I said I wouldn’t hesitate to use gRPC with mobile clients when the API is static and well understood.
For mobile apps, certificate pinning is an important capability to strengthen API security, and in this sequel, we’ll examine certificate pinning for gRPC on Android. Spoiler alert - in the end, it’s quite similar to pinning a restful connection.
Transport Layer Security (TLS) is a well accepted and evolving standard to strengthen privacy and message integrity. When establishing a connection, a server endpoint sends its public key certificate to the requesting client. The client follows the certificate chain of trust until it reaches a root certificate it implicitly trusts.
Source: Wikipedia — chain of trust: image originally via Gary Stevens of HostingCanada.org
Android and iOS devices maintain an installed set of certificates which they implicitly trust. Unfortunately, it is too easy to trick mobile devices into trusting certificates signed by unexpected certificate authorities. Diagnostic tools, such as mitmproxy, use this same technique to intercept and potentially interfere with encrypted HTTPS streams.
On mobile devices, certificate pinning should be used to limit trust to website leaf certificates or only those intermediate or root authorities trusted by the app itself. You can pin against the certificates, their public keys, or hashes of their public keys. Options for storing these certificate or key pins include:
When verifying the pinning certificates, the client verifies both the certificate’s signature and the requested hostname. Because the same IP address may share multiple hostnames, Server Name Indication is a TLS extension which enables a client to request a specific certificate by virtual hostname. In addition to its use in virtual hosting, this technique simplifies debugging self-signed certificates served from localhost.
With gRPC, a client makes an rpc call to a stub interface which, through a channel, sends one or more proto request messages to and receives one or more response messages from the server. In Consider gRPC for Mobile APIs, we used a plain managed channel for our transport. To pin the channel, we will enable TLS (SSL) and create our own set of trusted certificates, separate from the certificates already installed on the device. We’ll use Android for our examples.
First, we will build our own keystore within our demonstration app. For convenience, we store a set of public key certificates as raw resources, and we identify those resources in a certs.xml resource file:
<?xml version="1.0" encoding="utf-8"?> | |
<resources> | |
<integer-array name="certs"> | |
<item>@raw/localhost</item> | |
<item>@raw/otherhost</item> | |
</integer-array> | |
</resources> |
The keystore is created when the ShapesActivity is started, and the keystore and a server name override are passed to our pinned managed channel, PinnedChannelBuilder:
// build a key store from a set of raw certificates | |
KeyStore createPinnedKeyStore(int certsId) { | |
TypedArray rawCerts = null; | |
KeyStore ks = null; | |
try { | |
rawCerts = getResources().obtainTypedArray(certsId); | |
ks = KeyStore.getInstance(KeyStore.getDefaultType()); | |
ks.load(null, null); | |
CertificateFactory cf = CertificateFactory.getInstance("X.509"); | |
for (int i = 0; i < rawCerts.length(); ++i) { | |
InputStream certIS = getResources(). | |
openRawResource(rawCerts.getResourceId(i, -1)); | |
X509Certificate cert = (X509Certificate) cf.generateCertificate(certIS); | |
ks.setCertificateEntry("cert" + i, cert); | |
} | |
} catch (Exception e) { | |
throw new RuntimeException(e); | |
} finally { | |
if (rawCerts != null) rawCerts.recycle(); | |
} | |
return ks; | |
} | |
@Override | |
protected void onCreate(Bundle savedInstanceState) { | |
super.onCreate(savedInstanceState); | |
// ... | |
// open grpc managed channel | |
String host = getResources().getString(R.string.host); | |
int port = getResources().getInteger(R.integer.port); | |
String serverHostOverride = "localhost"; | |
KeyStore certsKS = createPinnedKeyStore(R.array.certs); | |
channel = PinnedChannelBuilder.build(host, port, | |
serverHostOverride, certsKS); | |
} |
We override the server name so that the server will respond with an end-entity certificate whose common name (CN) matches what we have pinned.
The PinnedChannelBuilder we are building will use a customized SSLSocketFactory to make its connections. Java’s security and networking stack make this a bit tedious, requiring 1) building a javax.net.ssl.TrustManagerFactory containing our java.security.KeyStore, 2) creating a javax.net.ssl.SSLContext containing this TrustManagerFactory, and 3) exposing the javax.net.ssl.SSLSocketFactory from within the SSLContext:
public class PinnedChannelBuilder { | |
public static ManagedChannel build(String host, int port, | |
@Nullable String serverHostOverride, | |
KeyStore certsKS) { | |
ManagedChannelBuilder<?> channelBuilder = | |
ManagedChannelBuilder.forAddress(host, port) | |
.maxInboundMessageSize(16 * 1024 * 1024); | |
if (serverHostOverride != null) { | |
// Force the hostname to match the cert the server uses. | |
channelBuilder.overrideAuthority(serverHostOverride); | |
} | |
try { | |
((OkHttpChannelBuilder) channelBuilder).useTransportSecurity(); | |
((OkHttpChannelBuilder) channelBuilder). | |
sslSocketFactory(getSslSocketFactory(certsKS)); | |
} catch (Exception e) { | |
throw new RuntimeException(e); | |
} | |
return channelBuilder.build(); | |
} | |
private static SSLSocketFactory getSslSocketFactory(KeyStore certsKS) | |
throws Exception { | |
if (certsKS == null) | |
throw new RuntimeException("No pinning Certificates found"); | |
// initialize trust manager factor from certs keystore | |
TrustManagerFactory tmf = TrustManagerFactory. | |
getInstance(TrustManagerFactory.getDefaultAlgorithm()); | |
tmf.init(certsKS); | |
// initialize SSL context from trust manager factory | |
SSLContext context = SSLContext.getInstance("TLS"); | |
context.init(null, tmf.getTrustManagers() , null); | |
// return socket factory from the SSL context | |
return context.getSocketFactory(); | |
} | |
} |
Though cumbersome, this is not much different than building a custom socket factory for a restful HTTPS connection, although many networking stacks and Android N have convenience methods to hide this complexity.
To try it out, we use the same shapes demo application we used previously. We are running a gRPC server on localhost serving a shapes.proto API to a shapes app running in a local Android emulator.
First, we’ll generate a self-signed private key, public-key certificate pair for localhost using openssl:
$ openssl req -newkey rsa:4096 -nodes -keyout localhost.key | |
-x509 -days 365 -out localhost.crt | |
Generating a 4096 bit RSA private key | |
............................................++ | |
..................................................................++ | |
writing new private key to 'localhost.key' | |
-----. | |
You are about to be asked to enter information that will be incorporated | |
into your certificate request. | |
What you are about to enter is what is called a Distinguished Name or a DN. | |
There are quite a few fields but you can leave some blank | |
For some fields there will be a default value, | |
If you enter '.', the field will be left blank. | |
----- | |
Country Name (2 letter code) []: | |
State or Province Name (full name) []: | |
Locality Name (eg, city) []: | |
Organization Name (eg, company) []: | |
Organizational Unit Name (eg, section) []: | |
Common Name (eg, fully qualified host name) []:localhost | |
Email Address []: | |
$ |
The localhost.crt certificate file should be copied into the shape app’s raw resources directory (app/src/main/res/raw/). Similarly, we generate an otherhost.crt certificate file and also copy it to the raw resource directory.
We’ll install both the localhost.key private key and the localhost.crt certificate files into our gRPC server. gRPC servers are commonly configured for mutual-SSL. Our client is pinning the gRPC server and not the other way around, so ensure mutual SSL is disabled.
Now we fire up the app. The managed channel requests the localhost certificate from the server, and the channel is successfully pinned upon connection. Hitting the Stream button, we see the expected response:
To test the pinning, we delete the localhost certificate from the app and restart. This time the server delivers the localhost certificate, but the trust manager finds no matching pinned certificate, so the channel connection fails:
To keep it simple, we have only shown self-signed leaf certificates. A better practice would be to pin on intermediate certificates. Square’s certstrap tool is an excellent resource for generating your own test certificate authority and longer key chains if you would like to explore these scenarios.
On Android, we were able to demonstrate a pinned gRPC channel, and it really wasn’t any more difficult than pinning a restful HTTPS connection. Including Consider gRPC for Mobile APIs, we have demonstrated:
gRPC’s function call paradigm, along with gRPC’s ability to generate both client and server API interfaces for many target languages from a single proto file and our demonstration of basic and secure API functionality, makes gRPC a reasonable approach for mobile API development.