diff --git a/command/ca/rekey.go b/command/ca/rekey.go index fe41da450..7bb82145c 100644 --- a/command/ca/rekey.go +++ b/command/ca/rekey.go @@ -6,7 +6,6 @@ import ( "os" "strconv" "strings" - "syscall" "time" "github.com/smallstep/cli/crypto/keys" @@ -169,12 +168,7 @@ flag.`, has been rekeyed. 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), - }, + flags.Signal, cli.StringFlag{ Name: "exec", Usage: "The to run after the certificate has been rekeyed.", @@ -309,7 +303,7 @@ func rekeyCertificateAction(ctx *cli.Context) error { if isDaemon { // Force is always enabled when daemon mode is used ctx.Set("force", "true") - next := nextRenewDuration(leaf, expiresIn, rekeyPeriod) + next := utils.NextRenewDuration(leaf, expiresIn, rekeyPeriod) return renewer.Daemon(outCert, next, expiresIn, rekeyPeriod, afterRekey) } diff --git a/command/ca/renew.go b/command/ca/renew.go index eeb7010d3..58dc64932 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -1,35 +1,27 @@ package ca import ( - "crypto" - cryptoRand "crypto/rand" "crypto/tls" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "log" "math/rand" "net/http" "net/url" "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/certificates/pki" "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/x509util" "github.com/smallstep/cli/flags" - "github.com/smallstep/cli/jose" - "github.com/smallstep/cli/token" + "github.com/smallstep/cli/internal/offline" + renewerPkg "github.com/smallstep/cli/internal/renewer" "github.com/smallstep/cli/utils" - "github.com/smallstep/cli/utils/cautils" + caclient "github.com/smallstep/cli/utils/cautils/client" "github.com/smallstep/cli/utils/sysutils" "github.com/urfave/cli" "go.step.sm/cli-utils/command" @@ -180,12 +172,7 @@ flag.`, 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), - }, + flags.Signal, cli.StringFlag{ Name: "exec", Usage: "The to run after the certificate has been renewed.", @@ -309,7 +296,7 @@ func renewCertificateAction(ctx *cli.Context) error { if isDaemon { // Force is always enabled when daemon mode is used ctx.Set("force", "true") - next := nextRenewDuration(cert.Leaf, expiresIn, renewPeriod) + next := utils.NextRenewDuration(cert.Leaf, expiresIn, renewPeriod) return renewer.Daemon(outFile, next, expiresIn, renewPeriod, afterRenew) } @@ -330,32 +317,6 @@ func renewCertificateAction(ctx *cli.Context) error { 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 (time.Until(leaf.NotAfter) - renewPeriod) <= 0 { - return 0 - } - return renewPeriod - } - - period := leaf.NotAfter.Sub(leaf.NotBefore) - if expiresIn == 0 { - expiresIn = period / 3 - } - - switch d := time.Until(leaf.NotAfter) - expiresIn; { - case d <= 0: - return 0 - case d < period/20: - return time.Duration(rand.Int63n(int64(d))) - default: - n := rand.Int63n(int64(period / 20)) - d -= time.Duration(n) - return d - } -} - func getAfterRenewFunc(pid, signum int, execCmd string) func() error { return func() error { if err := runKillPid(pid, signum); err != nil { @@ -388,17 +349,8 @@ func runExecCmd(execCmd string) error { return cmd.Run() } -type renewer struct { - client cautils.CaClient - transport *http.Transport - key crypto.PrivateKey - offline bool - cert tls.Certificate - caURL *url.URL - mtls bool -} +func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile string) (*renewerPkg.Renewer, error) { -func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile string) (*renewer, error) { if len(cert.Certificate) == 0 { return nil, errors.New("error loading certificate: certificate chain is empty") } @@ -420,14 +372,14 @@ func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile s tr.TLSClientConfig.Certificates = []tls.Certificate{cert} } - var client cautils.CaClient - offline := ctx.Bool("offline") - if offline { + var client caclient.CaClient + isOffline := ctx.Bool("offline") + if isOffline { caConfig := ctx.String("ca-config") if caConfig == "" { return nil, errs.InvalidFlagValue(ctx, "ca-config", "", "") } - client, err = cautils.NewOfflineCA(ctx, caConfig) + client, err = offline.New(ctx, caConfig) if err != nil { return nil, err } @@ -443,189 +395,7 @@ func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile s return nil, errors.Errorf("error parsing CA URL: %s", client.GetCaURL()) } - return &renewer{ - client: client, - transport: tr, - key: cert.PrivateKey, - offline: offline, - cert: cert, - caURL: u, - mtls: ctx.Bool("mtls"), - }, nil -} - -func (r *renewer) Renew(outFile string) (resp *api.SignResponse, err error) { - if !r.mtls || time.Now().After(r.cert.Leaf.NotAfter) { - resp, err = r.RenewWithToken(r.cert) - } else { - resp, err = r.client.Renew(r.transport) - } - if err != nil { - return nil, errors.Wrap(err, "error renewing certificate") - } - - if resp.CertChainPEM == nil || len(resp.CertChainPEM) == 0 { - resp.CertChainPEM = []api.Certificate{resp.ServerPEM, resp.CaPEM} - } - var data []byte - for _, certPEM := range resp.CertChainPEM { - pemblk, err := pemutil.Serialize(certPEM.Certificate) - if err != nil { - return nil, errors.Wrap(err, "error serializing certificate PEM") - } - data = append(data, pem.EncodeToMemory(pemblk)...) - } - if err := utils.WriteFile(outFile, data, 0600); err != nil { - return nil, errs.FileError(err, outFile) - } - - return resp, nil -} - -func (r *renewer) Rekey(priv interface{}, outCert, outKey string, writePrivateKey bool) (*api.SignResponse, error) { - csrBytes, err := x509.CreateCertificateRequest(cryptoRand.Reader, &x509.CertificateRequest{}, priv) - if err != nil { - return nil, err - } - csr, err := x509.ParseCertificateRequest(csrBytes) - if err != nil { - return nil, err - } - resp, err := r.client.Rekey(&api.RekeyRequest{CsrPEM: api.NewCertificateRequest(csr)}, r.transport) - if err != nil { - return nil, errors.Wrap(err, "error rekeying certificate") - } - if resp.CertChainPEM == nil || len(resp.CertChainPEM) == 0 { - resp.CertChainPEM = []api.Certificate{resp.ServerPEM, resp.CaPEM} - } - var data []byte - for _, certPEM := range resp.CertChainPEM { - pemblk, err := pemutil.Serialize(certPEM.Certificate) - if err != nil { - return nil, errors.Wrap(err, "error serializing certificate PEM") - } - data = append(data, pem.EncodeToMemory(pemblk)...) - } - if err := utils.WriteFile(outCert, data, 0600); err != nil { - return nil, errs.FileError(err, outCert) - } - if writePrivateKey { - _, err = pemutil.Serialize(priv, pemutil.ToFile(outKey, 0600)) - if err != nil { - return nil, err - } - } - - return resp, nil -} - -// RenewAndPrepareNext renews the cert and prepares the cert for it's next renewal. -// NOTE: this function logs each time the certificate is successfully renewed. -func (r *renewer) RenewAndPrepareNext(outFile string, expiresIn, renewPeriod time.Duration) (time.Duration, error) { - const durationOnErrors = 1 * time.Minute - Info := log.New(os.Stdout, "INFO: ", log.LstdFlags) - - resp, err := r.Renew(outFile) - if err != nil { - return durationOnErrors, err - } - - x509Chain, err := pemutil.ReadCertificateBundle(outFile) - if err != nil { - return durationOnErrors, errs.Wrap(err, "error reading certificate chain") - } - x509ChainBytes := make([][]byte, len(x509Chain)) - for i, c := range x509Chain { - x509ChainBytes[i] = c.Raw - } - - cert := tls.Certificate{ - Certificate: x509ChainBytes, - PrivateKey: r.key, - Leaf: x509Chain[0], - } - if len(cert.Certificate) == 0 { - return durationOnErrors, errors.New("error loading certificate: certificate chain is empty") - } - - // Prepare next transport - r.cert = cert - r.transport.TLSClientConfig.Certificates = []tls.Certificate{cert} - - // Get next renew duration - next := nextRenewDuration(resp.ServerPEM.Certificate, expiresIn, renewPeriod) - Info.Printf("%s certificate renewed, next in %s", resp.ServerPEM.Certificate.Subject.CommonName, next.Round(time.Second)) - return next, 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)) - var err error - for { - select { - case sig := <-signals: - switch sig { - case syscall.SIGHUP: - if next, err = r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { - Error.Println(err) - } else if err := afterRenew(); err != nil { - Error.Println(err) - } - case syscall.SIGINT, syscall.SIGTERM: - return nil - } - case <-time.After(next): - if next, err = r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { - Error.Println(err) - } else if err := afterRenew(); err != nil { - Error.Println(err) - } - } - } -} - -// RenewWithToken creates an authorization token with the given certificate and -// attempts to renew the given certificate. It can be used to renew expired -// certificates. -func (r *renewer) RenewWithToken(cert tls.Certificate) (*api.SignResponse, error) { - claims, err := token.NewClaims( - token.WithAudience(r.caURL.ResolveReference(&url.URL{Path: "/renew"}).String()), - token.WithIssuer("step-ca-client/1.0"), - token.WithSubject(cert.Leaf.Subject.CommonName), - ) - if err != nil { - return nil, errors.Wrap(err, "error creating authorization token") - } - var x5c []string - for _, b := range cert.Certificate { - x5c = append(x5c, base64.StdEncoding.EncodeToString(b)) - } - if claims.ExtraHeaders == nil { - claims.ExtraHeaders = make(map[string]interface{}) - } - claims.ExtraHeaders[jose.X5cInsecureKey] = x5c - - tok, err := claims.Sign("", cert.PrivateKey) - if err != nil { - return nil, errors.Wrap(err, "error signing authorization token") - } - - // Remove existing certificate from the transport. And close keep-alive - // connections. When daemon is used we don't want to re-use the connection - // that did not include a certificate. - r.transport.TLSClientConfig.Certificates = nil - defer r.transport.CloseIdleConnections() - - return r.client.RenewWithToken(tok) + return renewerPkg.New(client, tr, cert.PrivateKey, isOffline, cert, u, ctx.Bool("mtls")), nil } func tlsLoadX509KeyPair(certFile, keyFile, passFile string) (tls.Certificate, error) { diff --git a/command/ca/revoke.go b/command/ca/revoke.go index 187e9b38e..c6913c3de 100644 --- a/command/ca/revoke.go +++ b/command/ca/revoke.go @@ -18,8 +18,10 @@ import ( "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/x509util" "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/internal/offline" "github.com/smallstep/cli/jose" "github.com/smallstep/cli/utils/cautils" + caclient "github.com/smallstep/cli/utils/cautils/client" "github.com/urfave/cli" "go.step.sm/cli-utils/command" "go.step.sm/cli-utils/errs" @@ -211,7 +213,7 @@ func revokeCertificateAction(ctx *cli.Context) error { serial := args.Get(0) certFile, keyFile := ctx.String("cert"), ctx.String("key") token := ctx.String("token") - offline := ctx.Bool("offline") + isOffline := ctx.Bool("offline") // Validate the reasonCode arg early in the flow. if _, err := ReasonCodeToNum(ctx.String("reasonCode")); err != nil { @@ -220,7 +222,7 @@ func revokeCertificateAction(ctx *cli.Context) error { // offline and token are incompatible because the token is generated before // the start of the offline CA. - if offline && token != "" { + if isOffline && token != "" { return errs.IncompatibleFlagWithFlag(ctx, "offline", "token") } @@ -283,38 +285,38 @@ type revokeTokenClaims struct { } type revokeFlow struct { - offlineCA *cautils.OfflineCA + offlineCA offline.CA offline bool } func newRevokeFlow(ctx *cli.Context, certFile, keyFile string) (*revokeFlow, error) { var err error - var offlineClient *cautils.OfflineCA + var offlineCA offline.CA - offline := ctx.Bool("offline") - if offline { + isOffline := ctx.Bool("offline") + if isOffline { caConfig := ctx.String("ca-config") if caConfig == "" { return nil, errs.InvalidFlagValue(ctx, "ca-config", "", "") } - offlineClient, err = cautils.NewOfflineCA(ctx, caConfig) + offlineCA, err = offline.New(ctx, caConfig) if err != nil { return nil, err } if len(certFile) > 0 || len(keyFile) > 0 { - if err := offlineClient.VerifyClientCert(certFile, keyFile); err != nil { + if err := offlineCA.VerifyClientCert(certFile, keyFile); err != nil { return nil, err } } } return &revokeFlow{ - offlineCA: offlineClient, - offline: offline, + offlineCA: offlineCA, + offline: isOffline, }, nil } -func (f *revokeFlow) getClient(ctx *cli.Context, serial, token string) (cautils.CaClient, error) { +func (f *revokeFlow) getClient(ctx *cli.Context, serial, token string) (caclient.CaClient, error) { if f.offline { return f.offlineCA, nil } @@ -367,6 +369,7 @@ func (f *revokeFlow) getClient(ctx *cli.Context, serial, token string) (cautils. } func (f *revokeFlow) GenerateToken(ctx *cli.Context, subject *string) (string, error) { + // For offline just generate the token if f.offline { return f.offlineCA.GenerateToken(ctx, cautils.RevokeType, *subject, nil, time.Time{}, time.Time{}, provisioner.TimeDuration{}, provisioner.TimeDuration{}) diff --git a/command/ca/token.go b/command/ca/token.go index 96a089b32..12790e9ad 100644 --- a/command/ca/token.go +++ b/command/ca/token.go @@ -222,6 +222,7 @@ func tokenAction(ctx *cli.Context) error { subject := ctx.Args().Get(0) outputFile := ctx.String("output-file") offline := ctx.Bool("offline") + // x.509 flags sans := ctx.StringSlice("san") isRevoke := ctx.Bool("revoke") @@ -314,18 +315,18 @@ func tokenAction(ctx *cli.Context) error { var token string if offline { token, err = cautils.OfflineTokenFlow(ctx, typ, subject, sans, notBefore, notAfter, certNotBefore, certNotAfter) - if err != nil { - return err - } } else { token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter) - if err != nil { - return err - } } + + if err != nil { + return err + } + if len(outputFile) > 0 { return utils.WriteFile(outputFile, []byte(token), 0600) } + fmt.Println(token) return nil } diff --git a/crypto/sshutil/agent_unix.go b/crypto/sshutil/agent_unix.go index 07aa4350f..ce587ec6d 100644 --- a/crypto/sshutil/agent_unix.go +++ b/crypto/sshutil/agent_unix.go @@ -1,5 +1,5 @@ -//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris -// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || js +// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris js package sshutil diff --git a/flags/flags_js.go b/flags/flags_js.go new file mode 100644 index 000000000..0726d91df --- /dev/null +++ b/flags/flags_js.go @@ -0,0 +1,22 @@ +//go:build js +// +build js + +package flags + +import ( + "syscall" + + "github.com/urfave/cli" +) + +var ( + + // Signal is a cli.Flag used to specify the signal (number) to send to + // a process with PID. + Signal = 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.SIGTERM), // in js/wasm, there's no SIGHUP; default to SIGTERM (for now) + } +) diff --git a/flags/flags_other.go b/flags/flags_other.go new file mode 100644 index 000000000..ae276ab84 --- /dev/null +++ b/flags/flags_other.go @@ -0,0 +1,22 @@ +//go:build !js +// +build !js + +package flags + +import ( + "syscall" + + "github.com/urfave/cli" +) + +var ( + + // Signal is a cli.Flag used to specify the signal (number) to send to + // a process with PID. + Signal = 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), + } +) diff --git a/internal/offline/ca.go b/internal/offline/ca.go new file mode 100644 index 000000000..89e1f751c --- /dev/null +++ b/internal/offline/ca.go @@ -0,0 +1,16 @@ +package offline + +import ( + "time" + + "github.com/smallstep/certificates/authority/provisioner" + caclient "github.com/smallstep/cli/utils/cautils/client" + "github.com/urfave/cli" +) + +type CA interface { + caclient.CaClient + + VerifyClientCert(certFile, keyFile string) error + GenerateToken(ctx *cli.Context, tokType int, subject string, sans []string, notBefore, notAfter time.Time, certNotBefore, certNotAfter provisioner.TimeDuration) (string, error) +} diff --git a/internal/offline/offline_js.go b/internal/offline/offline_js.go new file mode 100644 index 000000000..6c6419051 --- /dev/null +++ b/internal/offline/offline_js.go @@ -0,0 +1,23 @@ +//go:build js +// +build js + +package offline + +import ( + "errors" + + "github.com/urfave/cli" +) + +var errNotSupported = errors.New("offline client not (yet) supported for js/wasm") + +// TODO(hs): implement method stubs for unsupportedOfflineCA and +// return it in New() +type unsupportedOfflineCA struct{} + +// New always return a nil instance of the CA interface and an +// error indicating offline mode is not (yet) support for the js/wasm +// target. +func New(ctx *cli.Context, caConfig string) (CA, error) { + return nil, errNotSupported +} diff --git a/internal/offline/offline_others.go b/internal/offline/offline_others.go new file mode 100644 index 000000000..6595eb5c1 --- /dev/null +++ b/internal/offline/offline_others.go @@ -0,0 +1,13 @@ +//go:build !js +// +build !js + +package offline + +import ( + "github.com/smallstep/cli/utils/cautils" + "github.com/urfave/cli" +) + +func New(ctx *cli.Context, caConfig string) (CA, error) { + return cautils.NewOfflineCA(ctx, caConfig) +} diff --git a/internal/renewer/daemon_js.go b/internal/renewer/daemon_js.go new file mode 100644 index 000000000..d48cd5f04 --- /dev/null +++ b/internal/renewer/daemon_js.go @@ -0,0 +1,13 @@ +//go:build js +// +build js + +package renewer + +import ( + "errors" + "time" +) + +func (r *Renewer) Daemon(outFile string, next, expiresIn, renewPeriod time.Duration, afterRenew func() error) error { + return errors.New("daemonizing not supported in js/wasm") +} diff --git a/internal/renewer/daemon_others.go b/internal/renewer/daemon_others.go new file mode 100644 index 000000000..34a3ab69c --- /dev/null +++ b/internal/renewer/daemon_others.go @@ -0,0 +1,48 @@ +//go:build !js +// +build !js + +package renewer + +import ( + "log" + "os" + "os/signal" + "syscall" + "time" +) + +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) + //signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + + Info.Printf("first renewal in %s", next.Round(time.Second)) + var err error + for { + select { + case sig := <-signals: + switch sig { + case syscall.SIGHUP: + if next, err = r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { + Error.Println(err) + } else if err := afterRenew(); err != nil { + Error.Println(err) + } + case syscall.SIGINT, syscall.SIGTERM: + return nil + } + case <-time.After(next): + if next, err = r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { + Error.Println(err) + } else if err := afterRenew(); err != nil { + Error.Println(err) + } + } + } +} diff --git a/internal/renewer/renewer.go b/internal/renewer/renewer.go new file mode 100644 index 000000000..e73073f8f --- /dev/null +++ b/internal/renewer/renewer.go @@ -0,0 +1,184 @@ +package renewer + +import ( + "crypto" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "log" + "net/http" + "net/url" + "os" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/api" + "github.com/smallstep/cli/crypto/pemutil" + "github.com/smallstep/cli/jose" + "github.com/smallstep/cli/token" + "github.com/smallstep/cli/utils" + caclient "github.com/smallstep/cli/utils/cautils/client" + "go.step.sm/cli-utils/errs" +) + +type Renewer struct { + client caclient.CaClient + transport *http.Transport + key crypto.PrivateKey + offline bool + cert tls.Certificate + caURL *url.URL + mtls bool +} + +func New(client caclient.CaClient, tr *http.Transport, key crypto.PrivateKey, offline bool, cert tls.Certificate, caURL *url.URL, useMTLS bool) *Renewer { + return &Renewer{ + client: client, + transport: tr, + key: key, + offline: offline, + cert: cert, + caURL: caURL, + mtls: useMTLS, + } +} + +func (r *Renewer) Renew(outFile string) (resp *api.SignResponse, err error) { + if !r.mtls || time.Now().After(r.cert.Leaf.NotAfter) { + resp, err = r.RenewWithToken(r.cert) + } else { + resp, err = r.client.Renew(r.transport) + } + if err != nil { + return nil, errors.Wrap(err, "error renewing certificate") + } + + if resp.CertChainPEM == nil || len(resp.CertChainPEM) == 0 { + resp.CertChainPEM = []api.Certificate{resp.ServerPEM, resp.CaPEM} + } + var data []byte + for _, certPEM := range resp.CertChainPEM { + pemblk, err := pemutil.Serialize(certPEM.Certificate) + if err != nil { + return nil, errors.Wrap(err, "error serializing certificate PEM") + } + data = append(data, pem.EncodeToMemory(pemblk)...) + } + if err := utils.WriteFile(outFile, data, 0600); err != nil { + return nil, errs.FileError(err, outFile) + } + + return resp, nil +} + +func (r *Renewer) Rekey(priv interface{}, outCert, outKey string, writePrivateKey bool) (*api.SignResponse, error) { + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, priv) + if err != nil { + return nil, err + } + csr, err := x509.ParseCertificateRequest(csrBytes) + if err != nil { + return nil, err + } + resp, err := r.client.Rekey(&api.RekeyRequest{CsrPEM: api.NewCertificateRequest(csr)}, r.transport) + if err != nil { + return nil, errors.Wrap(err, "error rekeying certificate") + } + if resp.CertChainPEM == nil || len(resp.CertChainPEM) == 0 { + resp.CertChainPEM = []api.Certificate{resp.ServerPEM, resp.CaPEM} + } + var data []byte + for _, certPEM := range resp.CertChainPEM { + pemblk, err := pemutil.Serialize(certPEM.Certificate) + if err != nil { + return nil, errors.Wrap(err, "error serializing certificate PEM") + } + data = append(data, pem.EncodeToMemory(pemblk)...) + } + if err := utils.WriteFile(outCert, data, 0600); err != nil { + return nil, errs.FileError(err, outCert) + } + if writePrivateKey { + _, err = pemutil.Serialize(priv, pemutil.ToFile(outKey, 0600)) + if err != nil { + return nil, err + } + } + + return resp, nil +} + +// RenewAndPrepareNext renews the cert and prepares the cert for it's next renewal. +// NOTE: this function logs each time the certificate is successfully renewed. +func (r *Renewer) RenewAndPrepareNext(outFile string, expiresIn, renewPeriod time.Duration) (time.Duration, error) { + const durationOnErrors = 1 * time.Minute + Info := log.New(os.Stdout, "INFO: ", log.LstdFlags) + + resp, err := r.Renew(outFile) + if err != nil { + return durationOnErrors, err + } + + x509Chain, err := pemutil.ReadCertificateBundle(outFile) + if err != nil { + return durationOnErrors, errs.Wrap(err, "error reading certificate chain") + } + x509ChainBytes := make([][]byte, len(x509Chain)) + for i, c := range x509Chain { + x509ChainBytes[i] = c.Raw + } + + cert := tls.Certificate{ + Certificate: x509ChainBytes, + PrivateKey: r.key, + Leaf: x509Chain[0], + } + 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 + next := utils.NextRenewDuration(resp.ServerPEM.Certificate, expiresIn, renewPeriod) + Info.Printf("%s certificate renewed, next in %s", resp.ServerPEM.Certificate.Subject.CommonName, next.Round(time.Second)) + return next, nil +} + +// RenewWithToken creates an authorization token with the given certificate and +// attempts to renew the given certificate. It can be used to renew expired +// certificates. +func (r *Renewer) RenewWithToken(cert tls.Certificate) (*api.SignResponse, error) { + claims, err := token.NewClaims( + token.WithAudience(r.caURL.ResolveReference(&url.URL{Path: "/renew"}).String()), + token.WithIssuer("step-ca-client/1.0"), + token.WithSubject(cert.Leaf.Subject.CommonName), + ) + if err != nil { + return nil, errors.Wrap(err, "error creating authorization token") + } + var x5c []string + for _, b := range cert.Certificate { + x5c = append(x5c, base64.StdEncoding.EncodeToString(b)) + } + if claims.ExtraHeaders == nil { + claims.ExtraHeaders = make(map[string]interface{}) + } + claims.ExtraHeaders[jose.X5cInsecureKey] = x5c + + tok, err := claims.Sign("", cert.PrivateKey) + if err != nil { + return nil, errors.Wrap(err, "error signing authorization token") + } + + // Remove existing certificate from the transport. And close keep-alive + // connections. When daemon is used we don't want to re-use the connection + // that did not include a certificate. + r.transport.TLSClientConfig.Certificates = nil + defer r.transport.CloseIdleConnections() + + return r.client.RenewWithToken(tok) +} diff --git a/utils/cautils/acmeutils.go b/utils/cautils/acmeutils.go index 78532984a..e98f4a74d 100644 --- a/utils/cautils/acmeutils.go +++ b/utils/cautils/acmeutils.go @@ -24,6 +24,7 @@ import ( "github.com/smallstep/cli/flags" "github.com/smallstep/cli/jose" "github.com/smallstep/cli/utils" + "github.com/smallstep/cli/utils/sansutils" "github.com/urfave/cli" "go.step.sm/cli-utils/errs" "go.step.sm/cli-utils/ui" @@ -270,7 +271,7 @@ func finalizeOrder(ac *ca.ACMEClient, o *acme.Order, csr *x509.CertificateReques } func validateSANsForACME(sans []string) ([]string, []net.IP, error) { - dnsNames, ips, emails, uris := splitSANs(sans) + dnsNames, ips, emails, uris := sansutils.Split(sans) if len(emails) > 0 || len(uris) > 0 { return nil, nil, errors.New("Email Address and URI SANs are not supported for ACME flow") } diff --git a/utils/cautils/certificate_flow.go b/utils/cautils/certificate_flow.go index 6cd664cb2..d0f359315 100644 --- a/utils/cautils/certificate_flow.go +++ b/utils/cautils/certificate_flow.go @@ -7,7 +7,6 @@ import ( "crypto/x509/pkix" "encoding/pem" "fmt" - "net" "net/url" "os" "strings" @@ -20,10 +19,11 @@ import ( "github.com/smallstep/certificates/pki" "github.com/smallstep/cli/crypto/keys" "github.com/smallstep/cli/crypto/pemutil" - "github.com/smallstep/cli/crypto/x509util" "github.com/smallstep/cli/flags" "github.com/smallstep/cli/token" "github.com/smallstep/cli/utils" + caclient "github.com/smallstep/cli/utils/cautils/client" + "github.com/smallstep/cli/utils/sansutils" "github.com/urfave/cli" "go.step.sm/cli-utils/errs" "go.step.sm/cli-utils/ui" @@ -31,7 +31,7 @@ import ( // CertificateFlow manages the flow to retrieve a new certificate. type CertificateFlow struct { - offlineCA *OfflineCA + offlineCA *OfflineCA // TODO(hs): make this an offline.CA offline bool } @@ -42,29 +42,33 @@ var sharedContext = struct { // NewCertificateFlow initializes a cli flow to get a new certificate. func NewCertificateFlow(ctx *cli.Context) (*CertificateFlow, error) { + + // TODO(hs): fix offline NewCertificateFlow; this one's a bit messy in terms of import (cycle), it seems + var err error - var offlineClient *OfflineCA + var offlineCA *OfflineCA - offline := ctx.Bool("offline") - if offline { + isOffline := ctx.Bool("offline") + if isOffline { caConfig := ctx.String("ca-config") if caConfig == "" { return nil, errs.InvalidFlagValue(ctx, "ca-config", "", "") } - offlineClient, err = NewOfflineCA(ctx, caConfig) + offlineCA, err = NewOfflineCA(ctx, caConfig) // TODO(hs): use offline.New() or something similar if err != nil { return nil, err } } return &CertificateFlow{ - offlineCA: offlineClient, - offline: offline, + offlineCA: offlineCA, + offline: isOffline, }, nil } // GetClient returns the client used to send requests to the CA. -func (f *CertificateFlow) GetClient(ctx *cli.Context, tok string, options ...ca.ClientOption) (CaClient, error) { +func (f *CertificateFlow) GetClient(ctx *cli.Context, tok string, options ...ca.ClientOption) (caclient.CaClient, error) { + if f.offline { return f.offlineCA, nil } @@ -107,6 +111,7 @@ func (f *CertificateFlow) GetClient(ctx *cli.Context, tok string, options ...ca. // validity values will be used). The token is generated either with the offline // token flow or the online mode. func (f *CertificateFlow) GenerateToken(ctx *cli.Context, subject string, sans []string) (string, error) { + if f.offline { return f.offlineCA.GenerateToken(ctx, SignType, subject, sans, time.Time{}, time.Time{}, provisioner.TimeDuration{}, provisioner.TimeDuration{}) } @@ -140,6 +145,7 @@ func (f *CertificateFlow) GenerateToken(ctx *cli.Context, subject string, sans [ // GenerateSSHToken generates a token used to authorize the sign of an SSH // certificate. func (f *CertificateFlow) GenerateSSHToken(ctx *cli.Context, subject string, typ int, principals []string, validAfter, validBefore provisioner.TimeDuration) (string, error) { + if f.offline { return f.offlineCA.GenerateToken(ctx, typ, subject, principals, time.Time{}, time.Time{}, validAfter, validBefore) } @@ -242,7 +248,7 @@ func (f *CertificateFlow) CreateSignRequest(ctx *cli.Context, tok, subject strin return nil, nil, err } - dnsNames, ips, emails, uris := splitSANs(sans, jwt.Payload.SANs) + dnsNames, ips, emails, uris := sansutils.Split(sans, jwt.Payload.SANs) switch jwt.Payload.Type() { case token.AWS: doc := jwt.Payload.Amazon.InstanceIdentityDocument @@ -254,7 +260,7 @@ func (f *CertificateFlow) CreateSignRequest(ctx *cli.Context, tok, subject strin if !sharedContext.DisableCustomSANs { defaultSANs = append(defaultSANs, subject) } - dnsNames, ips, emails, uris = splitSANs(defaultSANs) + dnsNames, ips, emails, uris = sansutils.Split(defaultSANs) } case token.GCP: ce := jwt.Payload.Google.ComputeEngine @@ -266,7 +272,7 @@ func (f *CertificateFlow) CreateSignRequest(ctx *cli.Context, tok, subject strin if !sharedContext.DisableCustomSANs { defaultSANs = append(defaultSANs, subject) } - dnsNames, ips, emails, uris = splitSANs(defaultSANs) + dnsNames, ips, emails, uris = sansutils.Split(defaultSANs) } case token.Azure: if len(ips) == 0 && len(dnsNames) == 0 { @@ -276,7 +282,7 @@ func (f *CertificateFlow) CreateSignRequest(ctx *cli.Context, tok, subject strin if !sharedContext.DisableCustomSANs { defaultSANs = append(defaultSANs, subject) } - dnsNames, ips, emails, uris = splitSANs(defaultSANs) + dnsNames, ips, emails, uris = sansutils.Split(defaultSANs) } case token.OIDC: // If no sans are given using the --san flag, and the subject argument @@ -299,7 +305,7 @@ func (f *CertificateFlow) CreateSignRequest(ctx *cli.Context, tok, subject strin uris = append(uris, iss) } } else { - dnsNames, ips, emails, uris = splitSANs([]string{subject}) + dnsNames, ips, emails, uris = sansutils.Split([]string{subject}) } } case token.K8sSA: @@ -336,19 +342,3 @@ func (f *CertificateFlow) CreateSignRequest(ctx *cli.Context, tok, subject strin OTT: tok, }, pk, nil } - -// splitSANs unifies the SAN collections passed as arguments and returns a list -// of DNS names, a list of IP addresses, and a list of emails. -func splitSANs(args ...[]string) (dnsNames []string, ipAddresses []net.IP, email []string, uris []*url.URL) { - m := make(map[string]bool) - var unique []string - for _, sans := range args { - for _, san := range sans { - if ok := m[san]; !ok && san != "" { - m[san] = true - unique = append(unique, san) - } - } - } - return x509util.SplitSANs(unique) -} diff --git a/utils/cautils/client.go b/utils/cautils/client.go index 41699a19d..92a00f063 100644 --- a/utils/cautils/client.go +++ b/utils/cautils/client.go @@ -4,7 +4,6 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" - "net/http" "os" "time" @@ -16,37 +15,16 @@ import ( "github.com/smallstep/cli/crypto/keys" "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/flags" + caclient "github.com/smallstep/cli/utils/cautils/client" + "github.com/smallstep/cli/utils/sansutils" "github.com/urfave/cli" "go.step.sm/cli-utils/errs" "go.step.sm/cli-utils/ui" ) -// CaClient is the interface implemented by a client used to sign, renew, revoke -// certificates among other things. -type CaClient interface { - Sign(req *api.SignRequest) (*api.SignResponse, error) - Renew(tr http.RoundTripper) (*api.SignResponse, error) - RenewWithToken(ott string) (*api.SignResponse, error) - Revoke(req *api.RevokeRequest, tr http.RoundTripper) (*api.RevokeResponse, error) - Rekey(req *api.RekeyRequest, tr http.RoundTripper) (*api.SignResponse, error) - SSHSign(req *api.SSHSignRequest) (*api.SSHSignResponse, error) - SSHRenew(req *api.SSHRenewRequest) (*api.SSHRenewResponse, error) - SSHRekey(req *api.SSHRekeyRequest) (*api.SSHRekeyResponse, error) - SSHRevoke(req *api.SSHRevokeRequest) (*api.SSHRevokeResponse, error) - SSHRoots() (*api.SSHRootsResponse, error) - SSHFederation() (*api.SSHRootsResponse, error) - SSHConfig(req *api.SSHConfigRequest) (*api.SSHConfigResponse, error) - SSHCheckHost(principal string, token string) (*api.SSHCheckPrincipalResponse, error) - SSHGetHosts() (*api.SSHGetHostsResponse, error) - SSHBastion(req *api.SSHBastionRequest) (*api.SSHBastionResponse, error) - Version() (*api.VersionResponse, error) - GetRootCAs() *x509.CertPool - GetCaURL() string -} - // NewClient returns a client of an online or offline CA. Requires the flags // `offline`, `ca-config`, `ca-url`, and `root`. -func NewClient(ctx *cli.Context, opts ...ca.ClientOption) (CaClient, error) { +func NewClient(ctx *cli.Context, opts ...ca.ClientOption) (caclient.CaClient, error) { if ctx.Bool("offline") { caConfig := ctx.String("ca-config") if caConfig == "" { @@ -54,7 +32,6 @@ func NewClient(ctx *cli.Context, opts ...ca.ClientOption) (CaClient, error) { } return NewOfflineCA(ctx, caConfig) } - caURL, err := flags.ParseCaURL(ctx) if err != nil { return nil, err @@ -128,7 +105,7 @@ func NewAdminClient(ctx *cli.Context, opts ...ca.ClientOption) (*ca.AdminClient, return nil, err } - dnsNames, ips, emails, uris := splitSANs([]string{subject}) + dnsNames, ips, emails, uris := sansutils.Split([]string{subject}) template := &x509.CertificateRequest{ Subject: pkix.Name{ CommonName: subject, diff --git a/utils/cautils/client/client.go b/utils/cautils/client/client.go new file mode 100644 index 000000000..961855525 --- /dev/null +++ b/utils/cautils/client/client.go @@ -0,0 +1,31 @@ +package client + +import ( + "crypto/x509" + "net/http" + + "github.com/smallstep/certificates/api" +) + +// CaClient is the interface implemented by a client used to sign, renew, revoke +// certificates among other things. +type CaClient interface { + Sign(req *api.SignRequest) (*api.SignResponse, error) + Renew(tr http.RoundTripper) (*api.SignResponse, error) + RenewWithToken(ott string) (*api.SignResponse, error) + Revoke(req *api.RevokeRequest, tr http.RoundTripper) (*api.RevokeResponse, error) + Rekey(req *api.RekeyRequest, tr http.RoundTripper) (*api.SignResponse, error) + SSHSign(req *api.SSHSignRequest) (*api.SSHSignResponse, error) + SSHRenew(req *api.SSHRenewRequest) (*api.SSHRenewResponse, error) + SSHRekey(req *api.SSHRekeyRequest) (*api.SSHRekeyResponse, error) + SSHRevoke(req *api.SSHRevokeRequest) (*api.SSHRevokeResponse, error) + SSHRoots() (*api.SSHRootsResponse, error) + SSHFederation() (*api.SSHRootsResponse, error) + SSHConfig(req *api.SSHConfigRequest) (*api.SSHConfigResponse, error) + SSHCheckHost(principal string, token string) (*api.SSHCheckPrincipalResponse, error) + SSHGetHosts() (*api.SSHGetHostsResponse, error) + SSHBastion(req *api.SSHBastionRequest) (*api.SSHBastionResponse, error) + Version() (*api.VersionResponse, error) + GetRootCAs() *x509.CertPool + GetCaURL() string +} diff --git a/utils/renew.go b/utils/renew.go new file mode 100644 index 000000000..bc30a564e --- /dev/null +++ b/utils/renew.go @@ -0,0 +1,33 @@ +package utils + +import ( + "crypto/x509" + "math/rand" + "time" +) + +func NextRenewDuration(leaf *x509.Certificate, expiresIn, renewPeriod time.Duration) time.Duration { + if renewPeriod > 0 { + // Renew now if it will be expired in renewPeriod + if (time.Until(leaf.NotAfter) - renewPeriod) <= 0 { + return 0 + } + return renewPeriod + } + + period := leaf.NotAfter.Sub(leaf.NotBefore) + if expiresIn == 0 { + expiresIn = period / 3 + } + + switch d := time.Until(leaf.NotAfter) - expiresIn; { + case d <= 0: + return 0 + case d < period/20: + return time.Duration(rand.Int63n(int64(d))) + default: + n := rand.Int63n(int64(period / 20)) + d -= time.Duration(n) + return d + } +} diff --git a/utils/sansutils/sans.go b/utils/sansutils/sans.go new file mode 100644 index 000000000..0dde12841 --- /dev/null +++ b/utils/sansutils/sans.go @@ -0,0 +1,24 @@ +package sansutils + +import ( + "net" + "net/url" + + "github.com/smallstep/cli/crypto/x509util" +) + +// Split unifies the SAN collections passed as arguments and returns a list +// of DNS names, a list of IP addresses, and a list of emails. +func Split(args ...[]string) (dnsNames []string, ipAddresses []net.IP, email []string, uris []*url.URL) { + m := make(map[string]bool) + var unique []string + for _, sans := range args { + for _, san := range sans { + if ok := m[san]; !ok && san != "" { + m[san] = true + unique = append(unique, san) + } + } + } + return x509util.SplitSANs(unique) +} diff --git a/utils/sysutils/sysutils_js.go b/utils/sysutils/sysutils_js.go new file mode 100644 index 000000000..088ba62cc --- /dev/null +++ b/utils/sysutils/sysutils_js.go @@ -0,0 +1,31 @@ +//go:build js +// +build js + +package sysutils + +import ( + "errors" + "syscall" +) + +var errNotImplemented = errors.New("not implemented") + +func flock(fd, how int) error { + return errNotImplemented +} + +func fileLock(fd int) error { + return errNotImplemented +} + +func fileUnlock(fd int) error { + return errNotImplemented +} + +func kill(pid int, signum syscall.Signal) error { + return errNotImplemented +} + +func exec(argv0 string, argv, envv []string) error { + return errNotImplemented +}