boulder/test/load-generator/boulder-calls.go

653 lines
20 KiB
Go

package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
mrand "math/rand/v2"
"net/http"
"time"
"github.com/go-jose/go-jose/v4"
"golang.org/x/crypto/ocsp"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/identifier"
"github.com/letsencrypt/boulder/probs"
"github.com/letsencrypt/boulder/test/load-generator/acme"
)
var (
// stringToOperation maps a configured plan action to a function that can
// operate on a state/context.
stringToOperation = map[string]func(*State, *acmeCache) error{
"newAccount": newAccount,
"getAccount": getAccount,
"newOrder": newOrder,
"fulfillOrder": fulfillOrder,
"finalizeOrder": finalizeOrder,
"revokeCertificate": revokeCertificate,
}
)
// OrderJSON is used because it's awkward to work with core.Order or corepb.Order
// when the API returns a different object than either of these types can represent without
// converting field values. The WFE uses an unexported `orderJSON` type for the
// API results that contain an order. We duplicate it here instead of moving it
// somewhere exported for this one utility.
type OrderJSON struct {
// The URL field isn't returned by the API, we populate it manually with the
// `Location` header.
URL string
Status core.AcmeStatus `json:"status"`
Expires time.Time `json:"expires"`
Identifiers identifier.ACMEIdentifiers `json:"identifiers"`
Authorizations []string `json:"authorizations"`
Finalize string `json:"finalize"`
Certificate string `json:"certificate,omitempty"`
Error *probs.ProblemDetails `json:"error,omitempty"`
}
// getAccount takes a randomly selected v2 account from `state.accts` and puts it
// into `c.acct`. The context `nonceSource` is also populated as convenience.
func getAccount(s *State, c *acmeCache) error {
s.rMu.RLock()
defer s.rMu.RUnlock()
// There must be an existing v2 account in the state
if len(s.accts) == 0 {
return errors.New("no accounts to return")
}
// Select a random account from the state and put it into the context
c.acct = s.accts[mrand.IntN(len(s.accts))]
c.ns = &nonceSource{s: s}
return nil
}
// newAccount puts a V2 account into the provided context. If the state provided
// has too many accounts already (based on `state.NumAccts` and `state.maxRegs`)
// then `newAccount` puts an existing account from the state into the context,
// otherwise it creates a new account and puts it into both the state and the
// context.
func newAccount(s *State, c *acmeCache) error {
// Check the max regs and if exceeded, just return an existing account instead
// of creating a new one.
if s.maxRegs != 0 && s.numAccts() >= s.maxRegs {
return getAccount(s, c)
}
// Create a random signing key
signKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
c.acct = &account{
key: signKey,
}
c.ns = &nonceSource{s: s}
// Prepare an account registration message body
reqBody := struct {
ToSAgreed bool `json:"termsOfServiceAgreed"`
Contact []string
}{
ToSAgreed: true,
}
// Set the account contact email if configured
if s.email != "" {
reqBody.Contact = []string{fmt.Sprintf("mailto:%s", s.email)}
}
reqBodyStr, err := json.Marshal(&reqBody)
if err != nil {
return err
}
// Sign the new account registration body using a JWS with an embedded JWK
// because we do not have a key ID from the server yet.
newAccountURL := s.directory.EndpointURL(acme.NewAccountEndpoint)
jws, err := c.signEmbeddedV2Request(reqBodyStr, newAccountURL)
if err != nil {
return err
}
bodyBuf := []byte(jws.FullSerialize())
resp, err := s.post(
newAccountURL,
bodyBuf,
c.ns,
string(acme.NewAccountEndpoint),
http.StatusCreated)
if err != nil {
return fmt.Errorf("%s, post failed: %s", newAccountURL, err)
}
defer resp.Body.Close()
// Populate the context account's key ID with the Location header returned by
// the server
locHeader := resp.Header.Get("Location")
if locHeader == "" {
return fmt.Errorf("%s, bad response - no Location header with account ID", newAccountURL)
}
c.acct.id = locHeader
// Add the account to the state
s.addAccount(c.acct)
return nil
}
// randDomain generates a random(-ish) domain name as a subdomain of the
// provided base domain.
func randDomain(base string) string {
// This approach will cause some repeat domains but not enough to make rate
// limits annoying!
var bytes [3]byte
_, _ = rand.Read(bytes[:])
return hex.EncodeToString(bytes[:]) + base
}
// newOrder creates a new pending order object for a random set of domains using
// the context's account.
func newOrder(s *State, c *acmeCache) error {
// Pick a random number of names within the constraints of the maxNamesPerCert
// parameter
orderSize := 1 + mrand.IntN(s.maxNamesPerCert-1)
// Generate that many random domain names. There may be some duplicates, we
// don't care. The ACME server will collapse those down for us, how handy!
dnsNames := identifier.ACMEIdentifiers{}
for range orderSize {
dnsNames = append(dnsNames, identifier.NewDNS(randDomain(s.domainBase)))
}
// create the new order request object
initOrder := struct {
Identifiers identifier.ACMEIdentifiers
}{
Identifiers: dnsNames,
}
initOrderStr, err := json.Marshal(&initOrder)
if err != nil {
return err
}
// Sign the new order request with the context account's key/key ID
newOrderURL := s.directory.EndpointURL(acme.NewOrderEndpoint)
jws, err := c.signKeyIDV2Request(initOrderStr, newOrderURL)
if err != nil {
return err
}
bodyBuf := []byte(jws.FullSerialize())
resp, err := s.post(
newOrderURL,
bodyBuf,
c.ns,
string(acme.NewOrderEndpoint),
http.StatusCreated)
if err != nil {
return fmt.Errorf("%s, post failed: %s", newOrderURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%s, bad response: %s", newOrderURL, body)
}
// Unmarshal the Order object
var orderJSON OrderJSON
err = json.Unmarshal(body, &orderJSON)
if err != nil {
return err
}
// Populate the URL of the order from the Location header
orderURL := resp.Header.Get("Location")
if orderURL == "" {
return fmt.Errorf("%s, bad response - no Location header with order ID", newOrderURL)
}
orderJSON.URL = orderURL
// Store the pending order in the context
c.pendingOrders = append(c.pendingOrders, &orderJSON)
return nil
}
// popPendingOrder *removes* a random pendingOrder from the context, returning
// it.
func popPendingOrder(c *acmeCache) *OrderJSON {
orderIndex := mrand.IntN(len(c.pendingOrders))
order := c.pendingOrders[orderIndex]
c.pendingOrders = append(c.pendingOrders[:orderIndex], c.pendingOrders[orderIndex+1:]...)
return order
}
// getAuthorization fetches an authorization by GET-ing the provided URL. It
// records the latency and result of the GET operation in the state.
func getAuthorization(s *State, c *acmeCache, url string) (*core.Authorization, error) {
latencyTag := "/acme/authz/{ID}"
resp, err := postAsGet(s, c, url, latencyTag)
// If there was an error, note the state and return
if err != nil {
return nil, fmt.Errorf("%s bad response: %s", url, err)
}
// Read the response body
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Unmarshal an authorization from the HTTP response body
var authz core.Authorization
err = json.Unmarshal(body, &authz)
if err != nil {
return nil, fmt.Errorf("%s response: %s", url, body)
}
// The Authorization ID is not set in the response so we populate it using the
// URL
authz.ID = url
return &authz, nil
}
// completeAuthorization processes a provided authorization by solving its
// HTTP-01 challenge using the context's account and the state's challenge
// server. Aftering POSTing the authorization's HTTP-01 challenge the
// authorization will be polled waiting for a state change.
func completeAuthorization(authz *core.Authorization, s *State, c *acmeCache) error {
// Skip if the authz isn't pending
if authz.Status != core.StatusPending {
return nil
}
// Find a challenge to solve from the pending authorization using the
// challenge selection strategy from the load-generator state.
chalToSolve, err := s.challStrat.PickChallenge(authz)
if err != nil {
return err
}
// Compute the key authorization from the context account's key
jwk := &jose.JSONWebKey{Key: &c.acct.key.PublicKey}
thumbprint, err := jwk.Thumbprint(crypto.SHA256)
if err != nil {
return err
}
authStr := fmt.Sprintf("%s.%s", chalToSolve.Token, base64.RawURLEncoding.EncodeToString(thumbprint))
// Add the challenge response to the state's test server and defer a clean-up.
switch chalToSolve.Type {
case core.ChallengeTypeHTTP01:
s.challSrv.AddHTTPOneChallenge(chalToSolve.Token, authStr)
defer s.challSrv.DeleteHTTPOneChallenge(chalToSolve.Token)
case core.ChallengeTypeDNS01:
// Compute the digest of the key authorization
h := sha256.New()
h.Write([]byte(authStr))
authorizedKeysDigest := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
domain := "_acme-challenge." + authz.Identifier.Value + "."
s.challSrv.AddDNSOneChallenge(domain, authorizedKeysDigest)
defer s.challSrv.DeleteDNSOneChallenge(domain)
case core.ChallengeTypeTLSALPN01:
s.challSrv.AddTLSALPNChallenge(authz.Identifier.Value, authStr)
defer s.challSrv.DeleteTLSALPNChallenge(authz.Identifier.Value)
default:
return fmt.Errorf("challenge strategy picked challenge with unknown type: %q", chalToSolve.Type)
}
// Prepare the Challenge POST body
jws, err := c.signKeyIDV2Request([]byte(`{}`), chalToSolve.URL)
if err != nil {
return err
}
requestPayload := []byte(jws.FullSerialize())
resp, err := s.post(
chalToSolve.URL,
requestPayload,
c.ns,
"/acme/challenge/{ID}", // We want all challenge POST latencies to be grouped
http.StatusOK,
)
if err != nil {
return err
}
// Read the response body and cleanup when finished
defer resp.Body.Close()
_, err = io.ReadAll(resp.Body)
if err != nil {
return err
}
// Poll the authorization waiting for the challenge response to be recorded in
// a change of state. The polling may sleep and retry a few times if required
err = pollAuthorization(authz, s, c)
if err != nil {
return err
}
// The challenge is completed, the authz is valid
return nil
}
// pollAuthorization GETs a provided authorization up to three times, sleeping
// in between attempts, waiting for the status of the returned authorization to
// be valid. If the status is invalid, or if three GETs do not produce the
// correct authorization state an error is returned. If no error is returned
// then the authorization is valid and ready.
func pollAuthorization(authz *core.Authorization, s *State, c *acmeCache) error {
authzURL := authz.ID
for range 3 {
// Fetch the authz by its URL
authz, err := getAuthorization(s, c, authzURL)
if err != nil {
return nil
}
// If the authz is invalid, abort with an error
if authz.Status == "invalid" {
return fmt.Errorf("Authorization %q failed challenge and is status invalid", authzURL)
}
// If the authz is valid, return with no error - the authz is ready to go!
if authz.Status == "valid" {
return nil
}
// Otherwise sleep and try again
time.Sleep(3 * time.Second)
}
return fmt.Errorf("Timed out polling authorization %q", authzURL)
}
// fulfillOrder processes a pending order from the context, completing each
// authorization's HTTP-01 challenge using the context's account, and finally
// placing the now-ready-to-be-finalized order into the context's list of
// fulfilled orders.
func fulfillOrder(s *State, c *acmeCache) error {
// There must be at least one pending order in the context to fulfill
if len(c.pendingOrders) == 0 {
return errors.New("no pending orders to fulfill")
}
// Get an order to fulfill from the context
order := popPendingOrder(c)
// Each of its authorizations need to be processed
for _, url := range order.Authorizations {
// Fetch the authz by its URL
authz, err := getAuthorization(s, c, url)
if err != nil {
return err
}
// Complete the authorization by solving a challenge
err = completeAuthorization(authz, s, c)
if err != nil {
return err
}
}
// Once all of the authorizations have been fulfilled the order is fulfilled
// and ready for future finalization.
c.fulfilledOrders = append(c.fulfilledOrders, order.URL)
return nil
}
// getOrder GETs an order by URL, returning an OrderJSON object. It tracks the
// latency of the GET operation in the provided state.
func getOrder(s *State, c *acmeCache, url string) (*OrderJSON, error) {
latencyTag := "/acme/order/{ID}"
// POST-as-GET the order URL
resp, err := postAsGet(s, c, url, latencyTag)
// If there was an error, track that result
if err != nil {
return nil, fmt.Errorf("%s bad response: %s", url, err)
}
// Read the response body
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%s, bad response: %s", url, body)
}
// Unmarshal the Order object from the response body
var orderJSON OrderJSON
err = json.Unmarshal(body, &orderJSON)
if err != nil {
return nil, err
}
// Populate the order's URL based on the URL we fetched it from
orderJSON.URL = url
return &orderJSON, nil
}
// pollOrderForCert polls a provided order, waiting for the status to change to
// valid such that a certificate URL for the order is known. Three attempts are
// made to check the order status, sleeping 3s between each. If these attempts
// expire without the status becoming valid an error is returned.
func pollOrderForCert(order *OrderJSON, s *State, c *acmeCache) (*OrderJSON, error) {
for range 3 {
// Fetch the order by its URL
order, err := getOrder(s, c, order.URL)
if err != nil {
return nil, err
}
// If the order is invalid, fail
if order.Status == "invalid" {
return nil, fmt.Errorf("Order %q failed and is status invalid", order.URL)
}
// If the order is valid, return with no error - the authz is ready to go!
if order.Status == "valid" {
return order, nil
}
// Otherwise sleep and try again
time.Sleep(3 * time.Second)
}
return nil, fmt.Errorf("Timed out polling order %q", order.URL)
}
// popFulfilledOrder **removes** a fulfilled order from the context, returning
// it. Fulfilled orders have all of their authorizations satisfied.
func popFulfilledOrder(c *acmeCache) string {
orderIndex := mrand.IntN(len(c.fulfilledOrders))
order := c.fulfilledOrders[orderIndex]
c.fulfilledOrders = append(c.fulfilledOrders[:orderIndex], c.fulfilledOrders[orderIndex+1:]...)
return order
}
// finalizeOrder removes a fulfilled order from the context and POSTs a CSR to
// the order's finalization URL. The CSR's key is set from the state's
// `certKey`. The order is then polled for the status to change to valid so that
// the certificate URL can be added to the context. The context's `certs` list
// is updated with the URL for the order's certificate.
func finalizeOrder(s *State, c *acmeCache) error {
// There must be at least one fulfilled order in the context
if len(c.fulfilledOrders) < 1 {
return errors.New("No fulfilled orders in the context ready to be finalized")
}
// Pop a fulfilled order to process, and then GET its contents
orderID := popFulfilledOrder(c)
order, err := getOrder(s, c, orderID)
if err != nil {
return err
}
if order.Status != core.StatusReady {
return fmt.Errorf("order %s was status %q, expected %q",
orderID, order.Status, core.StatusReady)
}
// Mark down the finalization URL for the order
finalizeURL := order.Finalize
// Pull the values from the order identifiers for use in the CSR
dnsNames := make([]string, len(order.Identifiers))
for i, ident := range order.Identifiers {
dnsNames[i] = ident.Value
}
// Create a CSR using the state's certKey
csr, err := x509.CreateCertificateRequest(
rand.Reader,
&x509.CertificateRequest{DNSNames: dnsNames},
s.certKey,
)
if err != nil {
return err
}
// Create the finalization request body with the encoded CSR
request := fmt.Sprintf(
`{"csr":"%s"}`,
base64.RawURLEncoding.EncodeToString(csr),
)
// Sign the request body with the context's account key/keyID
jws, err := c.signKeyIDV2Request([]byte(request), finalizeURL)
if err != nil {
return err
}
requestPayload := []byte(jws.FullSerialize())
resp, err := s.post(
finalizeURL,
requestPayload,
c.ns,
"/acme/order/finalize", // We want all order finalizations to be grouped.
http.StatusOK,
)
if err != nil {
return err
}
defer resp.Body.Close()
// Read the body to ensure there isn't an error. We don't need the actual
// contents.
_, err = io.ReadAll(resp.Body)
if err != nil {
return err
}
// Poll the order waiting for the certificate to be ready
completedOrder, err := pollOrderForCert(order, s, c)
if err != nil {
return err
}
// The valid order should have a certificate URL
certURL := completedOrder.Certificate
if certURL == "" {
return fmt.Errorf("Order %q was finalized but has no cert URL", order.URL)
}
// Append the certificate URL into the context's list of certificates
c.certs = append(c.certs, certURL)
c.finalizedOrders = append(c.finalizedOrders, order.URL)
return nil
}
// postAsGet performs a POST-as-GET request to the provided URL authenticated by
// the context's account. A HTTP status code other than StatusOK (200)
// in response to a POST-as-GET request is considered an error. The caller is
// responsible for closing the HTTP response body.
//
// See RFC 8555 Section 6.3 for more information on POST-as-GET requests.
func postAsGet(s *State, c *acmeCache, url string, latencyTag string) (*http.Response, error) {
// Create the POST-as-GET request JWS
jws, err := c.signKeyIDV2Request([]byte(""), url)
if err != nil {
return nil, err
}
requestPayload := []byte(jws.FullSerialize())
return s.post(url, requestPayload, c.ns, latencyTag, http.StatusOK)
}
func popCertificate(c *acmeCache) string {
certIndex := mrand.IntN(len(c.certs))
certURL := c.certs[certIndex]
c.certs = append(c.certs[:certIndex], c.certs[certIndex+1:]...)
return certURL
}
func getCert(s *State, c *acmeCache, url string) ([]byte, error) {
latencyTag := "/acme/cert/{serial}"
resp, err := postAsGet(s, c, url, latencyTag)
if err != nil {
return nil, fmt.Errorf("%s bad response: %s", url, err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// revokeCertificate removes a certificate url from the context, retrieves it,
// and sends a revocation request for the certificate to the ACME server.
// The revocation request is signed with the account key rather than the certificate
// key.
func revokeCertificate(s *State, c *acmeCache) error {
if len(c.certs) < 1 {
return errors.New("No certificates in the context that can be revoked")
}
if r := mrand.Float32(); r > s.revokeChance {
return nil
}
certURL := popCertificate(c)
certPEM, err := getCert(s, c, certURL)
if err != nil {
return err
}
pemBlock, _ := pem.Decode(certPEM)
revokeObj := struct {
Certificate string
Reason int
}{
Certificate: base64.URLEncoding.EncodeToString(pemBlock.Bytes),
Reason: ocsp.Unspecified,
}
revokeJSON, err := json.Marshal(revokeObj)
if err != nil {
return err
}
revokeURL := s.directory.EndpointURL(acme.RevokeCertEndpoint)
// TODO(roland): randomly use the certificate key to sign the request instead of
// the account key
jws, err := c.signKeyIDV2Request(revokeJSON, revokeURL)
if err != nil {
return err
}
requestPayload := []byte(jws.FullSerialize())
resp, err := s.post(
revokeURL,
requestPayload,
c.ns,
"/acme/revoke-cert",
http.StatusOK,
)
if err != nil {
return err
}
defer resp.Body.Close()
_, err = io.ReadAll(resp.Body)
if err != nil {
return err
}
return nil
}