package acme import ( "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "time" ) type directory struct { NewNonce string `json:"newNonce"` NewAccount string `json:"newAccount"` NewOrder string `json:"newOrder"` } type Client struct { directoryURL string httpClient *http.Client dir directory accountKey *AccountKey accountURL string nonce string } func NewClient(directoryURL string, accountKey *AccountKey) *Client { return &Client{ directoryURL: directoryURL, httpClient: &http.Client{Timeout: 30 * time.Second}, accountKey: accountKey, } } // Bootstrap fetches the directory and a fresh nonce — call once before any // other method. func (c *Client) Bootstrap() error { resp, err := c.httpClient.Get(c.directoryURL) if err != nil { return fmt.Errorf("fetching ACME directory: %w", err) } defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(&c.dir); err != nil { return fmt.Errorf("parsing ACME directory: %w", err) } nonceResp, err := c.httpClient.Head(c.dir.NewNonce) if err != nil { return fmt.Errorf("fetching initial nonce: %w", err) } defer nonceResp.Body.Close() c.nonce = nonceResp.Header.Get("Replay-Nonce") if c.nonce == "" { return fmt.Errorf("server did not return a Replay-Nonce") } return nil } // post sends a JWS-signed POST and captures the next nonce from the // response for the following request — ACME nonces are single-use. func (c *Client) post(url string, payload []byte) (*http.Response, []byte, error) { useJWK := c.accountURL == "" body, err := c.accountKey.signJWS(url, c.nonce, useJWK, c.accountURL, payload) if err != nil { return nil, nil, fmt.Errorf("signing request: %w", err) } req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, nil, err } req.Header.Set("Content-Type", "application/jose+json") resp, err := c.httpClient.Do(req) if err != nil { return nil, nil, fmt.Errorf("ACME request to %s: %w", url, err) } defer resp.Body.Close() if n := resp.Header.Get("Replay-Nonce"); n != "" { c.nonce = n } respBody, err := io.ReadAll(resp.Body) if err != nil { return resp, nil, fmt.Errorf("reading response body: %w", err) } return resp, respBody, nil } // NewAccount registers (or, per RFC 8555 §7.3.1, retrieves the existing // account for this key if already registered) an ACME account. func (c *Client) NewAccount(contactEmail string) error { payload, err := json.Marshal(map[string]any{ "termsOfServiceAgreed": true, "contact": []string{"mailto:" + contactEmail}, }) if err != nil { return err } resp, body, err := c.post(c.dir.NewAccount, payload) if err != nil { return err } if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return fmt.Errorf("new-account failed: status %d: %s", resp.StatusCode, string(body)) } c.accountURL = resp.Header.Get("Location") if c.accountURL == "" { return fmt.Errorf("server did not return an account URL") } return nil } type Order struct { Status string `json:"status"` Authorizations []string `json:"authorizations"` Finalize string `json:"finalize"` Certificate string `json:"certificate"` orderURL string } func (c *Client) NewOrder(domains []string) (*Order, error) { var idents []map[string]string for _, d := range domains { idents = append(idents, map[string]string{"type": "dns", "value": d}) } payload, err := json.Marshal(map[string]any{"identifiers": idents}) if err != nil { return nil, err } resp, body, err := c.post(c.dir.NewOrder, payload) if err != nil { return nil, err } if resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("new-order failed: status %d: %s", resp.StatusCode, string(body)) } var order Order if err := json.Unmarshal(body, &order); err != nil { return nil, fmt.Errorf("parsing order: %w", err) } order.orderURL = resp.Header.Get("Location") return &order, nil } type Authorization struct { Status string `json:"status"` Identifier struct { Value string `json:"value"` } `json:"identifier"` Challenges []Challenge `json:"challenges"` } type Challenge struct { Type string `json:"type"` URL string `json:"url"` Token string `json:"token"` Status string `json:"status"` } // GetAuthorization fetches one authorization (POST-as-GET, per RFC 8555 §6.3). func (c *Client) GetAuthorization(authzURL string) (*Authorization, error) { resp, body, err := c.post(authzURL, nil) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("get authorization failed: status %d: %s", resp.StatusCode, string(body)) } var authz Authorization if err := json.Unmarshal(body, &authz); err != nil { return nil, fmt.Errorf("parsing authorization: %w", err) } return &authz, nil } // KeyAuthorization builds the value the HTTP-01 challenge response must // serve at /.well-known/acme-challenge/{token} — the token plus a JWK // thumbprint of the account key, per RFC 8555 §8.3. func (c *Client) KeyAuthorization(token string) string { return token + "." + c.accountKey.thumbprint() } // RespondToChallenge tells the server the challenge is ready to be // validated — the caller must have already made the key authorization // available at the HTTP-01 well-known path before calling this. func (c *Client) RespondToChallenge(challengeURL string) error { resp, body, err := c.post(challengeURL, []byte("{}")) if err != nil { return err } if resp.StatusCode != http.StatusOK { return fmt.Errorf("challenge response failed: status %d: %s", resp.StatusCode, string(body)) } return nil } // WaitForAuthorizationValid polls an authorization until it's valid, // invalid, or the timeout elapses. func (c *Client) WaitForAuthorizationValid(authzURL string, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { authz, err := c.GetAuthorization(authzURL) if err != nil { return err } switch authz.Status { case "valid": return nil case "invalid": return fmt.Errorf("authorization for %s became invalid", authz.Identifier.Value) } time.Sleep(200 * time.Millisecond) } return fmt.Errorf("timed out waiting for authorization to become valid") } // FinalizeAndDownload generates a fresh certificate key pair, builds and // submits a CSR, polls the order until the certificate is issued, and // downloads it — returning the PEM-encoded cert chain and the PEM-encoded // private key for the certificate (distinct from the ACME account key). func (c *Client) FinalizeAndDownload(order *Order, domains []string, timeout time.Duration) (certPEM, keyPEM []byte, err error) { certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, nil, fmt.Errorf("generating certificate key: %w", err) } csrDER, err := buildCSR(certKey, domains) if err != nil { return nil, nil, fmt.Errorf("building CSR: %w", err) } payload, err := json.Marshal(map[string]string{"csr": b64(csrDER)}) if err != nil { return nil, nil, err } resp, body, err := c.post(order.Finalize, payload) if err != nil { return nil, nil, err } if resp.StatusCode != http.StatusOK { return nil, nil, fmt.Errorf("finalize failed: status %d: %s", resp.StatusCode, string(body)) } var finalized Order if err := json.Unmarshal(body, &finalized); err != nil { return nil, nil, fmt.Errorf("parsing finalized order: %w", err) } finalized.orderURL = order.orderURL deadline := time.Now().Add(timeout) for finalized.Status != "valid" { if time.Now().After(deadline) { return nil, nil, fmt.Errorf("timed out waiting for order to become valid (status: %s)", finalized.Status) } time.Sleep(200 * time.Millisecond) _, pollBody, err := c.post(finalized.orderURL, nil) if err != nil { return nil, nil, err } if err := json.Unmarshal(pollBody, &finalized); err != nil { return nil, nil, fmt.Errorf("parsing polled order: %w", err) } finalized.orderURL = order.orderURL } certResp, certBody, err := c.post(finalized.Certificate, nil) if err != nil { return nil, nil, err } if certResp.StatusCode != http.StatusOK { return nil, nil, fmt.Errorf("certificate download failed: status %d", certResp.StatusCode) } keyDER, err := x509.MarshalECPrivateKey(certKey) if err != nil { return nil, nil, fmt.Errorf("marshaling certificate key: %w", err) } keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) return certBody, keyPEM, nil } func buildCSR(key *ecdsa.PrivateKey, domains []string) ([]byte, error) { template := &x509.CertificateRequest{ Subject: pkix.Name{CommonName: domains[0]}, DNSNames: domains, } return x509.CreateCertificateRequest(rand.Reader, template, key) }