From a3a826572f82c356b1dad6eeae024589896bf5a9 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 20:17:24 -0600 Subject: [PATCH] Refactor letsencrypt code into its own package --- config/config.go | 9 +- config/letsencrypt/crypto.go | 43 +++++ config/{ => letsencrypt}/letsencrypt.go | 204 ++++++------------------ config/letsencrypt/storage.go | 128 +++++++++++++++ config/letsencrypt/user.go | 97 +++++++++++ main.go | 5 +- 6 files changed, 318 insertions(+), 168 deletions(-) create mode 100644 config/letsencrypt/crypto.go rename config/{ => letsencrypt}/letsencrypt.go (54%) create mode 100644 config/letsencrypt/storage.go create mode 100644 config/letsencrypt/user.go diff --git a/config/config.go b/config/config.go index ea955682..11759351 100644 --- a/config/config.go +++ b/config/config.go @@ -8,6 +8,7 @@ import ( "sync" "github.com/mholt/caddy/app" + "github.com/mholt/caddy/config/letsencrypt" "github.com/mholt/caddy/config/parse" "github.com/mholt/caddy/config/setup" "github.com/mholt/caddy/middleware" @@ -102,7 +103,7 @@ func Load(filename string, input io.Reader) (Group, error) { log.SetFlags(flags) // secure all the things - configs, err = initiateLetsEncrypt(configs) + configs, err = letsencrypt.Activate(configs) if err != nil { return nil, err } @@ -272,12 +273,6 @@ var ( // Site port Port = DefaultPort - - // Let's Encrypt account email - LetsEncryptEmail string - - // Agreement to Let's Encrypt terms - LetsEncryptAgree bool ) // Group maps network addresses to their configurations. diff --git a/config/letsencrypt/crypto.go b/config/letsencrypt/crypto.go new file mode 100644 index 00000000..5c84b4e4 --- /dev/null +++ b/config/letsencrypt/crypto.go @@ -0,0 +1,43 @@ +package letsencrypt + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "io/ioutil" + "os" +) + +// saveCertificate saves a DER-encoded (binary format) certificate +// to file. +func saveCertificate(certBytes []byte, file string) error { + pemCert := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} + certOut, err := os.Create(file) + if err != nil { + return err + } + pem.Encode(certOut, &pemCert) + certOut.Close() + return nil +} + +// loadRSAPrivateKey loads a PEM-encoded RSA private key from file. +func loadRSAPrivateKey(file string) (*rsa.PrivateKey, error) { + keyBytes, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + keyBlock, _ := pem.Decode(keyBytes) + return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) +} + +// saveRSAPrivateKey saves a PEM-encoded RSA private key to file. +func saveRSAPrivateKey(key *rsa.PrivateKey, file string) error { + pemKey := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + keyOut, err := os.Create(file) + if err != nil { + return err + } + defer keyOut.Close() + return pem.Encode(keyOut, &pemKey) +} diff --git a/config/letsencrypt.go b/config/letsencrypt/letsencrypt.go similarity index 54% rename from config/letsencrypt.go rename to config/letsencrypt/letsencrypt.go index 63135171..2782fc0d 100644 --- a/config/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -1,47 +1,26 @@ -package config - -// TODO: This code is a mess but I'm cleaning it up locally and -// refactoring a bunch. It will have tests, too. Don't worry. :) +package letsencrypt import ( "bufio" - "crypto/rand" - "crypto/rsa" - "crypto/x509" "encoding/json" - "encoding/pem" "errors" "fmt" "io/ioutil" "net/http" "os" - "path/filepath" "strings" - "github.com/mholt/caddy/app" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/middleware/redirect" "github.com/mholt/caddy/server" "github.com/xenolf/lego/acme" ) -// Some essential values related to the Let's Encrypt process -const ( - // Size of RSA keys in bits - rsaKeySize = 2048 - - // The base URL to the Let's Encrypt CA - caURL = "http://192.168.99.100:4000" - - // The port to expose to the CA server for Simple HTTP Challenge - exposePort = "5001" -) - -// initiateLetsEncrypt sets up TLS for each server config -// in configs as needed. It only skips the config if the -// cert and key are already specified or if plaintext http -// is explicitly specified as the port. -func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { +// Activate sets up TLS for each server config in configs +// as needed. It only skips the config if the cert and key +// are already provided or if plaintext http is explicitly +// specified as the port. +func Activate(configs []server.Config) ([]server.Config, error) { // populate map of email address to server configs that use that email address for TLS. // this will help us reduce roundtrips when getting the certs. initMap := make(map[string][]*server.Config) @@ -59,7 +38,7 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // than one certificate per email address, and still save them individually. for leEmail, serverConfigs := range initMap { // Look up or create the LE user account - leUser, err := getLetsEncryptUser(leEmail) + leUser, err := getUser(leEmail) if err != nil { return configs, err } @@ -79,11 +58,11 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // TODO: we can just do the agreement once, when registering, right? err = client.AgreeToTos() if err != nil { - saveLetsEncryptUser(leUser) // TODO: Might as well try, right? Error check? + saveUser(leUser) // TODO: Might as well try, right? Error check? return configs, errors.New("error agreeing to terms: " + err.Error()) } - err = saveLetsEncryptUser(leUser) + err = saveUser(leUser) if err != nil { return configs, errors.New("could not save user: " + err.Error()) } @@ -103,17 +82,16 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // ... that's it. save the certs, keys, and update server configs. for _, cert := range certificates { - certFolder := filepath.Join(app.DataFolder(), "letsencrypt", "sites", cert.Domain) - os.MkdirAll(certFolder, 0700) + os.MkdirAll(storage.Site(cert.Domain), 0700) // Save cert - err = saveCertificate(cert.Certificate, filepath.Join(certFolder, cert.Domain+".crt")) + err = saveCertificate(cert.Certificate, storage.SiteCertFile(cert.Domain)) if err != nil { return configs, err } // Save private key - err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".key"), cert.PrivateKey, 0600) + err = ioutil.WriteFile(storage.SiteKeyFile(cert.Domain), cert.PrivateKey, 0600) if err != nil { return configs, err } @@ -123,7 +101,7 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { if err != nil { return configs, err } - err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".json"), jsonBytes, 0600) + err = ioutil.WriteFile(storage.SiteMetaFile(cert.Domain), jsonBytes, 0600) if err != nil { return configs, err } @@ -131,8 +109,8 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // it all comes down to this: filling in the file path of a valid certificate automatically for _, cfg := range serverConfigs { - cfg.TLS.Certificate = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".crt") - cfg.TLS.Key = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".key") + cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) + cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) cfg.TLS.Enabled = true cfg.Port = "https" @@ -188,12 +166,12 @@ func getEmail(cfg server.Config) string { leEmail := cfg.TLS.LetsEncryptEmail if leEmail == "" { // Then try memory (command line flag or typed by user previously) - leEmail = LetsEncryptEmail + leEmail = DefaultEmail } if leEmail == "" { // Then try to get most recent user email ~/.caddy/users file // TODO: Probably better to open the user's json file and read the email out of there... - userDirs, err := ioutil.ReadDir(filepath.Join(app.DataFolder(), "letsencrypt", "users")) + userDirs, err := ioutil.ReadDir(storage.Users()) if err == nil { var mostRecent os.FileInfo for _, dir := range userDirs { @@ -204,7 +182,9 @@ func getEmail(cfg server.Config) string { mostRecent = dir } } - leEmail = mostRecent.Name() + if mostRecent != nil { + leEmail = mostRecent.Name() + } } } if leEmail == "" { @@ -216,135 +196,41 @@ func getEmail(cfg server.Config) string { if err != nil { return "" } - LetsEncryptEmail = leEmail + DefaultEmail = leEmail } return strings.TrimSpace(leEmail) } -func saveLetsEncryptUser(user LetsEncryptUser) error { - // make user account folder - userFolder := filepath.Join(app.DataFolder(), "letsencrypt", "users", user.Email) - err := os.MkdirAll(userFolder, 0700) - if err != nil { - return err - } +var ( + // Let's Encrypt account email to use if none provided + DefaultEmail string - // save private key file - user.KeyFile = filepath.Join(userFolder, emailUsername(user.Email)+".key") - err = savePrivateKey(user.key, user.KeyFile) - if err != nil { - return err - } + // Whether user has agreed to the Let's Encrypt SA + Agreed bool +) - // save registration file - jsonBytes, err := json.MarshalIndent(&user, "", "\t") - if err != nil { - return err - } +// Some essential values related to the Let's Encrypt process +const ( + // Size of RSA keys in bits + rsaKeySize = 2048 - return ioutil.WriteFile(filepath.Join(userFolder, "registration.json"), jsonBytes, 0600) -} + // The base URL to the Let's Encrypt CA + caURL = "http://192.168.99.100:4000" -func getLetsEncryptUser(email string) (LetsEncryptUser, error) { - var user LetsEncryptUser + // The port to expose to the CA server for Simple HTTP Challenge + exposePort = "5001" +) - userFolder := filepath.Join(app.DataFolder(), "letsencrypt", "users", email) - regFile, err := os.Open(filepath.Join(userFolder, "registration.json")) - if err != nil { - if os.IsNotExist(err) { - // create a new user - return newLetsEncryptUser(email) - } - return user, err - } +// KeySize represents the length of a key in bits +type KeySize int - err = json.NewDecoder(regFile).Decode(&user) - if err != nil { - return user, err - } - - user.key, err = loadPrivateKey(user.KeyFile) - if err != nil { - return user, err - } - - return user, nil -} - -func newLetsEncryptUser(email string) (LetsEncryptUser, error) { - user := LetsEncryptUser{Email: email} - privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) - if err != nil { - return user, errors.New("error generating private key: " + err.Error()) - } - user.key = privateKey - return user, nil -} - -func emailUsername(email string) string { - at := strings.Index(email, "@") - if at == -1 { - return email - } - return email[:at] -} - -type LetsEncryptUser struct { - Email string - Registration *acme.RegistrationResource - KeyFile string - key *rsa.PrivateKey -} - -func (u LetsEncryptUser) GetEmail() string { - return u.Email -} -func (u LetsEncryptUser) GetRegistration() *acme.RegistrationResource { - return u.Registration -} -func (u LetsEncryptUser) GetPrivateKey() *rsa.PrivateKey { - return u.key -} - -// savePrivateKey saves an RSA private key to file. -// -// Borrowed from Sebastian Erhart -// https://github.com/xenolf/lego/blob/34910bd541315993224af1f04f9b2877513e5477/crypto.go -func savePrivateKey(key *rsa.PrivateKey, file string) error { - pemKey := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} - keyOut, err := os.Create(file) - if err != nil { - return err - } - pem.Encode(keyOut, &pemKey) - keyOut.Close() - return nil -} - -// TODO: Check file permission -func saveCertificate(certBytes []byte, file string) error { - pemCert := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} - certOut, err := os.Create(file) - if err != nil { - return err - } - pem.Encode(certOut, &pemCert) - certOut.Close() - return nil -} - -// loadPrivateKey loads an RSA private key from filename. -// -// Borrowed from Sebastian Erhart -// https://github.com/xenolf/lego/blob/34910bd541315993224af1f04f9b2877513e5477/crypto.go -func loadPrivateKey(file string) (*rsa.PrivateKey, error) { - keyBytes, err := ioutil.ReadFile(file) - if err != nil { - return nil, err - } - keyBlock, _ := pem.Decode(keyBytes) - return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) -} +// Key sizes +const ( + ECC_224 KeySize = 224 + ECC_256 = 256 + RSA_2048 = 2048 + RSA_4096 = 4096 +) type CertificateMeta struct { Domain, URL string diff --git a/config/letsencrypt/storage.go b/config/letsencrypt/storage.go new file mode 100644 index 00000000..3f932b6d --- /dev/null +++ b/config/letsencrypt/storage.go @@ -0,0 +1,128 @@ +package letsencrypt + +import ( + "path/filepath" + "strings" + + "github.com/mholt/caddy/app" +) + +// storage is used to get file paths in a consistent, +// cross-platform way for persisting Let's Encrypt assets +// on the file system. +var storage = Storage(filepath.Join(app.DataFolder(), "letsencrypt")) + +// Storage is a root directory and facilitates +// forming file paths derived from it. +type Storage string + +func (s Storage) Path(parts ...string) string { + return filepath.Join(append([]string{string(s)}, parts...)...) +} + +// Sites gets the directory that stores site certificate and keys. +func (s Storage) Sites() string { + return filepath.Join(string(s), "sites") +} + +// Site returns the path to the folder containing assets for domain. +func (s Storage) Site(domain string) string { + return filepath.Join(s.Sites(), domain) +} + +// CertFile returns the path to the certificate file for domain. +func (s Storage) SiteCertFile(domain string) string { + return filepath.Join(s.Site(domain), domain+".crt") +} + +// SiteKeyFile returns the path to domain's private key file. +func (s Storage) SiteKeyFile(domain string) string { + return filepath.Join(s.Site(domain), domain+".key") +} + +// SiteMetaFile returns the path to the domain's asset metadata file. +func (s Storage) SiteMetaFile(domain string) string { + return filepath.Join(s.Site(domain), domain+".json") +} + +// Users gets the directory that stores account folders. +func (s Storage) Users() string { + return filepath.Join(string(s), "users") +} + +// User gets the account folder for the user with email. +func (s Storage) User(email string) string { + return filepath.Join(s.Users(), email) +} + +// UserRegFile gets the path to the registration file for +// the user with the given email address. +func (s Storage) UserRegFile(email string) string { + fileName := emailUsername(email) + if fileName == "" { + fileName = "registration" + } + return filepath.Join(s.User(email), fileName+".json") +} + +// UserKeyFile gets the path to the private key file for +// the user with the given email address. +func (s Storage) UserKeyFile(email string) string { + // TODO: Read the KeyFile property in the registration file instead? + fileName := emailUsername(email) + if fileName == "" { + fileName = "private" + } + return filepath.Join(s.User(email), fileName+".key") +} + +// emailUsername returns the username portion of an +// email address (part before '@') or the original +// input if it can't find the "@" symbol. +func emailUsername(email string) string { + at := strings.Index(email, "@") + if at == -1 { + return email + } + return email[:at] +} + +/* +// StorageDir is the full path to the folder where this Let's +// Encrypt client will set up camp. In other words, where it +// stores user account information, keys, and certificates. +// All files will be contained in a 'letsencrypt' folder +// within StorageDir. +// +// Changing this after the program has accessed this folder +// will result in undefined behavior. +var StorageDir = "." + +// Values related to persisting things on the file system +const ( + // ContainerDir is the name of the folder within StorageDir + // in which files or folders are placed. + ContainerDir = "letsencrypt" + + // File that contains information about the user's LE account + UserRegistrationFile = "registration.json" +) + +// BaseDir returns the full path to the base directory in which +// files or folders may be placed, e.g. "/letsencrypt". +func BaseDir() string { + return filepath.Join(StorageDir, ContainerDir) +} + +// AccountsDir returns the full path to the directory where account +// information is stored for LE users. +func AccountsDir() string { + return filepath.Join(BaseDir(), "users") +} + +// AccountsDir gets the full path to the directory for a certain +// user with the email address email. +func AccountDir(email string) string { + return filepath.Join(AccountsDir(), email) +} +*/ diff --git a/config/letsencrypt/user.go b/config/letsencrypt/user.go new file mode 100644 index 00000000..446bb677 --- /dev/null +++ b/config/letsencrypt/user.go @@ -0,0 +1,97 @@ +package letsencrypt + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "io/ioutil" + "os" + + "github.com/xenolf/lego/acme" +) + +type User struct { + Email string + Registration *acme.RegistrationResource + KeyFile string + key *rsa.PrivateKey +} + +func (u User) GetEmail() string { + return u.Email +} +func (u User) GetRegistration() *acme.RegistrationResource { + return u.Registration +} +func (u User) GetPrivateKey() *rsa.PrivateKey { + return u.key +} + +// getUser loads the user with the given email from disk. +func getUser(email string) (User, error) { + var user User + + // open user file + regFile, err := os.Open(storage.UserRegFile(email)) + if err != nil { + if os.IsNotExist(err) { + // create a new user + return newUser(email) + } + return user, err + } + defer regFile.Close() + + // load user information + err = json.NewDecoder(regFile).Decode(&user) + if err != nil { + return user, err + } + + // load their private key + user.key, err = loadRSAPrivateKey(user.KeyFile) + if err != nil { + return user, err + } + + return user, nil +} + +// saveUser persists a user's key and account registration +// to the file system. +func saveUser(user User) error { + // make user account folder + err := os.MkdirAll(storage.User(user.Email), 0700) + if err != nil { + return err + } + + // save private key file + user.KeyFile = storage.UserKeyFile(user.Email) + err = saveRSAPrivateKey(user.key, user.KeyFile) + if err != nil { + return err + } + + // save registration file + jsonBytes, err := json.MarshalIndent(&user, "", "\t") + if err != nil { + return err + } + + return ioutil.WriteFile(storage.UserRegFile(user.Email), jsonBytes, 0600) +} + +// newUser creates a new User for the given email address +// with a new private key. This function does not register +// the user via ACME. +func newUser(email string) (User, error) { + user := User{Email: email} + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) + if err != nil { + return user, errors.New("error generating private key: " + err.Error()) + } + user.key = privateKey + return user, nil +} diff --git a/main.go b/main.go index 5405abe9..d194aef1 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/mholt/caddy/app" "github.com/mholt/caddy/config" + "github.com/mholt/caddy/config/letsencrypt" "github.com/mholt/caddy/server" ) @@ -33,8 +34,8 @@ func init() { flag.StringVar(&config.Host, "host", config.DefaultHost, "Default host") flag.StringVar(&config.Port, "port", config.DefaultPort, "Default port") flag.BoolVar(&version, "version", false, "Show version") - flag.BoolVar(&config.LetsEncryptAgree, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") - flag.StringVar(&config.LetsEncryptEmail, "email", "", "Email address to use for Let's Encrypt account") + flag.BoolVar(&letsencrypt.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") + flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default email address to use for Let's Encrypt transactions") } func main() {