DEV Community

Alexey Vasyukov
Alexey Vasyukov

Posted on

6 1

How to setup and test TLS in gRPC/gRPC-Web

This article has samples in Node.JS but can be helpful for other languages, so lets start. All working examples you can find here.


How TLS works?

I recommend to read the best comic-article about how TLS works.

gRPC connection types

There are three types for gRPC connections you can use:

  1. Insecure — all data transmitted without encryption.
  2. Server-Side TLS — browser like encryption, where only the server provides TLS certificate to the client.
  3. Mutual TLS — most secure, both the server and the client provides there certificates to each other.

Creating Self-Signed certificates

This is optional step if you don’t have certificate from any CA’s or want to work with TLS on localhost.

Note that in production environment is mostly recommended to choose CA’s certificate for more security, for example you can get free certificate from Let’s Encrypt.

rm *.pem
rm *.srl
rm *.cnf
# 1. Generate CA's private key and self-signed certificate
openssl req -x509 -newkey rsa:4096 -days 365 -nodes -keyout ca-key.pem -out ca-cert.pem -subj "/C=FR/ST=Occitanie/L=Toulouse/O=Test Org/OU=Test/CN=*.test/emailAddress=test@gmail.com"
echo "CA's self-signed certificate"
openssl x509 -in ca-cert.pem -noout -text
# 2. Generate web server's private key and certificate signing request (CSR)
openssl req -newkey rsa:4096 -nodes -keyout server-key.pem -out server-req.pem -subj "/C=FR/ST=Ile de France/L=Paris/O=Server TLS/OU=Server/CN=*.tls/emailAddress=tls@gmail.com"
# Remember that when we develop on localhost, It’s important to add the IP:0.0.0.0 as an Subject Alternative Name (SAN) extension to the certificate.
echo "subjectAltName=DNS:*.tls,DNS:localhost,IP:0.0.0.0" > server-ext.cnf
# Or you can use localhost DNS and grpc.ssl_target_name_override variable
# echo "subjectAltName=DNS:localhost" > server-ext.cnf
# 3. Use CA's private key to sign web server's CSR and get back the signed certificate
openssl x509 -req -in server-req.pem -days 60 -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -extfile server-ext.cnf
echo "Server's signed certificate"
openssl x509 -in server-cert.pem -noout -text
# 4. Generate client's private key and certificate signing request (CSR)
openssl req -newkey rsa:4096 -nodes -keyout client-key.pem -out client-req.pem -subj "/C=FR/ST=Alsace/L=Strasbourg/O=PC Client/OU=Computer/CN=*.client.com/emailAddress=client@gmail.com"
# Remember that when we develop on localhost, It’s important to add the IP:0.0.0.0 as an Subject Alternative Name (SAN) extension to the certificate.
echo "subjectAltName=DNS:*.client.com,IP:0.0.0.0" > client-ext.cnf
# 5. Use CA's private key to sign client's CSR and get back the signed certificate
openssl x509 -req -in client-req.pem -days 60 -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out client-cert.pem -extfile client-ext.cnf
echo "Client's signed certificate"
openssl x509 -in client-cert.pem -noout -text
view raw gen-certs.sh hosted with ❤ by GitHub

Preparation

Let’s start from implementing our gRPC service.

First, we will define our service Protocol Buffers file.

syntax = "proto3";
package tls_service.v1;
message SimpleMessage {
string id = 1;
}
service TLSService {
rpc Unary(SimpleMessage) returns (SimpleMessage);
}

Second, we need to generate types, service and client definitions.
For TypeScript I prefer to use ts-proto, but you can choose any tool you like depends on your language.

protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt=env=node,outputServices=grpc-js --ts_proto_out=./src/generated ./proto/tls_service.proto
Enter fullscreen mode Exit fullscreen mode

Now we can implement our server running on Node.js.


gRPC

We will use an official @grpc/grpc-js package both for server and client side here.


Server-Side TLS

Server-Side TLS requires only the server certificate and it’s private key.

Server

import { Server, ServerCredentials } from '@grpc/grpc-js';
import * as fs from 'fs';
import * as path from 'path';
import { TLSServiceServer, TLSServiceService } from './generated/proto/tls_service';
const TLSService: TLSServiceServer = {
unary(call, callback) {
callback(null, call.request);
},
};
function getServerCredentials(): ServerCredentials {
const serverCert = fs.readFileSync(path.resolve(__dirname, '../certs/server-cert.pem'));
const serverKey = fs.readFileSync(path.resolve(__dirname, '../certs/server-key.pem'));
const serverCredentials = ServerCredentials.createSsl(
null,
[
{
cert_chain: serverCert,
private_key: serverKey,
},
],
false
);
return serverCredentials;
}
function main() {
const server = new Server();
const serverCredentials = getServerCredentials();
server.addService(TLSServiceService, TLSService);
server.bindAsync('0.0.0.0:4000', serverCredentials, () => {
server.start();
console.log('gRPC server started on 0.0.0.0:4000');
});
}
main();

Client

Now we can implement the client side.

Note here that if your TLS certificate signed with CA (not Self-Signed) you don’t need to provide this certificate on the client, it should automatically works.

import { ChannelCredentials } from '@grpc/grpc-js';
import * as fs from 'fs';
import * as path from 'path';
import { TLSServiceClient } from './generated/proto/tls_service';
function getChannelCredentials(): ChannelCredentials {
const rootCert = fs.readFileSync(path.resolve(__dirname, '../certs/ca-cert.pem'));
// If you use CA root certificate
// const channelCredentials = ChannelCredentials.createSsl();
// If you use Self-Signed root certificate you need to provide it
const channelCredentials = ChannelCredentials.createSsl(rootCert);
return channelCredentials;
}
function main() {
const credentials = getChannelCredentials();
const client = new TLSServiceClient('0.0.0.0:4000', credentials);
client.unary({ id: 'test' }, (error, response) => {
// eslint-disable-next-line no-console
console.log('response: ', response);
});
}
main();

Mutual TLS

Mutual TLS requires the root certificate, server certificate and it’s private key.
Root certificate would be used here for checking that client certificate is signed and the server can trust to the client.

Server

Like in Server-Side section, changed only getServerCredentials function.

function getServerCredentials(): ServerCredentials {
const rootCert = fs.readFileSync(path.resolve(__dirname, '../certs/ca-cert.pem'));
const serverCert = fs.readFileSync(path.resolve(__dirname, '../certs/server-cert.pem'));
const serverKey = fs.readFileSync(path.resolve(__dirname, '../certs/server-key.pem'));
const serverCredentials = ServerCredentials.createSsl(
rootCert,
[
{
cert_chain: serverCert,
private_key: serverKey,
},
],
true
);
return serverCredentials;
}

Client

Like in Server-Side section, changed only getChannelCredentials function.

function getChannelCredentials(): ChannelCredentials {
const rootCert = fs.readFileSync(path.resolve(__dirname, '../certs/ca-cert.pem'));
const clientCert = fs.readFileSync(path.resolve(__dirname, '../certs/client-cert.pem'));
const clientKey = fs.readFileSync(path.resolve(__dirname, '../certs/client-key.pem'));
const channelCredentials = ChannelCredentials.createSsl(rootCert, clientKey, clientCert);
return channelCredentials;
}

Override SSL target name

@grpc/grpc-js package additionally provides some useful channel options that you can set. You can read about each of them here.

grpc.ssl_target_name_override — would be helpful for us when the actual server behind the proxy and CN don’t match.

To setup channel options you need to pass them into client constructor’s third argument.


gRPC-Web

Read the grpc.io blog post about the state of gRPC-Web.

TL;DR
The things you should know:

  1. Working scheme:
    Client ↔ Proxy [HTTP(S) gRPC-Web] ↔ Server (gRPC)

  2. There are two implementations — official gRPC-Web and @improbable-eng/grpc-web.

  3. Right now gRPC-Web supports only Unary and Server-Streaming requests over HTTP(S).

    Additionally @improbable-eng/grpc-web supports client-side and bi-directional streaming with an experimental websocket transport. This is not part of the gRPC-Web spec, and is not recommended for production use.

  4. There are two proxies — Envoy with gRPC-Web filter from official gRPC-Web and grpcwebproxy from @improbable-eng.

  5. You can use either client with either proxy.

  6. Clients have different communication transports.

    Official gRPC-web supports only XMLHttpRequest.
    @improbable-eng/grpc-web additionally support Fetch (using it if available) and can be extended with custom transport, for example Node.js.

In this example we will use Envoy proxy running in docker.


Server-Side TLS

Server

As you already noticed we need to start our gRPC service behind the proxy. So there is nothing to change, simply start service with Server-Side TLS we discuss earlier.

After that we need to setup envoy and start it.

services:
envoy-server:
image: envoyproxy/envoy:v1.22.0
ports:
- 8080:8080
volumes:
- ./envoy-server.yaml:/etc/envoy/envoy.yaml:ro
- ./certs/server-cert.pem:/etc/server-cert.pem
- ./certs/server-key.pem:/etc/server-key.pem
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: simple_service
timeout: 0s
max_stream_duration:
grpc_timeout_header_max: 0s
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
max_age: "1728000"
expose_headers: custom-header-1,grpc-status,grpc-message
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
# https://www.envoyproxy.io/docs/envoy/v1.15.0/api-v3/extensions/transport_sockets/tls/v3/tls.proto#extensions-transport-sockets-tls-v3-downstreamtlscontext
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
common_tls_context:
tls_certificates:
- certificate_chain:
# Certificate must be PEM-encoded
filename: /etc/server-cert.pem
private_key:
filename: /etc/server-key.pem
clusters:
- name: simple_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {}
lb_policy: round_robin
load_assignment:
cluster_name: cluster_0
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
# address: host.docker.internal - for macOS
# address: 0.0.0.0 - for others
address: host.docker.internal
port_value: 4000
# http2_protocol_options: {} # Force HTTP/2
# Your grpc server communicates over TLS. You must configure the transport
# socket. If you care about the overhead, you should configure the grpc
# server to listen without TLS. If you need to listen to grpc-web and grpc
# over HTTP/2 both you can also proxy your TCP traffic with the envoy.
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext

docker-compose up envoy-server
Enter fullscreen mode Exit fullscreen mode

That’s it. Your gRPC-Web proxy with Server-Side TLS will be available at https://0.0.0.0:8080.

Mutual TLS

Server

The hack here is to start gRPC service with Server-Side TLS but on envoy side check the client certificate signed by trusted CA.

services:
envoy-mutual:
image: envoyproxy/envoy:v1.22.0
ports:
- 8080:8080
volumes:
- ./envoy-mutual.yaml:/etc/envoy/envoy.yaml:ro
- ./certs/ca-cert.pem:/etc/ca-cert.pem
- ./certs/server-cert.pem:/etc/server-cert.pem
- ./certs/server-key.pem:/etc/server-key.pem
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: simple_service
timeout: 0s
max_stream_duration:
grpc_timeout_header_max: 0s
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
max_age: "1728000"
expose_headers: custom-header-1,grpc-status,grpc-message
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
# https://www.envoyproxy.io/docs/envoy/v1.15.0/api-v3/extensions/transport_sockets/tls/v3/tls.proto#extensions-transport-sockets-tls-v3-downstreamtlscontext
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
require_client_certificate: true
common_tls_context:
tls_certificates:
- certificate_chain:
# Certificate must be PEM-encoded
filename: /etc/server-cert.pem
private_key:
filename: /etc/server-key.pem
validation_context:
only_verify_leaf_cert_crl: true
trusted_ca:
filename: /etc/ca-cert.pem
clusters:
- name: simple_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {}
lb_policy: round_robin
load_assignment:
cluster_name: cluster_0
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
# address: host.docker.internal - for macOS
# address: 0.0.0.0 - for others
address: host.docker.internal
port_value: 4000
# http2_protocol_options: {} # Force HTTP/2
# Your grpc server communicates over TLS. You must configure the transport
# socket. If you care about the overhead, you should configure the grpc
# server to listen without TLS. If you need to listen to grpc-web and grpc
# over HTTP/2 both you can also proxy your TCP traffic with the envoy.
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
common_tls_context:
validation_context:
trusted_ca:
filename: /etc/ca-cert.pem

docker-compose up envoy-mutual
Enter fullscreen mode Exit fullscreen mode

Great! Your gRPC-Web proxy with Mutual TLS will be available at https://0.0.0.0:8080.


Testing both gRPC and gRPC-Web requests

Recently I released a multi-platform desktop gRPC / gRPC-Web client called ezy. I’m working with gRPC every day and there is no any fully featured, UI/UX-perfect clients for gRPC testing, so I tried to create one.

This client has fully featured support of gRPC / gRPC-Web. I would be appreciate if you try it.

🙏 If you have any feedback or ideas feel free to open discussion.

Top comments (0)

AWS Security LIVE!

Join us for AWS Security LIVE!

Discover the future of cloud security. Tune in live for trends, tips, and solutions from AWS and AWS Partners.

Learn More

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay