feat: add support for Profiles Extension (#2415)

Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
This commit is contained in:
bllfr0g 2025-01-28 23:55:36 -08:00 committed by GitHub
commit dc992b8d87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 226 additions and 13 deletions

View file

@ -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

View file

@ -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 ApplicationLayer 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

View file

@ -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

View file

@ -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].

View file

@ -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
}

View file

@ -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),
}

View file

@ -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),
}

View file

@ -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 ApplicationLayer 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

View file

@ -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)

View file

@ -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

View file

@ -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
}
}
}
}

View file

@ -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
}
}
}
}