302 lines
8.7 KiB
Go
302 lines
8.7 KiB
Go
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)
|
|
}
|