From 1abda6de6f78cf4e4374fd45bcf5cc7523a34b99 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 6 Feb 2025 01:27:18 +0100 Subject: [PATCH] feat: option to set CSR emails --- certcrypto/crypto.go | 27 +++++++++++--- certcrypto/crypto_test.go | 70 ++++++++++++++++++++++++------------- certificate/certificates.go | 37 +++++++++++++------- e2e/challenges_test.go | 39 +++++++++++++++++++++ 4 files changed, 131 insertions(+), 42 deletions(-) diff --git a/certcrypto/crypto.go b/certcrypto/crypto.go index 43fa774ae1..c6551071e3 100644 --- a/certcrypto/crypto.go +++ b/certcrypto/crypto.go @@ -135,10 +135,26 @@ func GeneratePrivateKey(keyType KeyType) (crypto.PrivateKey, error) { return nil, fmt.Errorf("invalid KeyType: %s", keyType) } +// Deprecated: uses [CreateCSR] instead. func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, mustStaple bool) ([]byte, error) { + return CreateCSR(privateKey, CSROptions{ + Domain: domain, + SAN: san, + MustStaple: mustStaple, + }) +} + +type CSROptions struct { + Domain string + SAN []string + MustStaple bool + EmailAddresses []string +} + +func CreateCSR(privateKey crypto.PrivateKey, opts CSROptions) ([]byte, error) { var dnsNames []string var ipAddresses []net.IP - for _, altname := range san { + for _, altname := range opts.SAN { if ip := net.ParseIP(altname); ip != nil { ipAddresses = append(ipAddresses, ip) } else { @@ -147,12 +163,13 @@ func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, must } template := x509.CertificateRequest{ - Subject: pkix.Name{CommonName: domain}, - DNSNames: dnsNames, - IPAddresses: ipAddresses, + Subject: pkix.Name{CommonName: opts.Domain}, + DNSNames: dnsNames, + EmailAddresses: opts.EmailAddresses, + IPAddresses: ipAddresses, } - if mustStaple { + if opts.MustStaple { template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{ Id: tlsFeatureExtensionOID, Value: ocspMustStapleFeature, diff --git a/certcrypto/crypto_test.go b/certcrypto/crypto_test.go index 7aba8b3781..4768435d18 100644 --- a/certcrypto/crypto_test.go +++ b/certcrypto/crypto_test.go @@ -33,55 +33,75 @@ func TestGenerateCSR(t *testing.T) { testCases := []struct { desc string privateKey crypto.PrivateKey - domain string - san []string - mustStaple bool + opts CSROptions expected expected }{ { desc: "without SAN (nil)", privateKey: privateKey, - domain: "lego.acme", - mustStaple: true, - expected: expected{len: 245}, + opts: CSROptions{ + Domain: "lego.acme", + MustStaple: true, + }, + expected: expected{len: 245}, }, { desc: "without SAN (empty)", privateKey: privateKey, - domain: "lego.acme", - san: []string{}, - mustStaple: true, - expected: expected{len: 245}, + opts: CSROptions{ + Domain: "lego.acme", + SAN: []string{}, + MustStaple: true, + }, + expected: expected{len: 245}, }, { desc: "with SAN", privateKey: privateKey, - domain: "lego.acme", - san: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"}, - mustStaple: true, - expected: expected{len: 296}, + opts: CSROptions{ + Domain: "lego.acme", + SAN: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"}, + MustStaple: true, + }, + expected: expected{len: 296}, }, { desc: "no domain", privateKey: privateKey, - domain: "", - mustStaple: true, - expected: expected{len: 225}, + opts: CSROptions{ + Domain: "", + MustStaple: true, + }, + expected: expected{len: 225}, }, { desc: "no domain with SAN", privateKey: privateKey, - domain: "", - san: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"}, - mustStaple: true, - expected: expected{len: 276}, + opts: CSROptions{ + Domain: "", + SAN: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"}, + MustStaple: true, + }, + expected: expected{len: 276}, }, { desc: "private key nil", privateKey: nil, - domain: "fizz.buzz", - mustStaple: true, - expected: expected{error: true}, + opts: CSROptions{ + Domain: "fizz.buzz", + MustStaple: true, + }, + expected: expected{error: true}, + }, + { + desc: "with email addresses", + privateKey: privateKey, + opts: CSROptions{ + Domain: "example.com", + SAN: []string{"example.org"}, + EmailAddresses: []string{"foo@example.com", "bar@example.com"}, + }, + expected: expected{len: 287}, }, } @@ -89,7 +109,7 @@ func TestGenerateCSR(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - csr, err := GenerateCSR(test.privateKey, test.domain, test.san, test.mustStaple) + csr, err := CreateCSR(test.privateKey, test.opts) if test.expected.error { require.Error(t, err) diff --git a/certificate/certificates.go b/certificate/certificates.go index 3d0483b1a9..56c0817018 100644 --- a/certificate/certificates.go +++ b/certificate/certificates.go @@ -65,9 +65,10 @@ 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 - PrivateKey crypto.PrivateKey - MustStaple bool + Domains []string + PrivateKey crypto.PrivateKey + MustStaple bool + EmailAddresses []string NotBefore time.Time NotAfter time.Time @@ -194,7 +195,7 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) { log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) failures := newObtainError() - cert, err := c.getForOrder(domains, order, request.Bundle, request.PrivateKey, request.MustStaple, request.PreferredChain) + cert, err := c.getForOrder(domains, order, request) if err != nil { for _, auth := range authz { failures.Add(challenge.GetTargetedDomain(auth), err) @@ -280,7 +281,9 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error) return cert, failures.Join() } -func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bundle bool, privateKey crypto.PrivateKey, mustStaple bool, preferredChain string) (*Resource, error) { +func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, request ObtainRequest) (*Resource, error) { + privateKey := request.PrivateKey + if privateKey == nil { var err error privateKey, err = certcrypto.GeneratePrivateKey(c.options.KeyType) @@ -312,13 +315,19 @@ func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bund } } - // TODO: should the CSR be customizable? - csr, err := certcrypto.GenerateCSR(privateKey, commonName, san, mustStaple) + csrOptions := certcrypto.CSROptions{ + Domain: commonName, + SAN: san, + MustStaple: request.MustStaple, + EmailAddresses: request.EmailAddresses, + } + + csr, err := certcrypto.CreateCSR(privateKey, csrOptions) if err != nil { return nil, err } - return c.getForCSR(domains, order, bundle, csr, certcrypto.PEMEncode(privateKey), preferredChain) + return c.getForCSR(domains, order, request.Bundle, csr, certcrypto.PEMEncode(privateKey), request.PreferredChain) } func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr, privateKeyPem []byte, preferredChain string) (*Resource, error) { @@ -451,12 +460,15 @@ 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 - Profile string + Bundle bool + PreferredChain string + + Profile string + AlwaysDeactivateAuthorizations bool // Not supported for CSR request. - MustStaple bool + MustStaple bool + EmailAddresses []string } // Renew takes a Resource and tries to renew the certificate. @@ -548,6 +560,7 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (* request.NotAfter = options.NotAfter request.Bundle = options.Bundle request.PreferredChain = options.PreferredChain + request.EmailAddresses = options.EmailAddresses request.Profile = options.Profile request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations } diff --git a/e2e/challenges_test.go b/e2e/challenges_test.go index 6f5de0d0ff..9b3812ce56 100644 --- a/e2e/challenges_test.go +++ b/e2e/challenges_test.go @@ -297,6 +297,45 @@ func TestChallengeHTTP_Client_Obtain_profile(t *testing.T) { assert.Empty(t, resource.CSR) } +func TestChallengeHTTP_Client_Obtain_emails_csr(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 + + request := certificate.ObtainRequest{ + Domains: []string{"acme.wtf"}, + Bundle: true, + EmailAddresses: []string{"foo@example.com"}, + } + 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) +} + func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err)