Skip to content

Commit

Permalink
fix(security): TLS with self-signed certs in local gRPC communications (
Browse files Browse the repository at this point in the history
#729)

That means no insecure channels between agent and gui and agent and
wsl-pro-service.

The strategy adopted here is mutual TLS, i.e. the agent authenticates
itself with clients (as normal TLS does), _but clients also authenticate
themselves to the agent_, which required extending the Dart
`ChannelCredentials` to allow providing custom private keys and
certificate chains. I got surprised to learn that there is no equivalent
of `credentials.NewTLS(tls.Config)` for `grpc.dart`

Most of the logic to create certificates was placed in common so we can
reuse in wsl-pro-service tests.

The specific call to create certificates at the right location was
placed in the agent, though, as it must be the source of all truth.

This PR pushed waaay out of my comfort zone, so feel free to dissect and
scrutinate any piece of code around generating and loading the
certificates.
  • Loading branch information
CarlosNihelton authored Apr 18, 2024
2 parents e42ea3c + 6da8123 commit 80f760b
Show file tree
Hide file tree
Showing 34 changed files with 1,043 additions and 150 deletions.
153 changes: 153 additions & 0 deletions common/certs/certs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Package certs provides functions to create certificates suitable for mTLS communication.
// In production only the agent should create those certificates, but placing this in the common module facilities other components's tests.
package certs

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"time"

"github.com/canonical/ubuntu-pro-for-wsl/common"
"github.com/ubuntu/decorate"
)

// Heavily inspired in:
// - https://go.dev/src/crypto/tls/generate_cert.go
// - https://github.com/grpc/grpc-go/blob/master/examples/features/encryption/mTLS
// - and https://gist.github.com/annanay25/43e3846e21b30818d8dcd5f9987e852d.

// CreateRootCA creates a new root certificate authority (CA) certificate and private key pair with the serial number and common name provided.
// Only the cert is written into destDir in the PEM format. Being a CA, the certificate and private key returned can be used to sign other certificates.
func CreateRootCA(commonName string, serialNo *big.Int, destDir string) (rootCert *x509.Certificate, rootKey *ecdsa.PrivateKey, err error) {
// generate a new key-pair for the root certificate based on the P256 elliptic curve
rootKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate random key: %v", err)
}

rootCertTmpl := template(commonName, serialNo)
rootCertTmpl.IsCA = true
rootCertTmpl.Subject.CommonName = commonName + " CA"
rootCertTmpl.KeyUsage = x509.KeyUsageCertSign

// We pass the template as the parent as well so that the certificate is self-signed.
rootCert, rootDER, err := createCert(rootCertTmpl, rootCertTmpl, &rootKey.PublicKey, rootKey)
if err != nil {
return nil, nil, err
}

// Write the CA certificate to disk.
// Notice that we don't write the private key to disk. Only the caller of this function can create other certificates signed by this root CA.
if err = writeCert(filepath.Join(destDir, common.RootCACertFileName), rootDER); err != nil {
return nil, nil, err
}

return rootCert, rootKey, nil
}

// CreateTLSCertificateSignedBy creates a certificate and key pair usable for authentication signed by the root certificate authority (root CA) certificate and key provided and write them into destDir in the PEM format.
func CreateTLSCertificateSignedBy(name, certCN string, serial *big.Int, rootCACert *x509.Certificate, rootCAKey *ecdsa.PrivateKey, destDir string) (tlsCert *tls.Certificate, err error) {
decorate.OnError(&err, "could not create root signed certificate pair for %s:", name)

key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate random key: %v", err)
}

certTmpl := template(certCN, serial)
// Customizing the usage for client and server applications:
// Even though x509.CreateCertificate documentation says it will use it, if present,
// it seems we need to set AuthorityKeyId manually to make the verification work.
certTmpl.KeyUsage = x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
certTmpl.AuthorityKeyId = rootCACert.SubjectKeyId

cert, der, err := createCert(certTmpl, rootCACert, &key.PublicKey, rootCAKey)
if err != nil {
return nil, err
}

// Verify the certificate against the root certificate.
caCertPool := x509.NewCertPool()
caCertPool.AddCert(rootCACert)
if _, err = cert.Verify(x509.VerifyOptions{Roots: caCertPool}); err != nil {
return nil, fmt.Errorf("certificate verification failed: %v", err)
}

if err = writeCert(filepath.Join(destDir, name+common.CertificateSuffix), der); err != nil {
return nil, err
}
if err = writeKey(filepath.Join(destDir, name+common.KeySuffix), key); err != nil {
return nil, err
}

return &tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: key,
Leaf: cert,
}, nil
}

// createCert invokes x509.CreateCertificate and returns the certificate and it's DER as bytes for serialization.
func createCert(template, parent *x509.Certificate, pub, parentPriv any) (cert *x509.Certificate, certDER []byte, err error) {
decorate.OnError(&err, "could not create certificate:")

certDER, err = x509.CreateCertificate(rand.Reader, template, parent, pub, parentPriv)
if err != nil {
return nil, nil, err
}

// parse the resulting certificate so we can use it again
cert, err = x509.ParseCertificate(certDER)

return cert, certDER, err
}

// template is a helper function to create a cert template with a serial number and other required fields filled in for UP4W specific use case.
func template(commonName string, serial *big.Int) *x509.Certificate {
return &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{Organization: []string{commonName}, CommonName: commonName},
DNSNames: []string{commonName, "localhost", "127.0.0.1"},
SignatureAlgorithm: x509.ECDSAWithSHA256,
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour * 24 * 30), // arbitrarily chosen expiration in a month
BasicConstraintsValid: true,
}
}

// writeCert writes a certificate to disk in PEM format to the given filename.
func writeCert(filename string, DER []byte) error {
w, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to open %q for writing: %v", filename, err)
}
defer w.Close()

return pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: DER})
}

// writeKey writes a private key to disk in PEM format to the given filename.
func writeKey(filename string, priv *ecdsa.PrivateKey) error {
w, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open %q for writing: %v", filename, err)
}
defer w.Close()

p, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return fmt.Errorf("failed to marshal private key: %v", err)
}

return pem.Encode(w, &pem.Block{Type: "PRIVATE KEY", Bytes: p})
}
118 changes: 118 additions & 0 deletions common/certs/certs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package certs_test

import (
"math/big"
"os"
"path/filepath"
"testing"

"github.com/canonical/ubuntu-pro-for-wsl/common"
"github.com/canonical/ubuntu-pro-for-wsl/common/certs"
"github.com/stretchr/testify/require"
)

func TestCreateRooCA(t *testing.T) {
t.Parallel()

testcases := map[string]struct {
missingSerialNumber bool
breakCertPem bool

wantErr bool
}{
"Success": {},

"Error when serial number is missing": {missingSerialNumber: true, wantErr: true},
"Error when the root CA certificate file cannot be written": {breakCertPem: true, wantErr: true},
}

for name, tc := range testcases {
t.Run(name, func(t *testing.T) {
t.Parallel()

var testSerial *big.Int
if !tc.missingSerialNumber {
testSerial = new(big.Int).SetInt64(1)
}

dir := t.TempDir()

if tc.breakCertPem {
require.NoError(t, os.MkdirAll(filepath.Join(dir, common.RootCACertFileName), 0700), "Setup: failed to create a directory that should break cert.pem")
}

rootCert, rootKey, err := certs.CreateRootCA("test-root-ca", testSerial, dir)

if tc.wantErr {
require.Error(t, err, "CreateRootCA should have failed")
return
}
require.NoError(t, err, "CreateRootCA failed")
require.NotNil(t, rootCert, "CreateRootCA didn't return a certificate")
require.NotNil(t, rootKey, "CreateRootCA didn't return a private key")
require.FileExists(t, filepath.Join(dir, common.RootCACertFileName), "CreateRootCA failed to write the certificate to disk")
})
}
}

func TestCreateTLSCertificateSignedBy(t *testing.T) {
t.Parallel()

testcases := map[string]struct {
missingSerialNumber bool
rootIsNotCA bool
breakCertPem bool
breakKeyPem bool

wantErr bool
}{
"Success": {},

"Error when serial number is missing": {missingSerialNumber: true, wantErr: true},
"Error when the signing certificate is not an authority": {rootIsNotCA: true, wantErr: true},
"Error when the cert.pem file cannot be written": {breakCertPem: true, wantErr: true},
"Error when the key.pem file cannot be written": {breakKeyPem: true, wantErr: true},
}

for name, tc := range testcases {
t.Run(name, func(t *testing.T) {
t.Parallel()

rootCert, rootKey, err := certs.CreateRootCA("test-root-ca", new(big.Int).SetInt64(1), t.TempDir())
require.NoError(t, err, "Setup: failed to generate root CA cert")
if tc.rootIsNotCA {
rootCert.IsCA = false
rootCert.AuthorityKeyId = nil
}

var testSerial *big.Int
if !tc.missingSerialNumber {
testSerial = new(big.Int).SetInt64(1)
}

dir := t.TempDir()

agentCertName := common.AgentCertFilePrefix + common.CertificateSuffix
agentKeyName := common.AgentCertFilePrefix + common.KeySuffix

if tc.breakCertPem {
require.NoError(t, os.MkdirAll(filepath.Join(dir, agentCertName), 0700), "Setup: failed to create a directory that should break cert.pem")
}

if tc.breakKeyPem {
require.NoError(t, os.MkdirAll(filepath.Join(dir, agentKeyName), 0700), "Setup: failed to create a directory that should break key.pem")
}

tlsCert, err := certs.CreateTLSCertificateSignedBy(common.AgentCertFilePrefix, "test-server-cn", testSerial, rootCert, rootKey, dir)

if tc.wantErr {
require.Error(t, err, "CreateTLSCertificateSignedBy should have failed")
return
}
require.NoError(t, err, "CreateTLSCertificateSignedBy failed")
require.NotNil(t, tlsCert, "CreateTLSCertificateSignedBy returned a nil certificate")
require.FileExists(t, filepath.Join(dir, agentCertName), "CreateTLSCertificateSignedBy failed to write the certificate")
require.FileExists(t, filepath.Join(dir, agentKeyName), "CreateTLSCertificateSignedBy failed to write the certificate")
})
}
}
21 changes: 21 additions & 0 deletions common/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,25 @@ const (
//
// TODO: Replace with real product ID.
MsStoreProductID = "9P25B50XMKXT"

// CertificatesDir is the agent's public subdirectory where the certificates are stored.
CertificatesDir = "certs"

// GRPCServerNameOverride is the name to override the server name in when configuring TLS for local clients.
GRPCServerNameOverride = "UP4W"

// RootCACertFileName is the name of the certificate file that identifies the root certificate authority in the PEM format.
RootCACertFileName = "ca_cert.pem"

// AgentCertFilePrefix is the file name prefix to identify the certificate/key pair of the agent in the PEM format.
AgentCertFilePrefix = "agent"

// ClientsCertFilePrefix is the file name prefix to identify the certificate/key pair of the clients (GUI and all WSL instances) in the PEM format.
ClientsCertFilePrefix = "client"

// CertificateSuffix is the file name suffix to the (public) certificate in the PEM format.
CertificateSuffix = "_cert.pem"

// KeySuffix is the file name suffix to the private key in the PEM format.
KeySuffix = "_key.pem"
)
2 changes: 1 addition & 1 deletion common/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/snapcore/go-gettext v0.0.0-20201130093759-38740d1bd3d2
github.com/stretchr/testify v1.9.0
github.com/ubuntu/decorate v0.0.0-20230905131025-e968fa48a85c
github.com/ubuntu/gowsl v0.0.0-20240313091109-66e05bce56e0
google.golang.org/grpc v1.63.2
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0
Expand All @@ -22,7 +23,6 @@ require (
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/ubuntu/decorate v0.0.0-20230905131025-e968fa48a85c // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
Expand Down
Loading

0 comments on commit 80f760b

Please sign in to comment.