Skip to content

Commit

Permalink
improve: generate server TLS cert from CA
Browse files Browse the repository at this point in the history
With this commit, a user has 3 options for configuring TLS:

1. set tls.ACME=true, to use ACME to create a publicly trusted certificate
2. set tls.ca and tls.caPrivateKey, which will be used to generate a TLS
   ceritifcate for any hostname the client requests
3. set tls.certificate and tls.privateKey to use that certificate for TLS
  • Loading branch information
dnephin committed Jun 24, 2022
1 parent 57e4608 commit 83bd672
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 207 deletions.
105 changes: 0 additions & 105 deletions internal/certs/certs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
Expand All @@ -14,11 +13,6 @@ import (
"net"
"strings"
"time"

"go.uber.org/zap"
"golang.org/x/crypto/acme/autocert"

"github.com/infrahq/infra/internal/logging"
)

func GenerateCertificate(hosts []string, caCert *x509.Certificate, caKey crypto.PrivateKey) (certPEM []byte, keyPEM []byte, err error) {
Expand Down Expand Up @@ -63,64 +57,6 @@ func GenerateCertificate(hosts []string, caCert *x509.Certificate, caKey crypto.
return PEMEncodeCertificate(certBytes), keyBytes, nil
}

func SelfSignedOrLetsEncryptCert(manager *autocert.Manager) func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
ctx := hello.Context()
cert, err := manager.GetCertificate(hello)
if err == nil {
return cert, nil
}

serverName := hello.ServerName

if serverName == "" {
serverName = hello.Conn.LocalAddr().String()
}

certBytes, err := manager.Cache.Get(ctx, serverName+".crt")
if err != nil {
logging.S.Warnf("cert: %s", err)
}

keyBytes, err := manager.Cache.Get(ctx, serverName+".key")
if err != nil {
logging.S.Warnf("key: %s", err)
}

// if either cert or key is missing, create it
if certBytes == nil || keyBytes == nil {
ca, caPrivKey, err := newCA()
if err != nil {
return nil, err
}

certBytes, keyBytes, err = GenerateCertificate([]string{serverName}, ca, caPrivKey)
if err != nil {
return nil, err
}

if err := manager.Cache.Put(ctx, serverName+".crt", certBytes); err != nil {
return nil, err
}

if err := manager.Cache.Put(ctx, serverName+".key", keyBytes); err != nil {
return nil, err
}

logging.L.Info("new server certificate",
zap.String("Server name", serverName),
zap.String("SHA256 fingerprint", Fingerprint(pemDecode(certBytes))))
}

keypair, err := tls.X509KeyPair(certBytes, keyBytes)
if err != nil {
return nil, err
}

return &keypair, nil
}
}

// Fingerprint returns a sha256 checksum of the certificate formatted as
// hex pairs separated by colons. This is a common format used by browsers.
// The bytes must be the ASN.1 DER form of the x509.Certificate.
Expand All @@ -130,11 +66,6 @@ func Fingerprint(raw []byte) string {
return strings.ToUpper(s)
}

func pemDecode(raw []byte) []byte {
block, _ := pem.Decode(raw)
return block.Bytes
}

// PEMEncodeCertificate accepts the bytes of a x509 certificate in ASN.1 DER form
// and returns a PEM encoded representation of that certificate.
func PEMEncodeCertificate(raw []byte) []byte {
Expand All @@ -146,39 +77,3 @@ func PEMEncodeCertificate(raw []byte) []byte {
func pemEncodePrivateKey(raw []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: raw})
}

func newCA() (*x509.Certificate, *rsa.PrivateKey, error) {
// Generate a CA to sign self-signed certificates
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, err
}

caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}

ca := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Infra"},
},
NotBefore: time.Now().Add(-5 * time.Minute).UTC(),
NotAfter: time.Now().AddDate(0, 0, 365).UTC(),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IsCA: true,
BasicConstraintsValid: true,
}

caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey)
if err != nil {
return nil, nil, err
}

// TODO: is there really no other way to get the Raw field populated?
ca, _ = x509.ParseCertificate(caBytes)

return ca, caPrivKey, nil
}
2 changes: 2 additions & 0 deletions internal/cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ tls:
caPrivateKey: file:ca.key
certificate: testdata/server.crt
privateKey: file:server.key
ACME: true
keys:
- kind: vault
Expand Down Expand Up @@ -223,6 +224,7 @@ users:
CAPrivateKey: "file:ca.key",
Certificate: "-----BEGIN CERTIFICATE-----\nnot a real server certificate\n-----END CERTIFICATE-----\n",
PrivateKey: "file:server.key",
ACME: true,
},

Keys: []server.KeyProvider{
Expand Down
67 changes: 5 additions & 62 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,24 @@ package server

import (
"context"
"crypto/tls"
"crypto/x509"
"embed"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"net/http/httputil"
"os"
"strings"
"time"

"github.com/gin-contrib/gzip"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/infrahq/secrets"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/sync/errgroup"
"gorm.io/gorm"

"github.com/infrahq/infra/internal"
"github.com/infrahq/infra/internal/certs"
"github.com/infrahq/infra/internal/cmd/types"
"github.com/infrahq/infra/internal/ginutil"
"github.com/infrahq/infra/internal/logging"
Expand Down Expand Up @@ -83,6 +78,11 @@ type TLSOptions struct {
CAPrivateKey string
Certificate types.StringOrFile
PrivateKey string

// ACME enables automated certificate manangement. When set to true a TLS
// certificate will be requested from Let's Encrypt, which will be cached
// in the TLSCache.
ACME bool
}

type Server struct {
Expand Down Expand Up @@ -299,63 +299,6 @@ type routine struct {
stop func()
}

func tlsConfigFromOptions(
storage map[string]secrets.SecretStorage,
tlsCacheDir string,
opts TLSOptions,
) (*tls.Config, error) {
// TODO: print CA fingerprint when the client can trust that fingerprint

if opts.Certificate != "" && opts.PrivateKey != "" {
roots, err := x509.SystemCertPool()
if err != nil {
logging.S.Warnf("failed to load TLS roots from system: %v", err)
roots = x509.NewCertPool()
}

if opts.CA != "" {
if !roots.AppendCertsFromPEM([]byte(opts.CA)) {
logging.S.Warnf("failed to load TLS CA, invalid PEM")
}
}

key, err := secrets.GetSecret(opts.PrivateKey, storage)
if err != nil {
return nil, fmt.Errorf("failed to load TLS private key: %w", err)
}

cert, err := tls.X509KeyPair([]byte(opts.Certificate), []byte(key))
if err != nil {
return nil, fmt.Errorf("failed to load TLS key pair: %w", err)
}

return &tls.Config{
MinVersion: tls.VersionTLS12,
// enable HTTP/2
NextProtos: []string{"h2", "http/1.1"},
Certificates: []tls.Certificate{cert},
// enabled optional mTLS
ClientAuth: tls.VerifyClientCertIfGiven,
ClientCAs: roots,
}, nil
}

if err := os.MkdirAll(tlsCacheDir, 0o700); err != nil {
return nil, fmt.Errorf("create tls cache: %w", err)
}

manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(tlsCacheDir),
}
tlsConfig := manager.TLSConfig()
tlsConfig.MinVersion = tls.VersionTLS12
// TODO: enabled optional mTLS when opts.CA is set
tlsConfig.GetCertificate = certs.SelfSignedOrLetsEncryptCert(manager)

return tlsConfig, nil
}

func (s *Server) getDatabaseDriver() (gorm.Dialector, error) {
postgres, err := s.getPostgresConnectionString()
if err != nil {
Expand Down
44 changes: 4 additions & 40 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"io/ioutil"
"net/http"
Expand Down Expand Up @@ -204,6 +203,10 @@ func TestServer_Run_UIProxy(t *testing.T) {
DBFile: filepath.Join(dir, "sqlite3.db"),
UI: UIOptions{Enabled: true},
EnableSignup: true,
TLS: TLSOptions{
CA: types.StringOrFile(golden.Get(t, "pki/ca.crt")),
CAPrivateKey: string(golden.Get(t, "pki/ca.key")),
},
}
assert.NilError(t, opts.UI.ProxyURL.Set(uiSrv.URL))

Expand Down Expand Up @@ -489,42 +492,3 @@ func TestServer_PersistSignupUser(t *testing.T) {
// retry the authenticated endpoint
checkAuthenticated()
}

func TestTLSConfigFromOptions(t *testing.T) {
storage := map[string]secrets.SecretStorage{
"plaintext": &secrets.PlainSecretProvider{},
"file": &secrets.FileSecretProvider{},
}

ca := golden.Get(t, "pki/ca.crt")
t.Run("user provided certificate", func(t *testing.T) {
opts := TLSOptions{
CA: types.StringOrFile(ca),
Certificate: types.StringOrFile(golden.Get(t, "pki/localhost.crt")),
PrivateKey: "file:testdata/pki/localhost.key",
}
config, err := tlsConfigFromOptions(storage, t.TempDir(), opts)
assert.NilError(t, err)

srv := httptest.NewUnstartedServer(noopHandler)
srv.TLS = config
srv.StartTLS()
t.Cleanup(srv.Close)

roots := x509.NewCertPool()
roots.AppendCertsFromPEM(ca)
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12},
},
}

resp, err := client.Get(srv.URL)
assert.NilError(t, err)
assert.Equal(t, resp.StatusCode, http.StatusOK)
})
}

var noopHandler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
Loading

0 comments on commit 83bd672

Please sign in to comment.