github.com/letsencrypt/boulder@v0.20251208.0/test/load-generator/boulder-calls.go (about)

     1  package main
     2  
     3  import (
     4  	"crypto"
     5  	"crypto/ecdsa"
     6  	"crypto/elliptic"
     7  	"crypto/rand"
     8  	"crypto/sha256"
     9  	"crypto/x509"
    10  	"encoding/base64"
    11  	"encoding/hex"
    12  	"encoding/json"
    13  	"encoding/pem"
    14  	"errors"
    15  	"fmt"
    16  	"io"
    17  	mrand "math/rand/v2"
    18  	"net/http"
    19  	"time"
    20  
    21  	"github.com/go-jose/go-jose/v4"
    22  
    23  	"github.com/letsencrypt/boulder/core"
    24  	"github.com/letsencrypt/boulder/identifier"
    25  	"github.com/letsencrypt/boulder/probs"
    26  	"github.com/letsencrypt/boulder/revocation"
    27  	"github.com/letsencrypt/boulder/test/load-generator/acme"
    28  )
    29  
    30  var (
    31  	// stringToOperation maps a configured plan action to a function that can
    32  	// operate on a state/context.
    33  	stringToOperation = map[string]func(*State, *acmeCache) error{
    34  		"newAccount":        newAccount,
    35  		"getAccount":        getAccount,
    36  		"newOrder":          newOrder,
    37  		"fulfillOrder":      fulfillOrder,
    38  		"finalizeOrder":     finalizeOrder,
    39  		"revokeCertificate": revokeCertificate,
    40  	}
    41  )
    42  
    43  // OrderJSON is used because it's awkward to work with core.Order or corepb.Order
    44  // when the API returns a different object than either of these types can represent without
    45  // converting field values. The WFE uses an unexported `orderJSON` type for the
    46  // API results that contain an order. We duplicate it here instead of moving it
    47  // somewhere exported for this one utility.
    48  type OrderJSON struct {
    49  	// The URL field isn't returned by the API, we populate it manually with the
    50  	// `Location` header.
    51  	URL            string
    52  	Status         core.AcmeStatus            `json:"status"`
    53  	Expires        time.Time                  `json:"expires"`
    54  	Identifiers    identifier.ACMEIdentifiers `json:"identifiers"`
    55  	Authorizations []string                   `json:"authorizations"`
    56  	Finalize       string                     `json:"finalize"`
    57  	Certificate    string                     `json:"certificate,omitempty"`
    58  	Error          *probs.ProblemDetails      `json:"error,omitempty"`
    59  }
    60  
    61  // getAccount takes a randomly selected v2 account from `state.accts` and puts it
    62  // into `c.acct`. The context `nonceSource` is also populated as convenience.
    63  func getAccount(s *State, c *acmeCache) error {
    64  	s.rMu.RLock()
    65  	defer s.rMu.RUnlock()
    66  
    67  	// There must be an existing v2 account in the state
    68  	if len(s.accts) == 0 {
    69  		return errors.New("no accounts to return")
    70  	}
    71  
    72  	// Select a random account from the state and put it into the context
    73  	c.acct = s.accts[mrand.IntN(len(s.accts))]
    74  	c.ns = &nonceSource{s: s}
    75  	return nil
    76  }
    77  
    78  // newAccount puts a V2 account into the provided context. If the state provided
    79  // has too many accounts already (based on `state.NumAccts` and `state.maxRegs`)
    80  // then `newAccount` puts an existing account from the state into the context,
    81  // otherwise it creates a new account and puts it into both the state and the
    82  // context.
    83  func newAccount(s *State, c *acmeCache) error {
    84  	// Check the max regs and if exceeded, just return an existing account instead
    85  	// of creating a new one.
    86  	if s.maxRegs != 0 && s.numAccts() >= s.maxRegs {
    87  		return getAccount(s, c)
    88  	}
    89  
    90  	// Create a random signing key
    91  	signKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
    92  	if err != nil {
    93  		return err
    94  	}
    95  	c.acct = &account{
    96  		key: signKey,
    97  	}
    98  	c.ns = &nonceSource{s: s}
    99  
   100  	// Prepare an account registration message body
   101  	reqBody := struct {
   102  		ToSAgreed bool `json:"termsOfServiceAgreed"`
   103  		Contact   []string
   104  	}{
   105  		ToSAgreed: true,
   106  	}
   107  	// Set the account contact email if configured
   108  	if s.email != "" {
   109  		reqBody.Contact = []string{fmt.Sprintf("mailto:%s", s.email)}
   110  	}
   111  	reqBodyStr, err := json.Marshal(&reqBody)
   112  	if err != nil {
   113  		return err
   114  	}
   115  
   116  	// Sign the new account registration body using a JWS with an embedded JWK
   117  	// because we do not have a key ID from the server yet.
   118  	newAccountURL := s.directory.EndpointURL(acme.NewAccountEndpoint)
   119  	jws, err := c.signEmbeddedV2Request(reqBodyStr, newAccountURL)
   120  	if err != nil {
   121  		return err
   122  	}
   123  	bodyBuf := []byte(jws.FullSerialize())
   124  
   125  	resp, err := s.post(
   126  		newAccountURL,
   127  		bodyBuf,
   128  		c.ns,
   129  		string(acme.NewAccountEndpoint),
   130  		http.StatusCreated)
   131  	if err != nil {
   132  		return fmt.Errorf("%s, post failed: %s", newAccountURL, err)
   133  	}
   134  	defer resp.Body.Close()
   135  
   136  	// Populate the context account's key ID with the Location header returned by
   137  	// the server
   138  	locHeader := resp.Header.Get("Location")
   139  	if locHeader == "" {
   140  		return fmt.Errorf("%s, bad response - no Location header with account ID", newAccountURL)
   141  	}
   142  	c.acct.id = locHeader
   143  
   144  	// Add the account to the state
   145  	s.addAccount(c.acct)
   146  	return nil
   147  }
   148  
   149  // randDomain generates a random(-ish) domain name as a subdomain of the
   150  // provided base domain.
   151  func randDomain(base string) string {
   152  	// This approach will cause some repeat domains but not enough to make rate
   153  	// limits annoying!
   154  	var bytes [3]byte
   155  	_, _ = rand.Read(bytes[:])
   156  	return hex.EncodeToString(bytes[:]) + base
   157  }
   158  
   159  // newOrder creates a new pending order object for a random set of domains using
   160  // the context's account.
   161  func newOrder(s *State, c *acmeCache) error {
   162  	// Pick a random number of names within the constraints of the maxNamesPerCert
   163  	// parameter
   164  	orderSize := 1 + mrand.IntN(s.maxNamesPerCert-1)
   165  	// Generate that many random domain names. There may be some duplicates, we
   166  	// don't care. The ACME server will collapse those down for us, how handy!
   167  	dnsNames := identifier.ACMEIdentifiers{}
   168  	for range orderSize {
   169  		dnsNames = append(dnsNames, identifier.NewDNS(randDomain(s.domainBase)))
   170  	}
   171  
   172  	// create the new order request object
   173  	initOrder := struct {
   174  		Identifiers identifier.ACMEIdentifiers
   175  	}{
   176  		Identifiers: dnsNames,
   177  	}
   178  	initOrderStr, err := json.Marshal(&initOrder)
   179  	if err != nil {
   180  		return err
   181  	}
   182  
   183  	// Sign the new order request with the context account's key/key ID
   184  	newOrderURL := s.directory.EndpointURL(acme.NewOrderEndpoint)
   185  	jws, err := c.signKeyIDV2Request(initOrderStr, newOrderURL)
   186  	if err != nil {
   187  		return err
   188  	}
   189  	bodyBuf := []byte(jws.FullSerialize())
   190  
   191  	resp, err := s.post(
   192  		newOrderURL,
   193  		bodyBuf,
   194  		c.ns,
   195  		string(acme.NewOrderEndpoint),
   196  		http.StatusCreated)
   197  	if err != nil {
   198  		return fmt.Errorf("%s, post failed: %s", newOrderURL, err)
   199  	}
   200  	defer resp.Body.Close()
   201  	body, err := io.ReadAll(resp.Body)
   202  	if err != nil {
   203  		return fmt.Errorf("%s, bad response: %s", newOrderURL, body)
   204  	}
   205  
   206  	// Unmarshal the Order object
   207  	var orderJSON OrderJSON
   208  	err = json.Unmarshal(body, &orderJSON)
   209  	if err != nil {
   210  		return err
   211  	}
   212  
   213  	// Populate the URL of the order from the Location header
   214  	orderURL := resp.Header.Get("Location")
   215  	if orderURL == "" {
   216  		return fmt.Errorf("%s, bad response - no Location header with order ID", newOrderURL)
   217  	}
   218  	orderJSON.URL = orderURL
   219  
   220  	// Store the pending order in the context
   221  	c.pendingOrders = append(c.pendingOrders, &orderJSON)
   222  	return nil
   223  }
   224  
   225  // popPendingOrder *removes* a random pendingOrder from the context, returning
   226  // it.
   227  func popPendingOrder(c *acmeCache) *OrderJSON {
   228  	orderIndex := mrand.IntN(len(c.pendingOrders))
   229  	order := c.pendingOrders[orderIndex]
   230  	c.pendingOrders = append(c.pendingOrders[:orderIndex], c.pendingOrders[orderIndex+1:]...)
   231  	return order
   232  }
   233  
   234  // getAuthorization fetches an authorization by GET-ing the provided URL. It
   235  // records the latency and result of the GET operation in the state.
   236  func getAuthorization(s *State, c *acmeCache, url string) (*core.Authorization, error) {
   237  	latencyTag := "/acme/authz/{ID}"
   238  	resp, err := postAsGet(s, c, url, latencyTag)
   239  	// If there was an error, note the state and return
   240  	if err != nil {
   241  		return nil, fmt.Errorf("%s bad response: %s", url, err)
   242  	}
   243  
   244  	// Read the response body
   245  	defer resp.Body.Close()
   246  	body, err := io.ReadAll(resp.Body)
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  
   251  	// Unmarshal an authorization from the HTTP response body
   252  	var authz core.Authorization
   253  	err = json.Unmarshal(body, &authz)
   254  	if err != nil {
   255  		return nil, fmt.Errorf("%s response: %s", url, body)
   256  	}
   257  	// The Authorization ID is not set in the response so we populate it using the
   258  	// URL
   259  	authz.ID = url
   260  	return &authz, nil
   261  }
   262  
   263  // completeAuthorization processes a provided authorization by solving its
   264  // HTTP-01 challenge using the context's account and the state's challenge
   265  // server. Aftering POSTing the authorization's HTTP-01 challenge the
   266  // authorization will be polled waiting for a state change.
   267  func completeAuthorization(authz *core.Authorization, s *State, c *acmeCache) error {
   268  	// Skip if the authz isn't pending
   269  	if authz.Status != core.StatusPending {
   270  		return nil
   271  	}
   272  
   273  	// Find a challenge to solve from the pending authorization using the
   274  	// challenge selection strategy from the load-generator state.
   275  	chalToSolve, err := s.challStrat.PickChallenge(authz)
   276  	if err != nil {
   277  		return err
   278  	}
   279  
   280  	// Compute the key authorization from the context account's key
   281  	jwk := &jose.JSONWebKey{Key: &c.acct.key.PublicKey}
   282  	thumbprint, err := jwk.Thumbprint(crypto.SHA256)
   283  	if err != nil {
   284  		return err
   285  	}
   286  	authStr := fmt.Sprintf("%s.%s", chalToSolve.Token, base64.RawURLEncoding.EncodeToString(thumbprint))
   287  
   288  	// Add the challenge response to the state's test server and defer a clean-up.
   289  	switch chalToSolve.Type {
   290  	case core.ChallengeTypeHTTP01:
   291  		s.challSrv.AddHTTPOneChallenge(chalToSolve.Token, authStr)
   292  		defer s.challSrv.DeleteHTTPOneChallenge(chalToSolve.Token)
   293  	case core.ChallengeTypeDNS01:
   294  		// Compute the digest of the key authorization
   295  		h := sha256.New()
   296  		h.Write([]byte(authStr))
   297  		authorizedKeysDigest := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
   298  		domain := "_acme-challenge." + authz.Identifier.Value + "."
   299  		s.challSrv.AddDNSOneChallenge(domain, authorizedKeysDigest)
   300  		defer s.challSrv.DeleteDNSOneChallenge(domain)
   301  	case core.ChallengeTypeTLSALPN01:
   302  		s.challSrv.AddTLSALPNChallenge(authz.Identifier.Value, authStr)
   303  		defer s.challSrv.DeleteTLSALPNChallenge(authz.Identifier.Value)
   304  	default:
   305  		return fmt.Errorf("challenge strategy picked challenge with unknown type: %q", chalToSolve.Type)
   306  	}
   307  
   308  	// Prepare the Challenge POST body
   309  	jws, err := c.signKeyIDV2Request([]byte(`{}`), chalToSolve.URL)
   310  	if err != nil {
   311  		return err
   312  	}
   313  	requestPayload := []byte(jws.FullSerialize())
   314  
   315  	resp, err := s.post(
   316  		chalToSolve.URL,
   317  		requestPayload,
   318  		c.ns,
   319  		"/acme/challenge/{ID}", // We want all challenge POST latencies to be grouped
   320  		http.StatusOK,
   321  	)
   322  	if err != nil {
   323  		return err
   324  	}
   325  
   326  	// Read the response body and cleanup when finished
   327  	defer resp.Body.Close()
   328  	_, err = io.ReadAll(resp.Body)
   329  	if err != nil {
   330  		return err
   331  	}
   332  
   333  	// Poll the authorization waiting for the challenge response to be recorded in
   334  	// a change of state. The polling may sleep and retry a few times if required
   335  	err = pollAuthorization(authz, s, c)
   336  	if err != nil {
   337  		return err
   338  	}
   339  
   340  	// The challenge is completed, the authz is valid
   341  	return nil
   342  }
   343  
   344  // pollAuthorization GETs a provided authorization up to three times, sleeping
   345  // in between attempts, waiting for the status of the returned authorization to
   346  // be valid. If the status is invalid, or if three GETs do not produce the
   347  // correct authorization state an error is returned. If no error is returned
   348  // then the authorization is valid and ready.
   349  func pollAuthorization(authz *core.Authorization, s *State, c *acmeCache) error {
   350  	authzURL := authz.ID
   351  	for range 3 {
   352  		// Fetch the authz by its URL
   353  		authz, err := getAuthorization(s, c, authzURL)
   354  		if err != nil {
   355  			return nil
   356  		}
   357  		// If the authz is invalid, abort with an error
   358  		if authz.Status == "invalid" {
   359  			return fmt.Errorf("Authorization %q failed challenge and is status invalid", authzURL)
   360  		}
   361  		// If the authz is valid, return with no error - the authz is ready to go!
   362  		if authz.Status == "valid" {
   363  			return nil
   364  		}
   365  		// Otherwise sleep and try again
   366  		time.Sleep(3 * time.Second)
   367  	}
   368  	return fmt.Errorf("Timed out polling authorization %q", authzURL)
   369  }
   370  
   371  // fulfillOrder processes a pending order from the context, completing each
   372  // authorization's HTTP-01 challenge using the context's account, and finally
   373  // placing the now-ready-to-be-finalized order into the context's list of
   374  // fulfilled orders.
   375  func fulfillOrder(s *State, c *acmeCache) error {
   376  	// There must be at least one pending order in the context to fulfill
   377  	if len(c.pendingOrders) == 0 {
   378  		return errors.New("no pending orders to fulfill")
   379  	}
   380  
   381  	// Get an order to fulfill from the context
   382  	order := popPendingOrder(c)
   383  
   384  	// Each of its authorizations need to be processed
   385  	for _, url := range order.Authorizations {
   386  		// Fetch the authz by its URL
   387  		authz, err := getAuthorization(s, c, url)
   388  		if err != nil {
   389  			return err
   390  		}
   391  
   392  		// Complete the authorization by solving a challenge
   393  		err = completeAuthorization(authz, s, c)
   394  		if err != nil {
   395  			return err
   396  		}
   397  	}
   398  
   399  	// Once all of the authorizations have been fulfilled the order is fulfilled
   400  	// and ready for future finalization.
   401  	c.fulfilledOrders = append(c.fulfilledOrders, order.URL)
   402  	return nil
   403  }
   404  
   405  // getOrder GETs an order by URL, returning an OrderJSON object. It tracks the
   406  // latency of the GET operation in the provided state.
   407  func getOrder(s *State, c *acmeCache, url string) (*OrderJSON, error) {
   408  	latencyTag := "/acme/order/{ID}"
   409  	// POST-as-GET the order URL
   410  	resp, err := postAsGet(s, c, url, latencyTag)
   411  	// If there was an error, track that result
   412  	if err != nil {
   413  		return nil, fmt.Errorf("%s bad response: %s", url, err)
   414  	}
   415  	// Read the response body
   416  	defer resp.Body.Close()
   417  	body, err := io.ReadAll(resp.Body)
   418  	if err != nil {
   419  		return nil, fmt.Errorf("%s, bad response: %s", url, body)
   420  	}
   421  
   422  	// Unmarshal the Order object from the response body
   423  	var orderJSON OrderJSON
   424  	err = json.Unmarshal(body, &orderJSON)
   425  	if err != nil {
   426  		return nil, err
   427  	}
   428  
   429  	// Populate the order's URL based on the URL we fetched it from
   430  	orderJSON.URL = url
   431  	return &orderJSON, nil
   432  }
   433  
   434  // pollOrderForCert polls a provided order, waiting for the status to change to
   435  // valid such that a certificate URL for the order is known. Three attempts are
   436  // made to check the order status, sleeping 3s between each. If these attempts
   437  // expire without the status becoming valid an error is returned.
   438  func pollOrderForCert(order *OrderJSON, s *State, c *acmeCache) (*OrderJSON, error) {
   439  	for range 3 {
   440  		// Fetch the order by its URL
   441  		order, err := getOrder(s, c, order.URL)
   442  		if err != nil {
   443  			return nil, err
   444  		}
   445  		// If the order is invalid, fail
   446  		if order.Status == "invalid" {
   447  			return nil, fmt.Errorf("Order %q failed and is status invalid", order.URL)
   448  		}
   449  		// If the order is valid, return with no error - the authz is ready to go!
   450  		if order.Status == "valid" {
   451  			return order, nil
   452  		}
   453  		// Otherwise sleep and try again
   454  		time.Sleep(3 * time.Second)
   455  	}
   456  	return nil, fmt.Errorf("Timed out polling order %q", order.URL)
   457  }
   458  
   459  // popFulfilledOrder **removes** a fulfilled order from the context, returning
   460  // it. Fulfilled orders have all of their authorizations satisfied.
   461  func popFulfilledOrder(c *acmeCache) string {
   462  	orderIndex := mrand.IntN(len(c.fulfilledOrders))
   463  	order := c.fulfilledOrders[orderIndex]
   464  	c.fulfilledOrders = append(c.fulfilledOrders[:orderIndex], c.fulfilledOrders[orderIndex+1:]...)
   465  	return order
   466  }
   467  
   468  // finalizeOrder removes a fulfilled order from the context and POSTs a CSR to
   469  // the order's finalization URL. The CSR's key is set from the state's
   470  // `certKey`. The order is then polled for the status to change to valid so that
   471  // the certificate URL can be added to the context. The context's `certs` list
   472  // is updated with the URL for the order's certificate.
   473  func finalizeOrder(s *State, c *acmeCache) error {
   474  	// There must be at least one fulfilled order in the context
   475  	if len(c.fulfilledOrders) < 1 {
   476  		return errors.New("No fulfilled orders in the context ready to be finalized")
   477  	}
   478  
   479  	// Pop a fulfilled order to process, and then GET its contents
   480  	orderID := popFulfilledOrder(c)
   481  	order, err := getOrder(s, c, orderID)
   482  	if err != nil {
   483  		return err
   484  	}
   485  
   486  	if order.Status != core.StatusReady {
   487  		return fmt.Errorf("order %s was status %q, expected %q",
   488  			orderID, order.Status, core.StatusReady)
   489  	}
   490  
   491  	// Mark down the finalization URL for the order
   492  	finalizeURL := order.Finalize
   493  
   494  	// Pull the values from the order identifiers for use in the CSR
   495  	dnsNames := make([]string, len(order.Identifiers))
   496  	for i, ident := range order.Identifiers {
   497  		dnsNames[i] = ident.Value
   498  	}
   499  
   500  	// Create a CSR using the state's certKey
   501  	csr, err := x509.CreateCertificateRequest(
   502  		rand.Reader,
   503  		&x509.CertificateRequest{DNSNames: dnsNames},
   504  		s.certKey,
   505  	)
   506  	if err != nil {
   507  		return err
   508  	}
   509  
   510  	// Create the finalization request body with the encoded CSR
   511  	request := fmt.Sprintf(
   512  		`{"csr":"%s"}`,
   513  		base64.RawURLEncoding.EncodeToString(csr),
   514  	)
   515  
   516  	// Sign the request body with the context's account key/keyID
   517  	jws, err := c.signKeyIDV2Request([]byte(request), finalizeURL)
   518  	if err != nil {
   519  		return err
   520  	}
   521  	requestPayload := []byte(jws.FullSerialize())
   522  
   523  	resp, err := s.post(
   524  		finalizeURL,
   525  		requestPayload,
   526  		c.ns,
   527  		"/acme/order/finalize", // We want all order finalizations to be grouped.
   528  		http.StatusOK,
   529  	)
   530  	if err != nil {
   531  		return err
   532  	}
   533  	defer resp.Body.Close()
   534  	// Read the body to ensure there isn't an error. We don't need the actual
   535  	// contents.
   536  	_, err = io.ReadAll(resp.Body)
   537  	if err != nil {
   538  		return err
   539  	}
   540  
   541  	// Poll the order waiting for the certificate to be ready
   542  	completedOrder, err := pollOrderForCert(order, s, c)
   543  	if err != nil {
   544  		return err
   545  	}
   546  
   547  	// The valid order should have a certificate URL
   548  	certURL := completedOrder.Certificate
   549  	if certURL == "" {
   550  		return fmt.Errorf("Order %q was finalized but has no cert URL", order.URL)
   551  	}
   552  
   553  	// Append the certificate URL into the context's list of certificates
   554  	c.certs = append(c.certs, certURL)
   555  	c.finalizedOrders = append(c.finalizedOrders, order.URL)
   556  	return nil
   557  }
   558  
   559  // postAsGet performs a POST-as-GET request to the provided URL authenticated by
   560  // the context's account. A HTTP status code other than StatusOK (200)
   561  // in response to a POST-as-GET request is considered an error. The caller is
   562  // responsible for closing the HTTP response body.
   563  //
   564  // See RFC 8555 Section 6.3 for more information on POST-as-GET requests.
   565  func postAsGet(s *State, c *acmeCache, url string, latencyTag string) (*http.Response, error) {
   566  	// Create the POST-as-GET request JWS
   567  	jws, err := c.signKeyIDV2Request([]byte(""), url)
   568  	if err != nil {
   569  		return nil, err
   570  	}
   571  	requestPayload := []byte(jws.FullSerialize())
   572  
   573  	return s.post(url, requestPayload, c.ns, latencyTag, http.StatusOK)
   574  }
   575  
   576  func popCertificate(c *acmeCache) string {
   577  	certIndex := mrand.IntN(len(c.certs))
   578  	certURL := c.certs[certIndex]
   579  	c.certs = append(c.certs[:certIndex], c.certs[certIndex+1:]...)
   580  	return certURL
   581  }
   582  
   583  func getCert(s *State, c *acmeCache, url string) ([]byte, error) {
   584  	latencyTag := "/acme/cert/{serial}"
   585  	resp, err := postAsGet(s, c, url, latencyTag)
   586  	if err != nil {
   587  		return nil, fmt.Errorf("%s bad response: %s", url, err)
   588  	}
   589  	defer resp.Body.Close()
   590  	return io.ReadAll(resp.Body)
   591  }
   592  
   593  // revokeCertificate removes a certificate url from the context, retrieves it,
   594  // and sends a revocation request for the certificate to the ACME server.
   595  // The revocation request is signed with the account key rather than the certificate
   596  // key.
   597  func revokeCertificate(s *State, c *acmeCache) error {
   598  	if len(c.certs) < 1 {
   599  		return errors.New("No certificates in the context that can be revoked")
   600  	}
   601  
   602  	if r := mrand.Float32(); r > s.revokeChance {
   603  		return nil
   604  	}
   605  
   606  	certURL := popCertificate(c)
   607  	certPEM, err := getCert(s, c, certURL)
   608  	if err != nil {
   609  		return err
   610  	}
   611  
   612  	pemBlock, _ := pem.Decode(certPEM)
   613  	revokeObj := struct {
   614  		Certificate string
   615  		Reason      revocation.Reason
   616  	}{
   617  		Certificate: base64.URLEncoding.EncodeToString(pemBlock.Bytes),
   618  		Reason:      revocation.Unspecified,
   619  	}
   620  
   621  	revokeJSON, err := json.Marshal(revokeObj)
   622  	if err != nil {
   623  		return err
   624  	}
   625  	revokeURL := s.directory.EndpointURL(acme.RevokeCertEndpoint)
   626  	// TODO(roland): randomly use the certificate key to sign the request instead of
   627  	// the account key
   628  	jws, err := c.signKeyIDV2Request(revokeJSON, revokeURL)
   629  	if err != nil {
   630  		return err
   631  	}
   632  	requestPayload := []byte(jws.FullSerialize())
   633  
   634  	resp, err := s.post(
   635  		revokeURL,
   636  		requestPayload,
   637  		c.ns,
   638  		"/acme/revoke-cert",
   639  		http.StatusOK,
   640  	)
   641  	if err != nil {
   642  		return err
   643  	}
   644  	defer resp.Body.Close()
   645  
   646  	_, err = io.ReadAll(resp.Body)
   647  	if err != nil {
   648  		return err
   649  	}
   650  
   651  	return nil
   652  }