-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(security): TLS with self-signed certs in local gRPC communications (
#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
Showing
34 changed files
with
1,043 additions
and
150 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.