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:
- Insecure — all data transmitted without encryption.
- Server-Side TLS — browser like encryption, where only the server provides TLS certificate to the client.
- 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 |
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
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:
Working scheme:
Client ↔ Proxy [HTTP(S) gRPC-Web] ↔ Server (gRPC)There are two implementations — official gRPC-Web and @improbable-eng/grpc-web.
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.There are two proxies — Envoy with gRPC-Web filter from official gRPC-Web and grpcwebproxy from @improbable-eng.
You can use either client with either proxy.
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
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
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)