Files

302 lines
8.7 KiB
Go
Raw Permalink Normal View History

2026-08-09 18:03:09 +01:00
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)
}