From dc992b8d874af8a4fa83bc477a40a9f499bfb8d2 Mon Sep 17 00:00:00 2001 From: bllfr0g <39714379+bllfr0g@users.noreply.github.com> Date: Tue, 28 Jan 2025 23:55:36 -0800 Subject: [PATCH] feat: add support for Profiles Extension (#2415) Co-authored-by: Fernandez Ludovic --- .github/workflows/pr.yml | 4 +- README.md | 1 + acme/api/order.go | 10 +++ acme/commons.go | 11 ++++ certificate/certificates.go | 35 ++++++++--- cmd/cmd_renew.go | 6 ++ cmd/cmd_run.go | 7 +++ docs/content/_index.md | 3 +- e2e/challenges_test.go | 83 +++++++++++++++++++++++++ e2e/dnschallenge/dns_challenges_test.go | 55 ++++++++++++++++ e2e/fixtures/pebble-config-dns.json | 12 +++- e2e/fixtures/pebble-config.json | 12 +++- 12 files changed, 226 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 891e261ed..a8f2ff452 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -46,10 +46,10 @@ jobs: golangci-lint --version - name: Install Pebble - run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@3fe019bbc0a41ed16e2fee31592bb91751acaa47 + run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.7.0 - name: Install challtestsrv - run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@3fe019bbc0a41ed16e2fee31592bb91751acaa47 + run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.7.0 - name: Set up a Memcached server uses: niden/actions-memcached@v7 diff --git a/README.md b/README.md index b779a5863..cdf1805f1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Let's Encrypt client and ACME library written in Go. - Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension - Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses - Support [draft-ietf-acme-ari-03](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension + - Support [draft-aaron-acme-profiles-00](https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/): Profiles Extension - Register with CA - Obtain certificates, both from scratch or with an existing CSR - Renew certificates diff --git a/acme/api/order.go b/acme/api/order.go index 5179d061a..708c35632 100644 --- a/acme/api/order.go +++ b/acme/api/order.go @@ -13,6 +13,12 @@ import ( type OrderOptions struct { NotBefore time.Time NotAfter time.Time + + // A string uniquely identifying the profile + // which will be used to affect issuance of the certificate requested by this Order. + // - https://www.ietf.org/id/draft-aaron-acme-profiles-00.html#section-4 + Profile string + // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 @@ -53,6 +59,10 @@ func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acm if o.core.GetDirectory().RenewalInfo != "" { orderReq.Replaces = opts.ReplacesCertID } + + if opts.Profile != "" { + orderReq.Profile = opts.Profile + } } var order acme.Order diff --git a/acme/commons.go b/acme/commons.go index 39aa35ac8..918cc605f 100644 --- a/acme/commons.go +++ b/acme/commons.go @@ -74,6 +74,11 @@ type Meta struct { // then the CA requires that all new-account requests include an "externalAccountBinding" field // associating the new account with an external account. ExternalAccountRequired bool `json:"externalAccountRequired"` + + // profiles (optional, object): + // A map of profile names to human-readable descriptions of those profiles. + // https://www.ietf.org/id/draft-aaron-acme-profiles-00.html#section-3 + Profiles map[string]string `json:"profiles"` } // ExtendedAccount an extended Account. @@ -148,6 +153,12 @@ type Order struct { // An array of identifier objects that the order pertains to. Identifiers []Identifier `json:"identifiers"` + // profile (string, optional): + // A string uniquely identifying the profile + // which will be used to affect issuance of the certificate requested by this Order. + // https://www.ietf.org/id/draft-aaron-acme-profiles-00.html#section-4 + Profile string `json:"profile,omitempty"` + // notBefore (optional, string): // The requested value of the notBefore field in the certificate, // in the date format defined in [RFC3339]. diff --git a/certificate/certificates.go b/certificate/certificates.go index fc139937b..3d0483b1a 100644 --- a/certificate/certificates.go +++ b/certificate/certificates.go @@ -69,11 +69,18 @@ type ObtainRequest struct { PrivateKey crypto.PrivateKey MustStaple bool - NotBefore time.Time - NotAfter time.Time - Bundle bool - PreferredChain string + NotBefore time.Time + NotAfter time.Time + Bundle bool + PreferredChain string + + // A string uniquely identifying the profile + // which will be used to affect issuance of the certificate requested by this Order. + // - https://www.ietf.org/id/draft-aaron-acme-profiles-00.html#section-4 + Profile string + AlwaysDeactivateAuthorizations bool + // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 @@ -89,11 +96,18 @@ type ObtainRequest struct { type ObtainForCSRRequest struct { CSR *x509.CertificateRequest - NotBefore time.Time - NotAfter time.Time - Bundle bool - PreferredChain string + NotBefore time.Time + NotAfter time.Time + Bundle bool + PreferredChain string + + // A string uniquely identifying the profile + // which will be used to affect issuance of the certificate requested by this Order. + // - https://www.ietf.org/id/draft-aaron-acme-profiles-00.html#section-4 + Profile string + AlwaysDeactivateAuthorizations bool + // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 @@ -154,6 +168,7 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) { orderOpts := &api.OrderOptions{ NotBefore: request.NotBefore, NotAfter: request.NotAfter, + Profile: request.Profile, ReplacesCertID: request.ReplacesCertID, } @@ -220,6 +235,7 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error) orderOpts := &api.OrderOptions{ NotBefore: request.NotBefore, NotAfter: request.NotAfter, + Profile: request.Profile, ReplacesCertID: request.ReplacesCertID, } @@ -437,6 +453,7 @@ type RenewOptions struct { // If true, the []byte contains both the issuer certificate and your issued certificate as a bundle. Bundle bool PreferredChain string + Profile string AlwaysDeactivateAuthorizations bool // Not supported for CSR request. MustStaple bool @@ -505,6 +522,7 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (* request.NotAfter = options.NotAfter request.Bundle = options.Bundle request.PreferredChain = options.PreferredChain + request.Profile = options.Profile request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations } @@ -530,6 +548,7 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (* request.NotAfter = options.NotAfter request.Bundle = options.Bundle request.PreferredChain = options.PreferredChain + request.Profile = options.Profile request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations } diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index f75678ba6..8d2135022 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -92,6 +92,10 @@ func createRenew() *cli.Command { Usage: "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.", }, + &cli.StringFlag{ + Name: flgProfile, + Usage: "If the CA offers multiple certificate profiles (draft-aaron-acme-profiles), choose this one.", + }, &cli.StringFlag{ Name: flgAlwaysDeactivateAuthorizations, Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", @@ -234,6 +238,7 @@ func renewForDomains(ctx *cli.Context, account *Account, keyType certcrypto.KeyT NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), + Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } @@ -317,6 +322,7 @@ func renewForCSR(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), + Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go index 8b57317ec..13757ca2f 100644 --- a/cmd/cmd_run.go +++ b/cmd/cmd_run.go @@ -21,6 +21,7 @@ const ( flgNotBefore = "not-before" flgNotAfter = "not-after" flgPreferredChain = "preferred-chain" + flgProfile = "profile" flgAlwaysDeactivateAuthorizations = "always-deactivate-authorizations" flgRunHook = "run-hook" flgRunHookTimeout = "run-hook-timeout" @@ -68,6 +69,10 @@ func createRun() *cli.Command { Usage: "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.", }, + &cli.StringFlag{ + Name: flgProfile, + Usage: "If the CA offers multiple certificate profiles (draft-aaron-acme-profiles), choose this one.", + }, &cli.StringFlag{ Name: flgAlwaysDeactivateAuthorizations, Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", @@ -201,6 +206,7 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso Bundle: bundle, MustStaple: ctx.Bool(flgMustStaple), PreferredChain: ctx.String(flgPreferredChain), + Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } @@ -230,6 +236,7 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), + Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } diff --git a/docs/content/_index.md b/docs/content/_index.md index 6d9fc3f1a..7cb903aac 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -12,7 +12,8 @@ Let's Encrypt client and ACME library written in Go. - ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html) - Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension - Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): issues certificates for IP addresses - - Support [draft-ietf-acme-ari-01](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension + - Support [draft-ietf-acme-ari-03](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension + - Support [draft-aaron-acme-profiles-00](https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/): Profiles Extension - Register with CA - Obtain certificates, both from scratch or with an existing CSR - Renew certificates diff --git a/e2e/challenges_test.go b/e2e/challenges_test.go index cbf364c57..c54e6a723 100644 --- a/e2e/challenges_test.go +++ b/e2e/challenges_test.go @@ -257,6 +257,45 @@ func TestChallengeHTTP_Client_Obtain(t *testing.T) { assert.Empty(t, resource.CSR) } +func TestChallengeHTTP_Client_Obtain_profile(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, + Profile: "shortlived", + } + 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) @@ -422,6 +461,50 @@ func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) { assert.NotEmpty(t, resource.CSR) } +func TestChallengeTLS_Client_ObtainForCSR_profile(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.SetTLSALPN01Provider(tlsalpn01.NewProviderServer("", "5001")) + require.NoError(t, err) + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + require.NoError(t, err) + user.registration = reg + + csrRaw, err := os.ReadFile("./fixtures/csr.raw") + require.NoError(t, err) + + csr, err := x509.ParseCertificateRequest(csrRaw) + require.NoError(t, err) + + resource, err := client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{ + CSR: csr, + Bundle: true, + Profile: "shortlived", + }) + 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.NotEmpty(t, resource.CSR) +} + func TestRegistrar_UpdateAccount(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) diff --git a/e2e/dnschallenge/dns_challenges_test.go b/e2e/dnschallenge/dns_challenges_test.go index 605a77bd0..4efa9f95e 100644 --- a/e2e/dnschallenge/dns_challenges_test.go +++ b/e2e/dnschallenge/dns_challenges_test.go @@ -124,6 +124,61 @@ func TestChallengeDNS_Client_Obtain(t *testing.T) { assert.Empty(t, resource.CSR) } +func TestChallengeDNS_Client_Obtain_profile(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") }() + + err = os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh") + require.NoError(t, err) + defer func() { _ = os.Unsetenv("EXEC_PATH") }() + + 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 = "https://localhost:15000/dir" + + client, err := lego.NewClient(config) + require.NoError(t, err) + + provider, err := dns.NewDNSChallengeProviderByName("exec") + require.NoError(t, err) + + err = client.Challenge.SetDNS01Provider(provider, + dns01.AddRecursiveNameservers([]string{":8053"}), + dns01.DisableAuthoritativeNssPropagationRequirement()) + require.NoError(t, err) + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + require.NoError(t, err) + user.registration = reg + + domains := []string{"*.légo.acme", "légo.acme"} + + // https://github.com/letsencrypt/pebble/issues/285 + privateKeyCSR, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + request := certificate.ObtainRequest{ + Domains: domains, + Bundle: true, + PrivateKey: privateKeyCSR, + Profile: "shortlived", + } + resource, err := client.Certificate.Obtain(request) + require.NoError(t, err) + + require.NotNil(t, resource) + assert.Equal(t, "*.xn--lgo-bma.acme", resource.Domain) + assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertURL) + assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertStableURL) + assert.NotEmpty(t, resource.Certificate) + assert.NotEmpty(t, resource.IssuerCertificate) + assert.Empty(t, resource.CSR) +} + type fakeUser struct { email string privateKey crypto.PrivateKey diff --git a/e2e/fixtures/pebble-config-dns.json b/e2e/fixtures/pebble-config-dns.json index 4834825a4..dd5b63142 100644 --- a/e2e/fixtures/pebble-config-dns.json +++ b/e2e/fixtures/pebble-config-dns.json @@ -4,6 +4,16 @@ "certificate": "fixtures/certs/localhost/cert.pem", "privateKey": "fixtures/certs/localhost/key.pem", "httpPort": 5004, - "tlsPort": 5003 + "tlsPort": 5003, + "profiles": { + "default": { + "description": "The profile you know and love", + "validityPeriod": 7776000 + }, + "shortlived": { + "description": "A short-lived cert profile, without actual enforcement", + "validityPeriod": 518400 + } + } } } diff --git a/e2e/fixtures/pebble-config.json b/e2e/fixtures/pebble-config.json index f2abe6ab8..dcf659b4c 100644 --- a/e2e/fixtures/pebble-config.json +++ b/e2e/fixtures/pebble-config.json @@ -4,6 +4,16 @@ "certificate": "fixtures/certs/localhost/cert.pem", "privateKey": "fixtures/certs/localhost/key.pem", "httpPort": 5002, - "tlsPort": 5001 + "tlsPort": 5001, + "profiles": { + "default": { + "description": "The profile you know and love", + "validityPeriod": 7776000 + }, + "shortlived": { + "description": "A short-lived cert profile, without actual enforcement", + "validityPeriod": 518400 + } + } } }