Skip to content

Commit

Permalink
GetCertificate from external certificate sources (Managers) (#163)
Browse files Browse the repository at this point in the history
This work made possible by Tailscale: https://tailscale.com - thank you to the Tailscale team!

* Implement custom GetCertificate callback

Useful if another entity is managing certificates and can
provide its own dynamically during handshakes.

* Refactor CustomGetCertificate into OnDemandConfig

* Set certs to managed=true

This is only sorta true, but it allows handshake-time maintenance of the
certificates that are cached from CustomGetCertificate.

Our background maintenance routine skips certs that are OnDemand so it
should be fine.

* Change CustomGetCertificate into interface value

Instead of a function

* Case-insensitive subject name comparison

Hostnames are case-insensitive

Also add context to GetCertificate

* Export a couple of outrageously useful functions

* Allow multiple custom certificate getters

Also minor refactoring and enhancements

* Fix tests

* Rename Getter -> Manager; refactor

And don't cache externally managed certs

* Minor updates to comments
  • Loading branch information
mholt committed Feb 17, 2022
1 parent 134f039 commit 797d29b
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 48 deletions.
6 changes: 3 additions & 3 deletions account.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (am *ACMEManager) loadAccount(ca, email string) (acme.Account, error) {
if err != nil {
return acct, err
}
acct.PrivateKey, err = decodePrivateKey(keyBytes)
acct.PrivateKey, err = PEMDecodePrivateKey(keyBytes)
if err != nil {
return acct, fmt.Errorf("could not decode account's private key: %v", err)
}
Expand Down Expand Up @@ -129,7 +129,7 @@ func (am *ACMEManager) lookUpAccount(ctx context.Context, privateKeyPEM []byte)
return acme.Account{}, fmt.Errorf("creating ACME client: %v", err)
}

privateKey, err := decodePrivateKey([]byte(privateKeyPEM))
privateKey, err := PEMDecodePrivateKey([]byte(privateKeyPEM))
if err != nil {
return acme.Account{}, fmt.Errorf("decoding private key: %v", err)
}
Expand Down Expand Up @@ -157,7 +157,7 @@ func (am *ACMEManager) saveAccount(ca string, account acme.Account) error {
if err != nil {
return err
}
keyBytes, err := encodePrivateKey(account.PrivateKey)
keyBytes, err := PEMEncodePrivateKey(account.PrivateKey)
if err != nil {
return err
}
Expand Down
21 changes: 16 additions & 5 deletions certificates.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ type Certificate struct {
issuerKey string
}

// Empty returns true if the certificate struct is not filled out; at
// least the tls.Certificate.Certificate field is expected to be set.
func (cert Certificate) Empty() bool {
return len(cert.Certificate.Certificate) == 0
}

// NeedsRenewal returns true if the certificate is
// expiring soon (according to cfg) or has expired.
func (cert Certificate) NeedsRenewal(cfg *Config) bool {
Expand Down Expand Up @@ -251,11 +257,15 @@ func fillCertFromLeaf(cert *Certificate, tlsCert tls.Certificate) error {
// the leaf cert should be the one for the site; we must set
// the tls.Certificate.Leaf field so that TLS handshakes are
// more efficient
leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil {
return err
leaf := cert.Certificate.Leaf
if leaf == nil {
var err error
leaf, err = x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil {
return err
}
cert.Certificate.Leaf = leaf
}
cert.Certificate.Leaf = leaf

// for convenience, we do want to assemble all the
// subjects on the certificate into one list
Expand Down Expand Up @@ -393,9 +403,10 @@ func SubjectIsInternal(subj string) bool {
// states that IP addresses must match exactly, but this function
// does not attempt to distinguish IP addresses from internal or
// external DNS names that happen to look like IP addresses.
// It uses DNS wildcard matching logic.
// It uses DNS wildcard matching logic and is case-insensitive.
// https://tools.ietf.org/html/rfc2818#section-3.1
func MatchWildcard(subject, wildcard string) bool {
subject, wildcard = strings.ToLower(subject), strings.ToLower(wildcard)
if subject == wildcard {
return true
}
Expand Down
12 changes: 8 additions & 4 deletions certificates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,27 @@ func TestUnexportedGetCertificate(t *testing.T) {
cfg := &Config{certCache: certCache}

// When cache is empty
if _, matched, defaulted := cfg.getCertificate(&tls.ClientHelloInfo{ServerName: "example.com"}); matched || defaulted {
if _, matched, defaulted := cfg.getCertificateFromCache(&tls.ClientHelloInfo{ServerName: "example.com"}); matched || defaulted {
t.Errorf("Got a certificate when cache was empty; matched=%v, defaulted=%v", matched, defaulted)
}

// When cache has one certificate in it
firstCert := Certificate{Names: []string{"example.com"}}
certCache.cache["0xdeadbeef"] = firstCert
certCache.cacheIndex["example.com"] = []string{"0xdeadbeef"}
if cert, matched, defaulted := cfg.getCertificate(&tls.ClientHelloInfo{ServerName: "example.com"}); !matched || defaulted || cert.Names[0] != "example.com" {
if cert, matched, defaulted := cfg.getCertificateFromCache(&tls.ClientHelloInfo{ServerName: "example.com"}); !matched || defaulted || cert.Names[0] != "example.com" {
t.Errorf("Didn't get a cert for 'example.com' or got the wrong one: %v, matched=%v, defaulted=%v", cert, matched, defaulted)
}

// When retrieving wildcard certificate
certCache.cache["0xb01dface"] = Certificate{Names: []string{"*.example.com"}}
certCache.cacheIndex["*.example.com"] = []string{"0xb01dface"}
if cert, matched, defaulted := cfg.getCertificate(&tls.ClientHelloInfo{ServerName: "sub.example.com"}); !matched || defaulted || cert.Names[0] != "*.example.com" {
if cert, matched, defaulted := cfg.getCertificateFromCache(&tls.ClientHelloInfo{ServerName: "sub.example.com"}); !matched || defaulted || cert.Names[0] != "*.example.com" {
t.Errorf("Didn't get wildcard cert for 'sub.example.com' or got the wrong one: %v, matched=%v, defaulted=%v", cert, matched, defaulted)
}

// When no certificate matches and SNI is provided, return no certificate (should be TLS alert)
if cert, matched, defaulted := cfg.getCertificate(&tls.ClientHelloInfo{ServerName: "nomatch"}); matched || defaulted {
if cert, matched, defaulted := cfg.getCertificateFromCache(&tls.ClientHelloInfo{ServerName: "nomatch"}); matched || defaulted {
t.Errorf("Expected matched=false, defaulted=false; but got matched=%v, defaulted=%v (cert: %v)", matched, defaulted, cert)
}
}
Expand Down Expand Up @@ -190,10 +190,14 @@ func TestMatchWildcard(t *testing.T) {
expect bool
}{
{"hostname", "hostname", true},
{"HOSTNAME", "hostname", true},
{"hostname", "HOSTNAME", true},
{"foo.localhost", "foo.localhost", true},
{"foo.localhost", "bar.localhost", false},
{"foo.localhost", "*.localhost", true},
{"bar.localhost", "*.localhost", true},
{"FOO.LocalHost", "*.localhost", true},
{"Bar.localhost", "*.LOCALHOST", true},
{"foo.bar.localhost", "*.localhost", false},
{".localhost", "*.localhost", false},
{"foo.localhost", "foo.*", false},
Expand Down
12 changes: 12 additions & 0 deletions certmagic.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,18 @@ type Revoker interface {
Revoke(ctx context.Context, cert CertificateResource, reason int) error
}

// CertificateManager is a type that manages certificates (keeps them renewed)
// such that we can get certificates during TLS handshakes to immediately serve
// to clients.
//
// TODO: This is an EXPERIMENTAL API. It is subject to change/removal.
type CertificateManager interface {
// GetCertificate returns the certificate to use to complete the handshake.
// Since this is called during every TLS handshake, it must be very fast and not block.
// Returning (nil, nil) is valid and is simply treated as a no-op.
GetCertificate(context.Context, *tls.ClientHelloInfo) (*tls.Certificate, error)
}

// KeyGenerator can generate a private key.
type KeyGenerator interface {
// GenerateKey generates a private key. The returned
Expand Down
19 changes: 14 additions & 5 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,21 @@ type Config struct {
// Adds the must staple TLS extension to the CSR.
MustStaple bool

// The source for getting new certificates; the
// default Issuer is ACMEManager. If multiple
// Sources for getting new, managed certificates;
// the default Issuer is ACMEManager. If multiple
// issuers are specified, they will be tried in
// turn until one succeeds.
Issuers []Issuer

// Sources for getting new, unmanaged certificates.
// They will be invoked only during TLS handshakes
// before on-demand certificate management occurs,
// for certificates that are not already loaded into
// the in-memory cache.
//
// TODO: EXPERIMENTAL: subject to change and/or removal.
Managers []CertificateManager

// The source of new private keys for certificates;
// the default KeySource is StandardKeyGenerator.
KeySource KeyGenerator
Expand Down Expand Up @@ -499,7 +508,7 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool
if err != nil {
return err
}
privKeyPEM, err = encodePrivateKey(privKey)
privKeyPEM, err = PEMEncodePrivateKey(privKey)
if err != nil {
return err
}
Expand Down Expand Up @@ -605,7 +614,7 @@ func (cfg *Config) reusePrivateKey(domain string) (privKey crypto.PrivateKey, pr
}

// we loaded a private key; try decoding it so we can use it
privKey, err = decodePrivateKey(privKeyPEM)
privKey, err = PEMDecodePrivateKey(privKeyPEM)
if err != nil {
return nil, nil, nil, err
}
Expand Down Expand Up @@ -722,7 +731,7 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv
zap.Duration("remaining", timeLeft))
}

privateKey, err := decodePrivateKey(certRes.PrivateKeyPEM)
privateKey, err := PEMDecodePrivateKey(certRes.PrivateKeyPEM)
if err != nil {
return err
}
Expand Down
16 changes: 10 additions & 6 deletions crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ import (
"golang.org/x/net/idna"
)

// encodePrivateKey marshals a EC or RSA private key into a PEM-encoded array of bytes.
func encodePrivateKey(key crypto.PrivateKey) ([]byte, error) {
// PEMEncodePrivateKey marshals a private key into a PEM-encoded block.
// The private key must be one of *ecdsa.PrivateKey, *rsa.PrivateKey, or
// *ed25519.PrivateKey.
func PEMEncodePrivateKey(key crypto.PrivateKey) ([]byte, error) {
var pemType string
var keyBytes []byte
switch key := key.(type) {
Expand Down Expand Up @@ -65,11 +67,13 @@ func encodePrivateKey(key crypto.PrivateKey) ([]byte, error) {
return pem.EncodeToMemory(&pemKey), nil
}

// decodePrivateKey loads a PEM-encoded ECC/RSA private key from an array of bytes.
// PEMDecodePrivateKey loads a PEM-encoded ECC/RSA private key from an array of bytes.
// Borrowed from Go standard library, to handle various private key and PEM block types.
// https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L291-L308
// https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L238)
func decodePrivateKey(keyPEMBytes []byte) (crypto.Signer, error) {
func PEMDecodePrivateKey(keyPEMBytes []byte) (crypto.Signer, error) {
// Modified from original:
// https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L291-L308
// https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L238

keyBlockDER, _ := pem.Decode(keyPEMBytes)

if keyBlockDER == nil {
Expand Down
10 changes: 5 additions & 5 deletions crypto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ func TestEncodeDecodeRSAPrivateKey(t *testing.T) {
}

// test save
savedBytes, err := encodePrivateKey(privateKey)
savedBytes, err := PEMEncodePrivateKey(privateKey)
if err != nil {
t.Fatal("error saving private key:", err)
}

// test load
loadedKey, err := decodePrivateKey(savedBytes)
loadedKey, err := PEMDecodePrivateKey(savedBytes)
if err != nil {
t.Error("error loading private key:", err)
}

// test load (should fail)
_, err = decodePrivateKey(savedBytes[2:])
_, err = PEMDecodePrivateKey(savedBytes[2:])
if err == nil {
t.Error("loading private key should have failed")
}
Expand All @@ -63,13 +63,13 @@ func TestSaveAndLoadECCPrivateKey(t *testing.T) {
}

// test save
savedBytes, err := encodePrivateKey(privateKey)
savedBytes, err := PEMEncodePrivateKey(privateKey)
if err != nil {
t.Fatal("error saving private key:", err)
}

// test load
loadedKey, err := decodePrivateKey(savedBytes)
loadedKey, err := PEMDecodePrivateKey(savedBytes)
if err != nil {
t.Error("error loading private key:", err)
}
Expand Down

0 comments on commit 797d29b

Please sign in to comment.