diff --git a/cmd/step/main.go b/cmd/step/main.go index 0183b12e..95382385 100644 --- a/cmd/step/main.go +++ b/cmd/step/main.go @@ -3,10 +3,12 @@ package main import ( "fmt" "log" + "math/rand" "net/http" "os" "reflect" "regexp" + "time" "github.com/urfave/cli" @@ -36,6 +38,7 @@ var BuildTime = "N/A" func init() { config.Set(Version, BuildTime) + rand.Seed(time.Now().UnixNano()) } func main() { diff --git a/command/ca/certificate.go b/command/ca/certificate.go index 10ae2b24..5f708f42 100644 --- a/command/ca/certificate.go +++ b/command/ca/certificate.go @@ -1,15 +1,10 @@ package ca import ( - "crypto/tls" "crypto/x509" "encoding/pem" - "fmt" - "math/rand" - "net/http" "os" "strings" - "time" "github.com/pkg/errors" "github.com/smallstep/certificates/api" @@ -112,79 +107,6 @@ $ step ca sign --token $TOKEN --not-after=1h internal.csr internal.crt } } -func renewCertificateCommand() cli.Command { - return cli.Command{ - Name: "renew", - Action: command.ActionFunc(renewCertificateAction), - Usage: "renew a valid certificate", - UsageText: `**step ca renew** - [**--ca-url**=] [**--root**=] - [**--out**=] [**--expires-in**=] [**--force**]`, - Description: ` -**step ca renew** command renews the given certificates on the certificate -authority and writes the new certificate to disk either overwriting -or using a new file if the **--out**= flag is used. - -## POSITIONAL ARGUMENTS - - -: The certificate in PEM format that we want to renew. - - -: They key file of the certificate. - -## EXAMPLES - -Renew a certificate with the configured CA: -''' -$ step ca renew internal.crt internal.key -Would you like to overwrite internal.crt [Y/n]: y -''' - -Renew a certificate without overwriting the previous certificate: -''' -$ step ca renew --out renewed.crt internal.crt internal.key -''' - -Renew a certificate forcing the overwrite of the previous certificate: -''' -$ step ca renew --force internal.crt internal.key -''' - -Renew a certificate providing the <--ca-url> and <--root> flags: -''' -$ step ca renew --ca-url https://ca.smallstep.com:9000 \ - --root /path/to/root_ca.crt internal.crt internal.key -Would you like to overwrite internal.crt [Y/n]: y -''' - -Renew skipped because it was too early: -''' -$ step ca renew --expires-in 8h internal.crt internal.key -certificate not renewed: expires in 10h52m5s -'''`, - Flags: []cli.Flag{ - caURLFlag, - rootFlag, - cli.StringFlag{ - Name: "out,output-file", - Usage: "The new certificate path. Defaults to overwriting the positional argument", - }, - cli.StringFlag{ - Name: "expires-in", - Usage: `The check that will be performed before renewing the certificate. The -certificate renew will be skipped if the time to expiration is greater than the -passed one. A random jitter (duration/20) will be added to avoid multiple -services hitting the renew endpoint at the same time. The is a -sequence of decimal numbers, each with optional fraction and a unit suffix, such -as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", -"s", "m", "h".`, - }, - flags.Force, - }, - } -} - func newCertificateAction(ctx *cli.Context) error { if err := errs.NumberOfArguments(ctx, 3); err != nil { return err @@ -378,104 +300,3 @@ func signCertificateRequest(ctx *cli.Context, token string, csr api.CertificateR data := append(pem.EncodeToMemory(serverBlock), pem.EncodeToMemory(caBlock)...) return utils.WriteFile(crtFile, data, 0600) } - -func renewCertificateAction(ctx *cli.Context) error { - if err := errs.NumberOfArguments(ctx, 2); err != nil { - return err - } - - args := ctx.Args() - crtFile := args.Get(0) - keyFile := args.Get(1) - - outFile := ctx.String("out") - if len(outFile) == 0 { - outFile = crtFile - } - - root := ctx.String("root") - if len(root) == 0 { - root = pki.GetRootCAPath() - } - - caURL := ctx.String("ca-url") - if len(caURL) == 0 { - return errs.RequiredFlag(ctx, "ca-url") - } - - cert, err := tls.LoadX509KeyPair(crtFile, keyFile) - if err != nil { - return errors.Wrap(err, "error loading certificates") - } - if len(cert.Certificate) == 0 { - return errors.New("error loading certificate: certificate chain is empty") - } - - // Do not renew if (now - cert.notBefore) > (expiresIn + jitter) - if s := ctx.String("expires-in"); len(s) > 0 { - duration, err := time.ParseDuration(s) - if err != nil { - return errs.InvalidFlagValue(ctx, "expires-in", s, "") - } - - leaf, err := x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - return errors.Wrap(err, "error parsing certificate") - } - - now := time.Now() - jitter := rand.Int63n(int64(duration / 20)) - if d := leaf.NotAfter.Sub(now); d > duration+time.Duration(jitter) { - fmt.Printf("certificate not renewed: expires in %s\n", d.Round(time.Second)) - return nil - } - } - - rootInt, err := pemutil.Read(root) - if err != nil { - return err - } - - rootCert, ok := rootInt.(*x509.Certificate) - if !ok { - return errors.Errorf("error parsing %s: file is not a root certificate", root) - } - - pool := x509.NewCertPool() - pool.AddCert(rootCert) - - tr := &http.Transport{ - TLSClientConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: pool, - PreferServerCipherSuites: true, - }, - } - - client, err := ca.NewClient(caURL, ca.WithTransport(tr)) - if err != nil { - return err - } - - resp, err := client.Renew(tr) - if err != nil { - return errors.Wrap(err, "error renewing certificate") - } - - serverBlock, err := pemutil.Serialize(resp.ServerPEM.Certificate) - if err != nil { - return err - } - caBlock, err := pemutil.Serialize(resp.CaPEM.Certificate) - if err != nil { - return err - } - data := append(pem.EncodeToMemory(serverBlock), pem.EncodeToMemory(caBlock)...) - - if err := utils.WriteFile(outFile, data, 0600); err != nil { - return errs.FileError(err, outFile) - } - - ui.Printf("Your certificate has been saved in %s.\n", outFile) - return nil -} diff --git a/command/ca/renew.go b/command/ca/renew.go new file mode 100644 index 00000000..771ecb5c --- /dev/null +++ b/command/ca/renew.go @@ -0,0 +1,460 @@ +package ca + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "log" + "math/rand" + "net/http" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/api" + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli/command" + "github.com/smallstep/cli/crypto/pemutil" + "github.com/smallstep/cli/crypto/pki" + "github.com/smallstep/cli/crypto/x509util" + "github.com/smallstep/cli/errs" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/ui" + "github.com/smallstep/cli/utils" + "github.com/urfave/cli" +) + +func renewCertificateCommand() cli.Command { + return cli.Command{ + Name: "renew", + Action: command.ActionFunc(renewCertificateAction), + Usage: "renew a valid certificate", + UsageText: `**step ca renew** + [**--ca-url**=] [**--root**=] + [**--out**=] [**--expires-in**=] [**--force**]`, + Description: ` +**step ca renew** command renews the given certificate (with a request to the +certificate authority) and writes the new certificate to disk - either overwriting + or using a new file when the **--out**= flag is used. + +With the **--daemon** flag the command will periodically update the given +certificate. By default, it will renew the certificate before 2/3 of the validity +period of the certificate has elapsed. A random jitter is used to avoid multiple +instances running at the same time. The amount of time between renewal and +certificate expiration can be configured using the **--expires-in** flag, or a +fixed period can be set with the **--renew-period** flag. + +The **--daemon** flag can be combined with **--pid**, **--signal**, or **--exec** +to provide certificate reloads on your services. + +## POSITIONAL ARGUMENTS + + +: The certificate in PEM format that we want to renew. + + +: They key file of the certificate. + +## EXAMPLES + +Renew a certificate with the configured CA: +''' +$ step ca renew internal.crt internal.key +Would you like to overwrite internal.crt [Y/n]: y +''' + +Renew a certificate without overwriting the previous certificate: +''' +$ step ca renew --out renewed.crt internal.crt internal.key +''' + +Renew a certificate forcing the overwrite of the previous certificate: +''' +$ step ca renew --force internal.crt internal.key +''' + +Renew a certificate providing the <--ca-url> and <--root> flags: +''' +$ step ca renew --ca-url https://ca.smallstep.com:9000 \ + --root /path/to/root_ca.crt internal.crt internal.key +Would you like to overwrite internal.crt [Y/n]: y +''' + +Renew skipped because it was too early: +''' +$ step ca renew --expires-in 8h internal.crt internal.key +certificate not renewed: expires in 10h52m5s +''' + +Renew the certificate before 2/3 of the validity has passed: +''' +$ step ca renew --daemon internal.crt internal.key +''' + +Renew the certificate before 8 hours and 30m of the expiration time: +''' +$ step ca renew --daemon --expires-in 8h30m internal.crt internal.key +''' + +Renew the certificate every 16h: +''' +$ step ca renew --daemon --renew-period 16h internal.crt internal.key +''' + +Renew the certificate and reload nginx: +''' +$ step ca renew --daemon --exec "nginx -s reload" internal.crt internal.key +''' + +Renew the certificate and convert it to DER: +''' +$ step ca renew --daemon --renew-period 16h \ + --exec "step certificate format --force --out internal.der internal.crt" \ + internal.crt internal.key +'''`, + Flags: []cli.Flag{ + caURLFlag, + rootFlag, + cli.StringFlag{ + Name: "out,output-file", + Usage: "The new certificate path. Defaults to overwriting the positional argument", + }, + cli.StringFlag{ + Name: "expires-in", + Usage: `The amount of time remaining before certificate expiration, +at which point a renewal should be attempted. The certificate renewal will not +be performed if the time to expiration is greater than the **--expires-in** value. +A random jitter (duration/20) will be added to avoid multiple services hitting the +renew endpoint at the same time. The is a sequence of decimal numbers, +each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". +Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`, + }, + flags.Force, + cli.IntFlag{ + Name: "pid", + Usage: `The process id to signal after the certificate has been renewed. By default the +the SIGHUP (1) signal will be used, but this can be configured with the **--signal** +flag.`, + }, + cli.IntFlag{ + Name: "signal", + Usage: `The signal to send to the selected PID, so it can reload the +configuration and load the new certificate. Default value is SIGHUP (1)`, + Value: int(syscall.SIGHUP), + }, + cli.StringFlag{ + Name: "exec", + Usage: "The to run after the certificate has been renewed.", + }, + cli.BoolFlag{ + Name: "daemon", + Usage: `Run the renew command as a daemon, renewing and overwriting the certificate +periodically. By default the daemon will renew a certificate before 2/3 of the +time to expiration has elapsed. The period can be configured using the +**--renew-period** or **--expires-in** flags.`, + }, + cli.StringFlag{ + Name: "renew-period", + Usage: `The period with which to schedule renewals of the certificate in daemon mode. +Requires the **--daemon** flag. The is a sequence of decimal numbers, +each with optional fraction and a unit suffix, such as "300ms", "1.5h", or "2h45m". +Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`, + }, + }, + } +} + +func renewCertificateAction(ctx *cli.Context) error { + err := errs.NumberOfArguments(ctx, 2) + if err != nil { + return err + } + + args := ctx.Args() + crtFile := args.Get(0) + keyFile := args.Get(1) + isDaemon := ctx.Bool("daemon") + execCmd := ctx.String("exec") + + outFile := ctx.String("out") + if len(outFile) == 0 { + outFile = crtFile + } + + rootFile := ctx.String("root") + if len(rootFile) == 0 { + rootFile = pki.GetRootCAPath() + } + + caURL := ctx.String("ca-url") + if len(caURL) == 0 { + return errs.RequiredFlag(ctx, "ca-url") + } + + var expiresIn, renewPeriod time.Duration + if s := ctx.String("expires-in"); len(s) > 0 { + if expiresIn, err = time.ParseDuration(s); err != nil { + return errs.InvalidFlagValue(ctx, "expires-in", s, "") + } + } + if s := ctx.String("renew-period"); len(s) > 0 { + if renewPeriod, err = time.ParseDuration(s); err != nil { + return errs.InvalidFlagValue(ctx, "renew-period", s, "") + } + } + if expiresIn > 0 && renewPeriod > 0 { + return errs.IncompatibleFlagWithFlag(ctx, "expires-in", "renew-period") + } + if renewPeriod > 0 && !isDaemon { + return errs.RequiredWithFlag(ctx, "renew-period", "daemon") + } + + pid := ctx.Int("pid") + if ctx.IsSet("pid") && pid <= 0 { + return errs.InvalidFlagValue(ctx, "pid", strconv.Itoa(pid), "") + } + + signum := ctx.Int("signal") + if ctx.IsSet("signal") && signum <= 0 { + return errs.InvalidFlagValue(ctx, "signal", strconv.Itoa(signum), "") + } + + cert, err := tls.LoadX509KeyPair(crtFile, keyFile) + if err != nil { + return errors.Wrap(err, "error loading certificates") + } + if len(cert.Certificate) == 0 { + return errors.New("error loading certificate: certificate chain is empty") + } + + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return errors.Wrap(err, "error parsing certificate") + } + if leaf.NotAfter.Before(time.Now()) { + return errors.New("cannot renew an expired certificate") + } + cvp := leaf.NotAfter.Sub(leaf.NotBefore) + if renewPeriod > 0 && renewPeriod >= cvp { + return errors.Errorf("flag '--renew-period' must be within (lower than) the certificate "+ + "validity period; renew-period=%v, cert-validity-period=%v", renewPeriod, cvp) + } + + renewer, err := newRenewer(caURL, crtFile, keyFile, rootFile) + if err != nil { + return err + } + + afterRenew := getAfterRenewFunc(pid, signum, execCmd) + if isDaemon { + // Force is always enabled when daemon mode is used + ctx.Set("force", "true") + next := nextRenewDuration(leaf, expiresIn, renewPeriod) + return renewer.Daemon(outFile, next, expiresIn, renewPeriod, afterRenew) + } + + // Do not renew if (cert.notAfter - now) > (expiresIn + jitter) + if expiresIn > 0 { + jitter := rand.Int63n(int64(expiresIn / 20)) + if d := leaf.NotAfter.Sub(time.Now()); d > expiresIn+time.Duration(jitter) { + ui.Printf("certificate not renewed: expires in %s\n", d.Round(time.Second)) + return nil + } + } + + if _, err := renewer.Renew(outFile); err != nil { + return err + } + + ui.Printf("Your certificate has been saved in %s.\n", outFile) + return afterRenew() +} + +func nextRenewDuration(leaf *x509.Certificate, expiresIn, renewPeriod time.Duration) time.Duration { + if renewPeriod > 0 { + // Renew now if it will be expired in renewPeriod + if (leaf.NotAfter.Sub(time.Now()) - renewPeriod) <= 0 { + return 0 + } + return renewPeriod + } + + period := leaf.NotAfter.Sub(leaf.NotBefore) + if expiresIn == 0 { + expiresIn = period / 3 + } + + d := leaf.NotAfter.Sub(time.Now()) - expiresIn + n := rand.Int63n(int64(period / 20)) + d -= time.Duration(n) + if d < 0 { + d = 0 + } + return d +} + +func getAfterRenewFunc(pid, signum int, execCmd string) func() error { + return func() error { + if err := runKillPid(pid, signum); err != nil { + return err + } + return runExecCmd(execCmd) + } +} + +func runKillPid(pid, signum int) error { + if pid == 0 { + return nil + } + if err := syscall.Kill(pid, syscall.Signal(signum)); err != nil { + return errors.Wrapf(err, "kill %d with signal %d failed", pid, signum) + } + return nil +} + +func runExecCmd(execCmd string) error { + execCmd = strings.TrimSpace(execCmd) + if execCmd == "" { + return nil + } + parts := strings.Split(execCmd, " ") + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +type renewer struct { + client *ca.Client + transport *http.Transport + keyFile string +} + +func newRenewer(caURL, crtFile, keyFile, rootFile string) (*renewer, error) { + cert, err := tls.LoadX509KeyPair(crtFile, keyFile) + if err != nil { + return nil, errors.Wrap(err, "error loading certificates") + } + if len(cert.Certificate) == 0 { + return nil, errors.New("error loading certificate: certificate chain is empty") + } + + rootCAs, err := x509util.ReadCertPool(rootFile) + if err != nil { + return nil, err + } + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: rootCAs, + PreferServerCipherSuites: true, + }, + } + + client, err := ca.NewClient(caURL, ca.WithTransport(tr)) + if err != nil { + return nil, err + } + + return &renewer{ + client: client, + transport: tr, + keyFile: keyFile, + }, nil +} + +func (r *renewer) Renew(outFile string) (*api.SignResponse, error) { + resp, err := r.client.Renew(r.transport) + if err != nil { + return nil, errors.Wrap(err, "error renewing certificate") + } + + serverBlock, err := pemutil.Serialize(resp.ServerPEM.Certificate) + if err != nil { + return nil, err + } + caBlock, err := pemutil.Serialize(resp.CaPEM.Certificate) + if err != nil { + return nil, err + } + data := append(pem.EncodeToMemory(serverBlock), pem.EncodeToMemory(caBlock)...) + + if err := utils.WriteFile(outFile, data, 0600); err != nil { + return nil, errs.FileError(err, outFile) + } + + return resp, nil +} + +func (r *renewer) RenewAndPrepareNext(outFile string, expiresIn, renewPeriod time.Duration) (time.Duration, error) { + const durationOnErrors = 1 * time.Minute + + resp, err := r.Renew(outFile) + if err != nil { + return durationOnErrors, err + } + + cert, err := tls.LoadX509KeyPair(outFile, r.keyFile) + if err != nil { + return durationOnErrors, errors.Wrap(err, "error loading certificates") + } + if len(cert.Certificate) == 0 { + return durationOnErrors, errors.New("error loading certificate: certificate chain is empty") + } + + // Prepare next transport + r.transport.TLSClientConfig.Certificates = []tls.Certificate{cert} + + // Get next renew duration + return nextRenewDuration(resp.ServerPEM.Certificate, expiresIn, renewPeriod), nil +} + +func (r *renewer) Daemon(outFile string, next, expiresIn, renewPeriod time.Duration, afterRenew func() error) error { + // Loggers + Info := log.New(os.Stdout, "INFO: ", log.LstdFlags) + Error := log.New(os.Stderr, "ERROR: ", log.LstdFlags) + + // Daemon loop + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(signals) + + Info.Printf("first renewal in %s", next.Round(time.Second)) + for { + select { + case sig := <-signals: + switch sig { + case syscall.SIGHUP: + if n, err := r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { + Error.Println(err) + } else { + next = n + Info.Printf("certificate renewed, next in %s", next.Round(time.Second)) + if err := afterRenew(); err != nil { + Error.Println(err) + } + } + case syscall.SIGINT, syscall.SIGTERM: + return nil + } + case <-time.After(next): + if n, err := r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { + next = n + Error.Println(err) + } else { + next = n + Info.Printf("certificate renewed, next in %s", next.Round(time.Second)) + if err := afterRenew(); err != nil { + Error.Println(err) + } + } + } + } +}