From 2e8c4a758f3c816d6710fc499ce9ef60e60de1e2 Mon Sep 17 00:00:00 2001 From: Craig Peterson <192540+captncraig@users.noreply.github.com> Date: Thu, 26 Apr 2018 13:11:13 -0400 Subject: [PATCH] Let's Encrypt Certificate Generation (#327) * Manual rebase of get-certs branch * fix endpoints, add verbose flag * more stable pre-check behaviour * start of docs * docs for get-certs * don't require cert for dnscontrol * fix up directory paths * small doc tweaks --- commands/getCerts.go | 184 +++++++++++++++++++++++++ docs/lets-encrypt.md | 128 +++++++++++++++++ models/dns.go | 20 +++ pkg/acme/acme.go | 290 +++++++++++++++++++++++++++++++++++++++ pkg/acme/checkDns.go | 31 +++++ pkg/acme/registration.go | 121 ++++++++++++++++ 6 files changed, 774 insertions(+) create mode 100644 commands/getCerts.go create mode 100644 docs/lets-encrypt.md create mode 100644 pkg/acme/acme.go create mode 100644 pkg/acme/checkDns.go create mode 100644 pkg/acme/registration.go diff --git a/commands/getCerts.go b/commands/getCerts.go new file mode 100644 index 000000000..9de4de552 --- /dev/null +++ b/commands/getCerts.go @@ -0,0 +1,184 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + + "github.com/StackExchange/dnscontrol/models" + "github.com/StackExchange/dnscontrol/pkg/acme" + "github.com/StackExchange/dnscontrol/pkg/normalize" + "github.com/urfave/cli" +) + +var _ = cmd(catUtils, func() *cli.Command { + var args GetCertsArgs + return &cli.Command{ + Name: "get-certs", + Usage: "Issue certificates via Let's Encrypt", + Action: func(c *cli.Context) error { + return exit(GetCerts(args)) + }, + Flags: args.flags(), + } +}()) + +type GetCertsArgs struct { + GetDNSConfigArgs + GetCredentialsArgs + + ACMEServer string + CertsFile string + RenewUnderDays int + CertDirectory string + Email string + AgreeTOS bool + Verbose bool + + IgnoredProviders string +} + +func (args *GetCertsArgs) flags() []cli.Flag { + flags := args.GetDNSConfigArgs.flags() + flags = append(flags, args.GetCredentialsArgs.flags()...) + + flags = append(flags, cli.StringFlag{ + Name: "acme", + Destination: &args.ACMEServer, + Value: "live", + Usage: `ACME server to issue against. Give full directory endpoint. Can also use 'staging' or 'live' for standard Let's Encrpyt endpoints.`, + }) + flags = append(flags, cli.IntFlag{ + Name: "renew", + Destination: &args.RenewUnderDays, + Value: 15, + Usage: `Renew certs with less than this many days remaining`, + }) + flags = append(flags, cli.StringFlag{ + Name: "dir", + Destination: &args.CertDirectory, + Value: ".", + Usage: `Directory to store certificates and other data`, + }) + flags = append(flags, cli.StringFlag{ + Name: "certConfig", + Destination: &args.CertsFile, + Value: "certs.json", + Usage: `Json file containing list of certificates to issue`, + }) + flags = append(flags, cli.StringFlag{ + Name: "email", + Destination: &args.Email, + Value: "", + Usage: `Email to register with let's encrypt`, + }) + flags = append(flags, cli.BoolFlag{ + Name: "agreeTOS", + Destination: &args.AgreeTOS, + Usage: `Must provide this to agree to Let's Encrypt terms of service`, + }) + flags = append(flags, cli.StringFlag{ + Name: "skip", + Destination: &args.IgnoredProviders, + Value: "", + Usage: `Provider names to not use for challenges (comma separated)`, + }) + flags = append(flags, cli.BoolFlag{ + Name: "verbose", + Destination: &args.Verbose, + Usage: "Enable detailed logging from acme library", + }) + return flags +} + +func GetCerts(args GetCertsArgs) error { + fmt.Println(args.JSFile) + // check agree flag + if !args.AgreeTOS { + return fmt.Errorf("You must agree to the Let's Encrypt Terms of Service by using -agreeTOS") + } + if args.Email == "" { + return fmt.Errorf("Must provide email to use for Let's Encrypt registration") + } + + // load dns config + cfg, err := GetDNSConfig(args.GetDNSConfigArgs) + if err != nil { + return err + } + errs := normalize.NormalizeAndValidateConfig(cfg) + if PrintValidationErrors(errs) { + return fmt.Errorf("Exiting due to validation errors") + } + _, err = InitializeProviders(args.CredsFile, cfg, false) + if err != nil { + return err + } + + for _, skip := range strings.Split(args.IgnoredProviders, ",") { + acme.IgnoredProviders[skip] = true + } + + // load cert list + certList := []*acme.CertConfig{} + f, err := os.Open(args.CertsFile) + if err != nil { + return err + } + defer f.Close() + dec := json.NewDecoder(f) + err = dec.Decode(&certList) + if err != nil { + return err + } + if len(certList) == 0 { + return fmt.Errorf("Must provide at least one certificate to issue in cert configuration") + } + if err = validateCertificateList(certList, cfg); err != nil { + return err + } + acmeServer := args.ACMEServer + if acmeServer == "live" { + acmeServer = acme.LetsEncryptLive + } else if acmeServer == "staging" { + acmeServer = acme.LetsEncryptStage + } + client, err := acme.New(cfg, args.CertDirectory, args.Email, acmeServer) + if err != nil { + return err + } + for _, cert := range certList { + _, err := client.IssueOrRenewCert(cert, args.RenewUnderDays, args.Verbose) + if err != nil { + return err + } + } + return nil +} + +var validCertNamesRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_\-]*$`) + +func validateCertificateList(certs []*acme.CertConfig, cfg *models.DNSConfig) error { + for _, cert := range certs { + name := cert.CertName + if !validCertNamesRegex.MatchString(name) { + return fmt.Errorf("'%s' is not a valud certificate name. Only alphanumerics, - and _ allowed", name) + } + sans := cert.Names + if len(sans) > 100 { + return fmt.Errorf("certificate '%s' has too many SANs. Max of 100", name) + } + if len(sans) == 0 { + return fmt.Errorf("certificate '%s' needs at least one SAN", name) + } + for _, san := range sans { + d := cfg.DomainContainingFQDN(san) + if d == nil { + return fmt.Errorf("DNS config has no domain that matches SAN '%s'", san) + } + } + } + return nil +} diff --git a/docs/lets-encrypt.md b/docs/lets-encrypt.md new file mode 100644 index 000000000..9918b682c --- /dev/null +++ b/docs/lets-encrypt.md @@ -0,0 +1,128 @@ +--- +layout: default +title: Let's Encrypt Certificate generation +--- + +# *Let's Encrypt* Certificate generation + +The `dnscontrol get-certs` command will obtain or renew TLS certificates for your managed domains via [*Let's Enrypt*](https://letsencrypt.org). This can be extremely useful in situations where other acme clients are problematic. Specifically, this may be useful if: + +- You are already managing dns records with dnscontrol. +- You have a large number of domains or dns providers in complicated configurations. +- You want **wildcard** certificates, which *require* dns validation. + +At stack overflow we have dual-hosted dns, with most domains having four nameservers from two different cloud DNS providers. DnsControl uses +the exact same code as the core DnsControl commands to issue certificates. This means is will work the same regardless of your domain layout or what providers you use. + +## General Process + +The `get-certs` command does the following steps: + +1. Determine which certificates you would like issued, and which names should belong to each one. +1. Look for existing certs on disk, and see if they have sufficient time remaining until expiration, and that the names match. +1. If updates are needed: + 1. Request a new certificate from the acme server. + 1. Receive a list of validations to fill. + 1. For each validation (usually one per name on the cert): + 1. Create a TXT record on the domain with a given secret value. + 1. Wait until the authoritative name servers all return the correct value (polls locally). + 1. Tell the acme server to validate the record. + 1. Receive a new certificate and save it to disk + +Because DNS propagation times vary from provider to provider, and validations are (currently) done serially, this process may take some time. + +## certs.json + +This file should be provided to specify which names you would like to get certificates for. You can +specify any number of certificates, with up to 100 SAN entries each. Subject names can contain wildcards if you wish. + +The format of the file is a simple json array of objects: + +``` +[ + { + "cert_name": "mainCert", + "names": [ + "example.com.com", + "www.example.com" + ] + }, + { + "cert_name": "wildcardCert", + "names": [ + "example.com", + "*.example.com", + "*.foo.example.com", + "otherdomain.tld", + "*.otherdomain.tld" + ] + } +] +``` + +`get-certs` will attempt to issue any certificates referenced by this file, and will renew or re-issue if the certificate we already have is +close to expiry or if the set of subject names changes for a cert. + +## Working directory layout +The `get-certs` command is designed to be run from a working directory that contains all of the data we need, +and stores all of the certificates and other data we generate. + +You may store this directory in source control or wherever you like. At Stack Overflow we have a dedicated repository for +certificates, but we take care to always encrypt any private keys with [black box](https://github.com/StackExchange/blackbox) before committing. + +The working directory should generally contain: + +- `certificates` folder for storing all obtained certificates. +- `.letsencrypt` folder for storing *Let's Encrypt* account keys, registrations, and other metadata. +- `certs.json` to describe what certificates to issue. +- `dnsconfig.js` and `creds.json` are the main files for other dnscontrol commands. + +``` +┏━━.letsencrypt +┃ ┗━(*Let's Encrypt* account keys and metadata) +┃ +┣━━certificates +┃ ┣━━mainCert +┃ ┃ ┣━mainCert.crt +┃ ┃ ┣━mainCert.json +┃ ┃ ┗━mainCert.key +┃ ┗━━wildcardCert +┃ ┣━wildcardCert.crt +┃ ┣━wildcardCert.json +┃ ┗━wildcardCert.key +┃ +┣━━certs.json +┣━━creds.json +┗━━dnsconfig.js +``` +## Command line flags + +### Required Flags + +- `-email test@example.com`: Email address to use for *Let's Encrypt* account registration. +- `--agreeTOS`: Indicates that you agree to the [*Let's Encrypt* Subscriber Agreement](https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf) + +### Optional Flags + +- `-acme {url}`: URL of the acme server you wish to use. For *Let's Encrypt* you can use the presets `live` or `staging` for the standard services. If you are using a custom boulder instance or other acme server, you may specify the full **directory** url. Must be an acme **v2** server. +- `-renew {n}`: `get-certs` will renew certs with less than this many **days** remaining. The default is 15, and certs will be renewed when they are within 15 days of expiration. +- `-dir {d}`: Root directory holding all certificate and account data as described above. Default is current woriking directory. +- `-certConfig {j}`: Location of certificate config json file as described above. Default is `./certs.json` +- `-skip {p}`: DNS Provider names (comma separated) to skip using as challenge providers. We use this to avoid uneccessary changes to our backup or internal dns providers that wouldn't be a part of the validation flow. +- `--verbose`: enable some extra logging from the [acme library](https://github.com/xenolf/lego) that we use. +- `-js {dnsconfig.js}`, `-creds {creds.json}` and other flags to find your dns configuration are the same as used for `dnscontrol preview` or `push`. `get-certs` needs to read the dns config so it knows which providers manage which domains, and so it can make sure it is not going to make any destructive changes to your domains. If the `get-certs` command needs to fill a challenge on a domain that has pending corrections, it will abort for safety. You can run `dnscontrol preview` and `dnscontrol push` at that point to cerify and push the pending corrrections, and then proceed with issuing certificates. + +## Workflow + +This command is intended to be just a small part of a full certificate automation workflow. It only issues certificates, and explicitly does not deal with certificate storage or deployment. We urge caution to secure your private keys for your certificates, as well as the *Let's Encrypt* account private key. We use [black box](https://github.com/StackExchange/blackbox) to securely store private keys in the certificate repo. + +This command is intended to be run as frequently as you desire. One workflow would be to check all certificates into a git repository and run a nightly build that: + +1. Clones the cert repo, and the dns config repo (if seperate). +2. Decrypt or otherwise obtain the *Let's Encrypt* account private key. Dnscontrol does not need to read any certificate private keys to check or issue certificates. +3. Run `dnscontrol get-certs` with appropriate flags. +4. Encrypt or store any new or updated private keys. +5. Commit and push any changes to the cert repo. +6. Take care to not leave any plain-text private keys on disk. + +The push to the certificate repo can trigger further automation to deploy certs to load balancers, cdns, applications and so forth. \ No newline at end of file diff --git a/models/dns.go b/models/dns.go index a104662e1..331157e6e 100644 --- a/models/dns.go +++ b/models/dns.go @@ -2,6 +2,7 @@ package models import ( "encoding/json" + "strings" ) // DefaultTTL is applied to any DNS record without an explicit TTL. @@ -45,6 +46,10 @@ type Nameserver struct { Name string `json:"name"` // Normalized to a FQDN with NO trailing "." } +func (n *Nameserver) String() string { + return n.Name +} + // StringsToNameservers constructs a list of *Nameserver structs using a list of FQDNs. func StringsToNameservers(nss []string) []*Nameserver { nservers := []*Nameserver{} @@ -59,3 +64,18 @@ type Correction struct { F func() error `json:"-"` Msg string } + +// DomainContainingFQDN finds the best domain from the dns config for the given record fqdn. +// It will chose the domain whose name is the longest suffix match for the fqdn. +func (config *DNSConfig) DomainContainingFQDN(fqdn string) *DomainConfig { + fqdn = strings.TrimSuffix(fqdn, ".") + longestLength := 0 + var d *DomainConfig + for _, dom := range config.Domains { + if (dom.Name == fqdn || strings.HasSuffix(fqdn, "."+dom.Name)) && len(dom.Name) > longestLength { + longestLength = len(dom.Name) + d = dom + } + } + return d +} diff --git a/pkg/acme/acme.go b/pkg/acme/acme.go new file mode 100644 index 000000000..21724dded --- /dev/null +++ b/pkg/acme/acme.go @@ -0,0 +1,290 @@ +// Package acme provides a means of performing Let's Encrypt DNS challenges via a DNSConfig +package acme + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" + "log" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/StackExchange/dnscontrol/models" + "github.com/StackExchange/dnscontrol/pkg/nameservers" + "github.com/xenolf/lego/acmev2" +) + +type CertConfig struct { + CertName string `json:"cert_name"` + Names []string `json:"names"` +} + +type Client interface { + IssueOrRenewCert(config *CertConfig, renewUnder int, verbose bool) (bool, error) +} + +type certManager struct { + directory string + email string + acmeDirectory string + acmeHost string + cfg *models.DNSConfig + checkedDomains map[string]bool + + account *account + client *acme.Client +} + +const ( + LetsEncryptLive = "https://acme-v02.api.letsencrypt.org/directory" + LetsEncryptStage = "https://acme-staging-v02.api.letsencrypt.org/directory" +) + +func New(cfg *models.DNSConfig, directory string, email string, server string) (Client, error) { + u, err := url.Parse(server) + if err != nil || u.Host == "" { + return nil, fmt.Errorf("ACME directory '%s' is not a valid URL", server) + } + c := &certManager{ + directory: directory, + email: email, + acmeDirectory: server, + acmeHost: u.Host, + cfg: cfg, + checkedDomains: map[string]bool{}, + } + + if err := c.loadOrCreateAccount(); err != nil { + return nil, err + } + c.client.ExcludeChallenges([]acme.Challenge{acme.HTTP01}) + c.client.SetChallengeProvider(acme.DNS01, c) + return c, nil +} + +// IssueOrRenewCert will obtain a certificate with the given name if it does not exist, +// or renew it if it is close enough to the expiration date. +// It will return true if it issued or updated the certificate. +func (c *certManager) IssueOrRenewCert(cfg *CertConfig, renewUnder int, verbose bool) (bool, error) { + if !verbose { + acme.Logger = log.New(ioutil.Discard, "", 0) + } + + log.Printf("Checking certificate [%s]", cfg.CertName) + if err := os.MkdirAll(filepath.Dir(c.certFile(cfg.CertName, "json")), perms); err != nil { + return false, err + } + existing, err := c.readCertificate(cfg.CertName) + if err != nil { + return false, err + } + + var action = func() (acme.CertificateResource, error) { + return c.client.ObtainCertificate(cfg.Names, true, nil, true) + } + + if existing == nil { + log.Println("No existing cert found. Issuing new...") + } else { + names, daysLeft, err := getCertInfo(existing.Certificate) + if err != nil { + return false, err + } + log.Printf("Found existing cert. %0.2f days remaining.", daysLeft) + namesOK := dnsNamesEqual(cfg.Names, names) + if daysLeft >= float64(renewUnder) && namesOK { + log.Println("Nothing to do") + //nothing to do + return false, nil + } + if !namesOK { + log.Println("DNS Names don't match expected set. Reissuing.") + } else { + log.Println("Renewing cert") + action = func() (acme.CertificateResource, error) { + return c.client.RenewCertificate(*existing, true, true) + } + } + } + + certResource, err := action() + if err != nil { + return false, err + } + fmt.Printf("Obtained certificate for %s\n", cfg.CertName) + return true, c.writeCertificate(cfg.CertName, &certResource) +} + +// filename for certifiacte / key / json file +func (c *certManager) certFile(name, ext string) string { + return filepath.Join(c.directory, "certificates", name, name+"."+ext) +} + +func (c *certManager) writeCertificate(name string, cr *acme.CertificateResource) error { + jDAt, err := json.MarshalIndent(cr, "", " ") + if err != nil { + return err + } + if err = ioutil.WriteFile(c.certFile(name, "json"), jDAt, perms); err != nil { + return err + } + if err = ioutil.WriteFile(c.certFile(name, "crt"), cr.Certificate, perms); err != nil { + return err + } + return ioutil.WriteFile(c.certFile(name, "key"), cr.PrivateKey, perms) +} + +func getCertInfo(pemBytes []byte) (names []string, remaining float64, err error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, 0, fmt.Errorf("Invalid certificate pem data") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, 0, err + } + var daysLeft = float64(cert.NotAfter.Sub(time.Now())) / float64(time.Hour*24) + return cert.DNSNames, daysLeft, nil +} + +// checks two lists of sans to make sure they have all the same names in them. +func dnsNamesEqual(a []string, b []string) bool { + if len(a) != len(b) { + return false + } + sort.Strings(a) + sort.Strings(b) + for i, s := range a { + if b[i] != s { + return false + } + } + return true +} + +func (c *certManager) readCertificate(name string) (*acme.CertificateResource, error) { + f, err := os.Open(c.certFile(name, "json")) + if err != nil && os.IsNotExist(err) { + // if json does not exist, nothing does + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + dec := json.NewDecoder(f) + cr := &acme.CertificateResource{} + if err = dec.Decode(cr); err != nil { + return nil, err + } + // load cert + crtBytes, err := ioutil.ReadFile(c.certFile(name, "crt")) + if err != nil { + return nil, err + } + cr.Certificate = crtBytes + return cr, nil +} + +func (c *certManager) Present(domain, token, keyAuth string) (e error) { + d := c.cfg.DomainContainingFQDN(domain) + // fix NS records for this domain's DNS providers + // only need to do this once per domain + const metaKey = "x-fixed-nameservers" + if d.Metadata[metaKey] == "" { + nsList, err := nameservers.DetermineNameservers(d) + if err != nil { + return err + } + d.Nameservers = nsList + nameservers.AddNSRecords(d) + d.Metadata[metaKey] = "true" + } + // copy now so we can add txt record safely, and just run unmodified version later to cleanup + d, err := d.Copy() + if err != nil { + return err + } + if err := c.ensureNoPendingCorrections(d); err != nil { + return err + } + fqdn, val, _ := acme.DNS01Record(domain, keyAuth) + fmt.Println(fqdn, val) + txt := &models.RecordConfig{Type: "TXT"} + txt.SetTargetTXT(val) + txt.SetLabelFromFQDN(fqdn, d.Name) + d.Records = append(d.Records, txt) + + return getAndRunCorrections(d) +} + +func (c *certManager) ensureNoPendingCorrections(d *models.DomainConfig) error { + // only need to check a domain once per app run + if c.checkedDomains[d.Name] { + return nil + } + corrections, err := getCorrections(d) + if err != nil { + return err + } + if len(corrections) != 0 { + // TODO: maybe allow forcing through this check. + for _, c := range corrections { + fmt.Println(c.Msg) + } + return fmt.Errorf("Found %d pending corrections for %s. Not going to proceed issuing certificates", len(corrections), d.Name) + } + return nil +} + +// IgnoredProviders is a lit of provider names that should not be used to fill challenges. +var IgnoredProviders = map[string]bool{} + +func getCorrections(d *models.DomainConfig) ([]*models.Correction, error) { + cs := []*models.Correction{} + for _, p := range d.DNSProviderInstances { + if IgnoredProviders[p.Name] { + continue + } + dc, err := d.Copy() + if err != nil { + return nil, err + } + corrections, err := p.Driver.GetDomainCorrections(dc) + if err != nil { + return nil, err + } + for _, c := range corrections { + c.Msg = fmt.Sprintf("[%s] %s", p.Name, strings.TrimSpace(c.Msg)) + } + cs = append(cs, corrections...) + } + return cs, nil +} + +func getAndRunCorrections(d *models.DomainConfig) error { + cs, err := getCorrections(d) + if err != nil { + return err + } + fmt.Printf("%d corrections\n", len(cs)) + for _, c := range cs { + fmt.Printf("Running [%s]\n", c.Msg) + err = c.F() + if err != nil { + return err + } + } + return nil +} + +func (c *certManager) CleanUp(domain, token, keyAuth string) error { + d := c.cfg.DomainContainingFQDN(domain) + return getAndRunCorrections(d) +} diff --git a/pkg/acme/checkDns.go b/pkg/acme/checkDns.go new file mode 100644 index 000000000..c3e0ac2f7 --- /dev/null +++ b/pkg/acme/checkDns.go @@ -0,0 +1,31 @@ +package acme + +import ( + "log" + "time" + + "github.com/xenolf/lego/acmev2" +) + +func init() { + // default record verification in the client library makes sure the authoritative nameservers + // have the expected records. + // Sometimes the Let's Encrypt verification fails anyway because records have not propagated the provider's network fully. + // So we add an additional 20 second sleep just for safety. + origCheck := acme.PreCheckDNS + acme.PreCheckDNS = func(fqdn, value string) (bool, error) { + start := time.Now() + v, err := origCheck(fqdn, value) + if err != nil { + return v, err + } + log.Printf("DNS ok after %s. Waiting again for propagation", time.Now().Sub(start)) + time.Sleep(20 * time.Second) + return v, err + } +} + +// Timeout increases the client-side polling check time to five minutes with one second waits in-between. +func (c *certManager) Timeout() (timeout, interval time.Duration) { + return 5 * time.Minute, time.Second +} diff --git a/pkg/acme/registration.go b/pkg/acme/registration.go new file mode 100644 index 000000000..08a41c462 --- /dev/null +++ b/pkg/acme/registration.go @@ -0,0 +1,121 @@ +package acme + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io/ioutil" + "log" + "os" + "path/filepath" + + "github.com/xenolf/lego/acmev2" +) + +func (c *certManager) loadOrCreateAccount() error { + f, err := os.Open(c.accountFile()) + if err != nil && os.IsNotExist(err) { + return c.createAccount() + } + if err != nil { + return err + } + defer f.Close() + dec := json.NewDecoder(f) + acct := &account{} + if err = dec.Decode(acct); err != nil { + return err + } + c.account = acct + keyBytes, err := ioutil.ReadFile(c.accountKeyFile()) + if err != nil { + return err + } + keyBlock, _ := pem.Decode(keyBytes) + if keyBlock == nil { + log.Fatal("WTF", keyBytes) + } + c.account.key, err = x509.ParseECPrivateKey(keyBlock.Bytes) + if err != nil { + return err + } + c.client, err = acme.NewClient(c.acmeDirectory, c.account, acme.RSA2048) // TODO: possibly make configurable on a cert-by cert basis + if err != nil { + return err + } + return nil +} + +func (c *certManager) accountDirectory() string { + return filepath.Join(c.directory, ".letsencrypt", c.acmeHost) +} + +func (c *certManager) accountFile() string { + return filepath.Join(c.accountDirectory(), "account.json") +} +func (c *certManager) accountKeyFile() string { + return filepath.Join(c.accountDirectory(), "account.key") +} + +const perms os.FileMode = 0644 // TODO: probably lock this down more + +func (c *certManager) createAccount() error { + if err := os.MkdirAll(c.accountDirectory(), perms); err != nil { + return err + } + privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return err + } + acct := &account{ + key: privateKey, + Email: c.email, + } + c.account = acct + c.client, err = acme.NewClient(c.acmeDirectory, c.account, acme.EC384) + if err != nil { + return err + } + reg, err := c.client.Register(true) + if err != nil { + return err + } + c.account.Registration = reg + acctBytes, err := json.MarshalIndent(c.account, "", " ") + if err != nil { + return err + } + if err = ioutil.WriteFile(c.accountFile(), acctBytes, perms); err != nil { + return err + } + keyBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return err + } + pemKey := &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes} + pemBytes := pem.EncodeToMemory(pemKey) + if err = ioutil.WriteFile(c.accountKeyFile(), pemBytes, perms); err != nil { + return err + } + return nil +} + +type account struct { + Email string `json:"email"` + key crypto.PrivateKey + Registration *acme.RegistrationResource `json:"registration"` +} + +func (a *account) GetEmail() string { + return a.Email +} +func (a *account) GetPrivateKey() crypto.PrivateKey { + return a.key +} +func (a *account) GetRegistration() *acme.RegistrationResource { + return a.Registration +}