From c341e6a381801167e9ead128bc65740050f99e32 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Sun, 28 May 2023 16:45:48 +0200 Subject: [PATCH] feat: support custom duration for certificate (#1925) --- acme/api/order.go | 22 +++++++++ acme/api/order_test.go | 62 +++++++++++++++++++----- certificate/certificates.go | 96 ++++++++++++++++++++++++++++++------- cmd/cmd_renew.go | 29 ++++++++--- cmd/cmd_run.go | 36 ++++++++++++-- cmd/flags.go | 10 ++++ docs/data/zz_cli_help.toml | 4 ++ e2e/challenges_test.go | 49 +++++++++++++++++++ 8 files changed, 268 insertions(+), 40 deletions(-) diff --git a/acme/api/order.go b/acme/api/order.go index 446f6d1a..fe1be94f 100644 --- a/acme/api/order.go +++ b/acme/api/order.go @@ -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 { diff --git a/acme/api/order_test.go b/acme/api/order_test.go index cba66eb5..72f4b846 100644 --- a/acme/api/order_test.go +++ b/acme/api/order_test.go @@ -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) { diff --git a/certificate/certificates.go b/certificate/certificates.go index d6ab4b41..f1712550 100644 --- a/certificate/certificates.go +++ b/certificate/certificates.go @@ -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, diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index 87da699e..70848af6 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -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) } diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go index 89c07b3b..38df4bc5 100644 --- a/cmd/cmd_run.go +++ b/cmd/cmd_run.go @@ -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) } diff --git a/cmd/flags.go b/cmd/flags.go index 8413426f..4a516ab4 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -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 +} diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index d99b3f3d..3e3b4d54 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -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) diff --git a/e2e/challenges_test.go b/e2e/challenges_test.go index d768e101..cbf364c5 100644 --- a/e2e/challenges_test.go +++ b/e2e/challenges_test.go @@ -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)