Skip to content

Commit

Permalink
Merge pull request #647 from smallstep/feat/renewAfterExpiry
Browse files Browse the repository at this point in the history
Renew after expiry
  • Loading branch information
maraino committed Apr 13, 2022
2 parents 023bec0 + 2b0404f commit b6adb57
Show file tree
Hide file tree
Showing 14 changed files with 348 additions and 59 deletions.
4 changes: 3 additions & 1 deletion command/ca/provisionerbeta/add.go
Expand Up @@ -141,6 +141,7 @@ func addCommand() cli.Command {
sshHostMaxDurFlag,
sshHostDefaultDurFlag,
disableRenewalFlag,
allowRenewalAfterExpiryFlag,
enableX509Flag,
enableSSHFlag,

Expand Down Expand Up @@ -404,7 +405,8 @@ func addAction(ctx *cli.Context) (err error) {
},
Enabled: !(ctx.IsSet("ssh") && !ctx.Bool("ssh")),
},
DisableRenewal: ctx.Bool("disable-renewal"),
DisableRenewal: ctx.Bool("disable-renewal"),
AllowRenewalAfterExpiry: ctx.Bool("allow-renewal-after-expiry"),
}

switch linkedca.Provisioner_Type(typ) {
Expand Down
8 changes: 7 additions & 1 deletion command/ca/provisionerbeta/provisioner.go
Expand Up @@ -57,6 +57,8 @@ with the following properties:
by default.
* **disableRenewal**: whether or not to disable certificate renewal, set to false
by default.
* **allowRenewalAfterExpiry**: whether or not to allow certificate renewal of
expired certificates, set to false by default.
## EXAMPLES
Expand Down Expand Up @@ -158,7 +160,11 @@ var (
}
disableRenewalFlag = cli.BoolFlag{
Name: "disable-renewal",
Usage: `Disable renewal for all certificates generated by this provisioner`,
Usage: `Disable renewal for all certificates generated by this provisioner.`,
}
allowRenewalAfterExpiryFlag = cli.BoolFlag{
Name: "allow-renewal-after-expiry",
Usage: `Allow renewals for expired certificates generated by this provisioner.`,
}
enableX509Flag = cli.BoolFlag{
Name: "x509",
Expand Down
4 changes: 4 additions & 0 deletions command/ca/provisionerbeta/update.go
Expand Up @@ -108,6 +108,7 @@ IID (AWS/GCP/Azure)
sshHostMaxDurFlag,
sshHostDefaultDurFlag,
disableRenewalFlag,
allowRenewalAfterExpiryFlag,
enableX509Flag,
enableSSHFlag,

Expand Down Expand Up @@ -425,6 +426,9 @@ func updateClaims(ctx *cli.Context, p *linkedca.Provisioner) {
if ctx.IsSet("disable-renewal") {
p.Claims.DisableRenewal = ctx.Bool("disable-renewal")
}
if ctx.IsSet("allow-renewal-after-expiry") {
p.Claims.AllowRenewalAfterExpiry = ctx.Bool("allow-renewal-after-expiry")
}
claims := p.Claims

if claims.X509 == nil {
Expand Down
71 changes: 61 additions & 10 deletions command/ca/renew.go
Expand Up @@ -5,10 +5,12 @@ import (
cryptoRand "crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
Expand All @@ -24,6 +26,8 @@ import (
"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/utils"
"github.com/smallstep/cli/utils/cautils"
"github.com/smallstep/cli/utils/sysutils"
Expand Down Expand Up @@ -269,12 +273,8 @@ func renewCertificateAction(ctx *cli.Context) error {
if err != nil {
return err
}
leaf := cert.Leaf

if leaf.NotAfter.Before(time.Now()) {
return errors.New("cannot renew an expired certificate")
}
cvp := leaf.NotAfter.Sub(leaf.NotBefore)
cvp := cert.Leaf.NotAfter.Sub(cert.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)
Expand All @@ -293,14 +293,14 @@ func renewCertificateAction(ctx *cli.Context) error {
if isDaemon {
// Force is always enabled when daemon mode is used
ctx.Set("force", "true")
next := nextRenewDuration(leaf, expiresIn, renewPeriod)
next := nextRenewDuration(cert.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 := time.Until(leaf.NotAfter); d > expiresIn+time.Duration(jitter) {
if d := time.Until(cert.Leaf.NotAfter); d > expiresIn+time.Duration(jitter) {
ui.Printf("certificate not renewed: expires in %s\n", d.Round(time.Second))
return nil
}
Expand Down Expand Up @@ -377,6 +377,8 @@ type renewer struct {
transport *http.Transport
key crypto.PrivateKey
offline bool
cert tls.Certificate
caURL *url.URL
}

func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile string) (*renewer, error) {
Expand All @@ -392,12 +394,15 @@ func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile s
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: rootCAs,
PreferServerCipherSuites: true,
},
}

if time.Now().Before(cert.Leaf.NotAfter) {
tr.TLSClientConfig.Certificates = []tls.Certificate{cert}
}

var client cautils.CaClient
offline := ctx.Bool("offline")
if offline {
Expand All @@ -416,16 +421,27 @@ func newRenewer(ctx *cli.Context, caURL string, cert tls.Certificate, rootFile s
}
}

u, err := url.Parse(client.GetCaURL())
if err != nil {
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,
}, nil
}

func (r *renewer) Renew(outFile string) (*api.SignResponse, error) {
resp, err := r.client.Renew(r.transport)
func (r *renewer) Renew(outFile string) (resp *api.SignResponse, err error) {
if time.Now().After(r.cert.Leaf.NotAfter) {
resp, err = r.RenewAfterExpiry(r.cert)
} else {
resp, err = r.client.Renew(r.transport)
}
if err != nil {
return nil, errors.Wrap(err, "error renewing certificate")
}
Expand Down Expand Up @@ -515,6 +531,7 @@ func (r *renewer) RenewAndPrepareNext(outFile string, expiresIn, renewPeriod tim
}

// Prepare next transport
r.cert = cert
r.transport.TLSClientConfig.Certificates = []tls.Certificate{cert}

// Get next renew duration
Expand Down Expand Up @@ -558,6 +575,40 @@ func (r *renewer) Daemon(outFile string, next, expiresIn, renewPeriod time.Durat
}
}

// RenewAfterExpiry creates an authorization token with the given certificate
// and attempts to renew the expired certificate.
func (r *renewer) RenewAfterExpiry(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)
}

func tlsLoadX509KeyPair(certFile, keyFile, passFile string) (tls.Certificate, error) {
x509Chain, err := pemutil.ReadCertificateBundle(certFile)
if err != nil {
Expand Down
11 changes: 10 additions & 1 deletion command/ca/token.go
Expand Up @@ -31,7 +31,7 @@ func tokenCommand() cli.Command {
[**--not-before**=<time|duration>] [**--not-after**=<time|duration>]
[**--password-file**=<file>] [**--provisioner-password-file**=<file>]
[**--output-file**=<file>] [**--key**=<file>] [**--san**=<SAN>] [**--offline**]
[**--revoke**] [**--x5c-cert**=<file>] [**--x5c-key**=<file>]
[**--revoke**] [**--x5c-cert**=<file>] [**--x5c-key**=<file>] [**--x5c-insecure**]
[**--sshpop-cert**=<file>] [**--sshpop-key**=<file>]
[**--ssh**] [**--host**] [**--principal**=<name>] [**--k8ssa-token-path**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Expand Down Expand Up @@ -137,6 +137,12 @@ $ step ca token max@smallstep.com --ssh
Get a new token for an SSH host certificate:
'''
$ step ca token my-remote.hostname --ssh --host
'''
Generate a renew token and use it in a renew after expiry request:
'''
$ TOKEN=$(step ca token --x5c-cert internal.crt --x5c-key internal.key --renew internal.example.com)
$ curl -X POST -H "Authorization: Bearer $TOKEN" https://ca.example.com/1.0/renew
'''`,
Flags: []cli.Flag{
certNotAfterFlag,
Expand Down Expand Up @@ -166,6 +172,7 @@ multiple principals.`,
flags.ProvisionerPasswordFile,
flags.X5cCert,
flags.X5cKey,
flags.X5cInsecure,
flags.SSHPOPCert,
flags.SSHPOPKey,
flags.NebulaCert,
Expand Down Expand Up @@ -259,6 +266,8 @@ func tokenAction(ctx *cli.Context) error {
switch {
case isRevoke:
typ = cautils.RevokeType
case isRenew:
typ = cautils.RenewType
default:
typ = cautils.SignType
}
Expand Down
26 changes: 15 additions & 11 deletions command/crypto/jwt/sign.go
Expand Up @@ -27,7 +27,8 @@ func signCommand() cli.Command {
[**--exp**=<expiration>] [**--iat**=<issued_at>] [**--nbf**=<not-before>]
[**--key**=<file>] [**--jwks**=<jwks>] [**--kid**=<kid>] [**--jti**=<jti>]
[**--header=<key=value>**] [**--password-file**=<file>]
[**--x5c-cert**=<file>] [**--x5c-key**=<file>] [**--x5t-cert**=<file>] [**--x5t-key**=<file>]`,
[**--x5c-cert**=<file>] [**--x5c-key**=<file>] [**--x5c-insecure**]
[**--x5t-cert**=<file>] [**--x5t-key**=<file>]`,
Description: `**step crypto jwt sign** command generates a signed JSON Web Token (JWT) by
computing a digital signature or message authentication code for a JSON
payload. By default, the payload to sign is read from STDIN and the JWT will
Expand Down Expand Up @@ -207,6 +208,7 @@ the **"kid"** member of one of the JWKs in the JWK Set.`,
},
flags.X5cCert,
flags.X5tCert,
flags.X5cInsecure,
},
}
}
Expand Down Expand Up @@ -234,6 +236,7 @@ func signAction(ctx *cli.Context) error {

x5cCertFile, x5cKeyFile := ctx.String("x5c-cert"), ctx.String("x5c-key")
x5tCertFile, x5tKeyFile := ctx.String("x5t-cert"), ctx.String("x5t-key")

key := ctx.String("key")
jwks := ctx.String("jwks")
kid := ctx.String("kid")
Expand Down Expand Up @@ -352,8 +355,6 @@ func signAction(ctx *cli.Context) error {
}
}

headers := ctx.StringSlice("header")

// Add claims
c := &jose.Claims{
Issuer: ctx.String("iss"),
Expand Down Expand Up @@ -401,22 +402,25 @@ func signAction(ctx *cli.Context) error {
so.WithHeader("kid", jwk.KeyID)
}

if len(headers) > 0 {
for _, s := range headers {
i := strings.Index(s, "=")
if i == -1 {
return errs.InvalidFlagValue(ctx, "set", s, "")
}
so.WithHeader(jose.HeaderKey(s[:i]), s[i+1:])
// Add extra headers. Currently only string headers are supported.
for _, s := range ctx.StringSlice("header") {
i := strings.Index(s, "=")
if i == -1 {
return errs.InvalidFlagValue(ctx, "header", s, "")
}
so.WithHeader(jose.HeaderKey(s[:i]), s[i+1:])
}

if isX5C {
certStrs, err := jose.ValidateX5C(x5cCertFile, jwk.Key)
if err != nil {
return errors.Wrap(err, "error validating x5c certificate chain and key for use in x5c header")
}
so.WithHeader("x5c", certStrs)
if ctx.Bool("x5c-insecure") {
so.WithHeader("x5cInsecure", certStrs)
} else {
so.WithHeader("x5c", certStrs)
}
}

if isX5T {
Expand Down
7 changes: 7 additions & 0 deletions flags/flags.go
Expand Up @@ -269,6 +269,13 @@ be stored in the 'x5c' header.`,
be stored in the 'x5c' header.`,
}

// X5cInsecure is a cli.Flag used to set the JWT header x5cInsecure instead
// of x5c when --x5c-cert is used.
X5cInsecure = cli.BoolFlag{
Name: "x5c-insecure",
Usage: "Use the JWT header 'x5cInsecure' instead of 'x5c'.",
}

// X5tCert is a cli.Flag used to pass the x5t header certificate thumbprint
// for a JWS or JWT.
X5tCert = cli.StringFlag{
Expand Down
12 changes: 6 additions & 6 deletions go.mod
Expand Up @@ -17,20 +17,20 @@ require (
github.com/shurcooL/sanitized_anchor_name v1.0.0
github.com/slackhq/nebula v1.5.2
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262
github.com/smallstep/certificates v0.18.2
github.com/smallstep/certificates v0.18.3-0.20220413221949-6331041b2b62
github.com/smallstep/certinfo v1.6.0
github.com/smallstep/truststore v0.11.0
github.com/smallstep/zcrypto v0.0.0-20210924233136-66c2600f6e71
github.com/smallstep/zlint v0.0.0-20180727184541-d84eaafe274f
github.com/stretchr/testify v1.7.0
github.com/stretchr/testify v1.7.1
github.com/urfave/cli v1.22.5
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352
go.step.sm/cli-utils v0.7.2
go.step.sm/crypto v0.15.0
go.step.sm/linkedca v0.10.0
go.step.sm/crypto v0.16.1
go.step.sm/linkedca v0.15.0
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd
golang.org/x/sys v0.0.0-20220209214540-3681064d5158
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
google.golang.org/protobuf v1.27.1
gopkg.in/square/go-jose.v2 v2.6.0
Expand Down

0 comments on commit b6adb57

Please sign in to comment.