feat: support custom duration for certificate (#1925)

This commit is contained in:
Ludovic Fernandez 2023-05-28 16:45:48 +02:00 committed by GitHub
parent 8bf0cee70e
commit c341e6a381
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 268 additions and 40 deletions

View file

@ -4,14 +4,26 @@ import (
"encoding/base64"
"errors"
"net"
"time"
"github.com/go-acme/lego/v4/acme"
)
// OrderOptions used to create an order (optional).
type OrderOptions struct {
NotBefore time.Time
NotAfter time.Time
}
type OrderService service
// New Creates a new order.
func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) {
return o.NewWithOptions(domains, nil)
}
// NewWithOptions Creates a new order.
func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acme.ExtendedOrder, error) {
var identifiers []acme.Identifier
for _, domain := range domains {
ident := acme.Identifier{Value: domain, Type: "dns"}
@ -25,6 +37,16 @@ func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) {
orderReq := acme.Order{Identifiers: identifiers}
if opts != nil {
if !opts.NotAfter.IsZero() {
orderReq.NotAfter = opts.NotAfter.Format(time.RFC3339)
}
if !opts.NotBefore.IsZero() {
orderReq.NotBefore = opts.NotBefore.Format(time.RFC3339)
}
}
var order acme.Order
resp, err := o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order)
if err != nil {

View file

@ -7,6 +7,7 @@ import (
"io"
"net/http"
"testing"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/platform/tester"
@ -15,7 +16,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestOrderService_New(t *testing.T) {
func TestOrderService_NewWithOptions(t *testing.T) {
mux, apiURL := tester.SetupFakeAPI(t)
// small value keeps test fast
@ -42,8 +43,15 @@ func TestOrderService_New(t *testing.T) {
}
err = tester.WriteJSONResponse(w, acme.Order{
Status: acme.StatusValid,
Identifiers: order.Identifiers,
Status: acme.StatusValid,
Expires: order.Expires,
Identifiers: order.Identifiers,
NotBefore: order.NotBefore,
NotAfter: order.NotAfter,
Error: order.Error,
Authorizations: order.Authorizations,
Finalize: order.Finalize,
Certificate: order.Certificate,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -54,16 +62,48 @@ func TestOrderService_New(t *testing.T) {
core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
require.NoError(t, err)
order, err := core.Orders.New([]string{"example.com"})
require.NoError(t, err)
expected := acme.ExtendedOrder{
Order: acme.Order{
Status: "valid",
Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}},
testCases := []struct {
desc string
opts *OrderOptions
expected acme.ExtendedOrder
}{
{
desc: "simple",
expected: acme.ExtendedOrder{
Order: acme.Order{
Status: "valid",
Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}},
},
},
},
{
desc: "with options",
opts: &OrderOptions{
NotBefore: time.Date(2023, 1, 1, 1, 0, 0, 0, time.UTC),
NotAfter: time.Date(2023, 1, 2, 1, 0, 0, 0, time.UTC),
},
expected: acme.ExtendedOrder{
Order: acme.Order{
Status: "valid",
Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}},
NotBefore: "2023-01-01T01:00:00Z",
NotAfter: "2023-01-02T01:00:00Z",
},
},
},
}
assert.Equal(t, expected, order)
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
order, err := core.Orders.NewWithOptions([]string{"example.com"}, test.opts)
require.NoError(t, err)
assert.Equal(t, test.expected, order)
})
}
}
func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) {

View file

@ -54,10 +54,13 @@ type Resource struct {
// If `AlwaysDeactivateAuthorizations` is true, the authorizations are also relinquished if the obtain request was successful.
// See https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2.
type ObtainRequest struct {
Domains []string
Domains []string
PrivateKey crypto.PrivateKey
MustStaple bool
NotBefore time.Time
NotAfter time.Time
Bundle bool
PrivateKey crypto.PrivateKey
MustStaple bool
PreferredChain string
AlwaysDeactivateAuthorizations bool
}
@ -69,7 +72,10 @@ type ObtainRequest struct {
// If `AlwaysDeactivateAuthorizations` is true, the authorizations are also relinquished if the obtain request was successful.
// See https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2.
type ObtainForCSRRequest struct {
CSR *x509.CertificateRequest
CSR *x509.CertificateRequest
NotBefore time.Time
NotAfter time.Time
Bundle bool
PreferredChain string
AlwaysDeactivateAuthorizations bool
@ -117,7 +123,12 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) {
log.Infof("[%s] acme: Obtaining SAN certificate", strings.Join(domains, ", "))
}
order, err := c.core.Orders.New(domains)
orderOpts := &api.OrderOptions{
NotBefore: request.NotBefore,
NotAfter: request.NotAfter,
}
order, err := c.core.Orders.NewWithOptions(domains, orderOpts)
if err != nil {
return nil, err
}
@ -182,7 +193,12 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error)
log.Infof("[%s] acme: Obtaining SAN certificate given a CSR", strings.Join(domains, ", "))
}
order, err := c.core.Orders.New(domains)
orderOpts := &api.OrderOptions{
NotBefore: request.NotBefore,
NotAfter: request.NotAfter,
}
order, err := c.core.Orders.NewWithOptions(domains, orderOpts)
if err != nil {
return nil, err
}
@ -388,6 +404,18 @@ func (c *Certifier) RevokeWithReason(cert []byte, reason *uint) error {
return c.core.Certificates.Revoke(revokeMsg)
}
// RenewOptions options used by Certifier.RenewWithOptions.
type RenewOptions struct {
NotBefore time.Time
NotAfter time.Time
// If true, the []byte contains both the issuer certificate and your issued certificate as a bundle.
Bundle bool
PreferredChain string
AlwaysDeactivateAuthorizations bool
// Not supported for CSR request.
MustStaple bool
}
// Renew takes a Resource and tries to renew the certificate.
//
// If the renewal process succeeds, the new certificate will be returned in a new CertResource.
@ -398,7 +426,26 @@ func (c *Certifier) RevokeWithReason(cert []byte, reason *uint) error {
// If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle.
//
// For private key reuse the PrivateKey property of the passed in Resource should be non-nil.
// Deprecated: use RenewWithOptions instead.
func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool, preferredChain string) (*Resource, error) {
return c.RenewWithOptions(certRes, &RenewOptions{
Bundle: bundle,
PreferredChain: preferredChain,
MustStaple: mustStaple,
})
}
// RenewWithOptions takes a Resource and tries to renew the certificate.
//
// If the renewal process succeeds, the new certificate will be returned in a new CertResource.
// Please be aware that this function will return a new certificate in ANY case that is not an error.
// If the server does not provide us with a new cert on a GET request to the CertURL
// this function will start a new-cert flow where a new certificate gets generated.
//
// If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle.
//
// For private key reuse the PrivateKey property of the passed in Resource should be non-nil.
func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (*Resource, error) {
// Input certificate is PEM encoded.
// Decode it here as we may need the decoded cert later on in the renewal process.
// The input may be a bundle or a single certificate.
@ -425,11 +472,17 @@ func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool, preferredCh
return nil, errP
}
return c.ObtainForCSR(ObtainForCSRRequest{
CSR: csr,
Bundle: bundle,
PreferredChain: preferredChain,
})
request := ObtainForCSRRequest{CSR: csr}
if options != nil {
request.NotBefore = options.NotBefore
request.NotAfter = options.NotAfter
request.Bundle = options.Bundle
request.PreferredChain = options.PreferredChain
request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations
}
return c.ObtainForCSR(request)
}
var privateKey crypto.PrivateKey
@ -440,14 +493,21 @@ func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool, preferredCh
}
}
query := ObtainRequest{
Domains: certcrypto.ExtractDomains(x509Cert),
Bundle: bundle,
PrivateKey: privateKey,
MustStaple: mustStaple,
PreferredChain: preferredChain,
request := ObtainRequest{
Domains: certcrypto.ExtractDomains(x509Cert),
PrivateKey: privateKey,
}
return c.Obtain(query)
if options != nil {
request.MustStaple = options.MustStaple
request.NotBefore = options.NotBefore
request.NotAfter = options.NotAfter
request.Bundle = options.Bundle
request.PreferredChain = options.PreferredChain
request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations
}
return c.Obtain(request)
}
// GetOCSP takes a PEM encoded cert or cert bundle returning the raw OCSP response,

View file

@ -75,9 +75,15 @@ func createRenew() *cli.Command {
Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate." +
" Only works if the CSR is generated by lego.",
},
&cli.StringFlag{
Name: "renew-hook",
Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.",
&cli.TimestampFlag{
Name: "not-before",
Usage: "Set the notBefore field in the certificate (RFC3339 format)",
Layout: time.RFC3339,
},
&cli.TimestampFlag{
Name: "not-after",
Usage: "Set the notAfter field in the certificate (RFC3339 format)",
Layout: time.RFC3339,
},
&cli.StringFlag{
Name: "preferred-chain",
@ -88,6 +94,10 @@ func createRenew() *cli.Command {
Name: "always-deactivate-authorizations",
Usage: "Force the authorizations to be relinquished even if the certificate request was successful.",
},
&cli.StringFlag{
Name: "renew-hook",
Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.",
},
&cli.BoolFlag{
Name: "no-random-sleep",
Usage: "Do not add a random sleep before the renewal." +
@ -188,12 +198,15 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
request := certificate.ObtainRequest{
Domains: merge(certDomains, domains),
Bundle: bundle,
PrivateKey: privateKey,
MustStaple: ctx.Bool("must-staple"),
NotBefore: getTime(ctx, "not-before"),
NotAfter: getTime(ctx, "not-after"),
Bundle: bundle,
PreferredChain: ctx.String("preferred-chain"),
AlwaysDeactivateAuthorizations: ctx.Bool("always-deactivate-authorizations"),
}
certRes, err := client.Certificate.Obtain(request)
if err != nil {
log.Fatal(err)
@ -265,12 +278,16 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours()))
certRes, err := client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{
request := certificate.ObtainForCSRRequest{
CSR: csr,
NotBefore: getTime(ctx, "not-before"),
NotAfter: getTime(ctx, "not-after"),
Bundle: bundle,
PreferredChain: ctx.String("preferred-chain"),
AlwaysDeactivateAuthorizations: ctx.Bool("always-deactivate-authorizations"),
})
}
certRes, err := client.Certificate.ObtainForCSR(request)
if err != nil {
log.Fatal(err)
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strings"
"time"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
@ -40,9 +41,15 @@ func createRun() *cli.Command {
Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate." +
" Only works if the CSR is generated by lego.",
},
&cli.StringFlag{
Name: "run-hook",
Usage: "Define a hook. The hook is executed when the certificates are effectively created.",
&cli.TimestampFlag{
Name: "not-before",
Usage: "Set the notBefore field in the certificate (RFC3339 format)",
Layout: time.RFC3339,
},
&cli.TimestampFlag{
Name: "not-after",
Usage: "Set the notAfter field in the certificate (RFC3339 format)",
Layout: time.RFC3339,
},
&cli.StringFlag{
Name: "preferred-chain",
@ -53,6 +60,10 @@ func createRun() *cli.Command {
Name: "always-deactivate-authorizations",
Usage: "Force the authorizations to be relinquished even if the certificate request was successful.",
},
&cli.StringFlag{
Name: "run-hook",
Usage: "Define a hook. The hook is executed when the certificates are effectively created.",
},
},
}
}
@ -177,6 +188,17 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso
PreferredChain: ctx.String("preferred-chain"),
AlwaysDeactivateAuthorizations: ctx.Bool("always-deactivate-authorizations"),
}
notBefore := ctx.Timestamp("not-before")
if notBefore != nil {
request.NotBefore = *notBefore
}
notAfter := ctx.Timestamp("not-after")
if notAfter != nil {
request.NotAfter = *notAfter
}
return client.Certificate.Obtain(request)
}
@ -187,10 +209,14 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso
}
// obtain a certificate for this CSR
return client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{
request := certificate.ObtainForCSRRequest{
CSR: csr,
NotBefore: getTime(ctx, "not-before"),
NotAfter: getTime(ctx, "not-after"),
Bundle: bundle,
PreferredChain: ctx.String("preferred-chain"),
AlwaysDeactivateAuthorizations: ctx.Bool("always-deactivate-authorizations"),
})
}
return client.Certificate.ObtainForCSR(request)
}

View file

@ -1,6 +1,8 @@
package cmd
import (
"time"
"github.com/go-acme/lego/v4/lego"
"github.com/urfave/cli/v2"
"software.sslmate.com/src/go-pkcs12"
@ -142,3 +144,11 @@ func CreateFlags(defaultPath string) []cli.Flag {
},
}
}
func getTime(ctx *cli.Context, name string) time.Time {
value := ctx.Timestamp(name)
if value == nil {
return time.Time{}
}
return *value
}

View file

@ -63,6 +63,8 @@ OPTIONS:
--always-deactivate-authorizations value Force the authorizations to be relinquished even if the certificate request was successful.
--must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. (default: false)
--no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. (default: false)
--not-after value Set the notAfter field in the certificate
--not-before value Set the notBefore field in the certificate
--preferred-chain value If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used.
--run-hook value Define a hook. The hook is executed when the certificates are effectively created.
"""
@ -85,6 +87,8 @@ OPTIONS:
--must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. (default: false)
--no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. (default: false)
--no-random-sleep Do not add a random sleep before the renewal. We do not recommend using this flag if you are doing your renewals in an automated way. (default: false)
--not-after value Set the notAfter field in the certificate
--not-before value Set the notBefore field in the certificate
--preferred-chain value If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used.
--renew-hook value Define a hook. The hook is executed only when the certificates are effectively renewed.
--reuse-key Used to indicate you want to reuse your current private key for the new certificate. (default: false)

View file

@ -8,7 +8,9 @@ import (
"fmt"
"os"
"testing"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/http01"
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
@ -255,6 +257,53 @@ func TestChallengeHTTP_Client_Obtain(t *testing.T) {
assert.Empty(t, resource.CSR)
}
func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
user := &fakeUser{privateKey: privateKey}
config := lego.NewConfig(user)
config.CADirURL = load.PebbleOptions.HealthCheckURL
client, err := lego.NewClient(config)
require.NoError(t, err)
err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002"))
require.NoError(t, err)
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
require.NoError(t, err)
user.registration = reg
now := time.Now().UTC()
request := certificate.ObtainRequest{
Domains: []string{"acme.wtf"},
NotBefore: now.Add(1 * time.Hour),
NotAfter: now.Add(2 * time.Hour),
Bundle: true,
}
resource, err := client.Certificate.Obtain(request)
require.NoError(t, err)
require.NotNil(t, resource)
assert.Equal(t, "acme.wtf", resource.Domain)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
assert.NotEmpty(t, resource.Certificate)
assert.NotEmpty(t, resource.IssuerCertificate)
assert.Empty(t, resource.CSR)
cert, err := certcrypto.ParsePEMCertificate(resource.Certificate)
require.NoError(t, err)
assert.WithinDuration(t, now.Add(1*time.Hour), cert.NotBefore, 1*time.Second)
assert.WithinDuration(t, now.Add(2*time.Hour), cert.NotAfter, 1*time.Second)
}
func TestChallengeHTTP_Client_Registration_QueryRegistration(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
require.NoError(t, err)