first commit
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ChallengeResponder serves HTTP-01 challenge responses at
|
||||
// /.well-known/acme-challenge/{token} — mount it on the plain :80 listener
|
||||
// (or wherever the CA's HTTP-01 validator will connect) before requesting
|
||||
// challenge validation.
|
||||
type ChallengeResponder struct {
|
||||
mu sync.RWMutex
|
||||
tokens map[string]string // token -> key authorization
|
||||
}
|
||||
|
||||
func NewChallengeResponder() *ChallengeResponder {
|
||||
return &ChallengeResponder{tokens: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (c *ChallengeResponder) Set(token, keyAuthorization string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.tokens[token] = keyAuthorization
|
||||
}
|
||||
|
||||
func (c *ChallengeResponder) Remove(token string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.tokens, token)
|
||||
}
|
||||
|
||||
func (c *ChallengeResponder) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimPrefix(r.URL.Path, "/.well-known/acme-challenge/")
|
||||
c.mu.RLock()
|
||||
keyAuth, ok := c.tokens[token]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Write([]byte(keyAuth))
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Package acme implements an ACME v2 (RFC 8555) client — account
|
||||
// registration, order creation, HTTP-01 challenge response, and
|
||||
// certificate issuance/renewal. Hand-rolled on stdlib crypto/ecdsa +
|
||||
// encoding/json + net/http, including the JWS request signing ACME
|
||||
// requires (RFC 7515 subset: ES256 only, flattened JSON serialization) —
|
||||
// no third-party ACME or JOSE library, matching the project's
|
||||
// dependency-minimal principle.
|
||||
package acme
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AccountKey wraps the ECDSA P-256 key pair ACME accounts are identified
|
||||
// by — generated once per hosted domain (or per instance) and stored
|
||||
// encrypted, same pattern as DKIM keys.
|
||||
type AccountKey struct {
|
||||
Private *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
func GenerateAccountKey() (*AccountKey, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating ACME account key: %w", err)
|
||||
}
|
||||
return &AccountKey{Private: priv}, nil
|
||||
}
|
||||
|
||||
func (k *AccountKey) MarshalPEM() ([]byte, error) {
|
||||
der, err := x509.MarshalECPrivateKey(k.Private)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil
|
||||
}
|
||||
|
||||
func ParseAccountKeyPEM(pemBytes []byte) (*AccountKey, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
priv, err := x509.ParseECPrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing EC private key: %w", err)
|
||||
}
|
||||
return &AccountKey{Private: priv}, nil
|
||||
}
|
||||
|
||||
// jwk is the JSON Web Key representation of the account's public key —
|
||||
// required in the JWS protected header for the very first request
|
||||
// (new-account), before the server has assigned an account URL (kid).
|
||||
type jwk struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
func (k *AccountKey) jwkValue() jwk {
|
||||
size := 32 // P-256 coordinate size in bytes
|
||||
return jwk{
|
||||
Kty: "EC", Crv: "P-256",
|
||||
X: b64(leftPad(k.Private.X.Bytes(), size)),
|
||||
Y: b64(leftPad(k.Private.Y.Bytes(), size)),
|
||||
}
|
||||
}
|
||||
|
||||
// thumbprint computes the JWK thumbprint (RFC 7638) — used as the
|
||||
// "key authorization" suffix for HTTP-01 challenge responses.
|
||||
func (k *AccountKey) thumbprint() string {
|
||||
j := k.jwkValue()
|
||||
// RFC 7638 requires this EXACT key order and no extra whitespace.
|
||||
canonical := fmt.Sprintf(`{"crv":"%s","kty":"%s","x":"%s","y":"%s"}`, j.Crv, j.Kty, j.X, j.Y)
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
return b64(sum[:])
|
||||
}
|
||||
|
||||
// signJWS builds a flattened-JSON-serialization JWS per RFC 7515, signed
|
||||
// with ES256, for one ACME request. Exactly one of useJWK/kid applies:
|
||||
// useJWK for the very first request (new-account), kid for every request
|
||||
// after (identifying the now-registered account by URL).
|
||||
func (k *AccountKey) signJWS(url, nonce string, useJWK bool, kid string, payload []byte) ([]byte, error) {
|
||||
protected := map[string]any{
|
||||
"alg": "ES256",
|
||||
"nonce": nonce,
|
||||
"url": url,
|
||||
}
|
||||
if useJWK {
|
||||
protected["jwk"] = k.jwkValue()
|
||||
} else {
|
||||
protected["kid"] = kid
|
||||
}
|
||||
|
||||
protectedJSON, err := json.Marshal(protected)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling protected header: %w", err)
|
||||
}
|
||||
protectedB64 := b64(protectedJSON)
|
||||
|
||||
var payloadB64 string
|
||||
if payload != nil {
|
||||
payloadB64 = b64(payload)
|
||||
}
|
||||
// A nil payload (POST-as-GET requests) intentionally encodes as "" —
|
||||
// not "null" — per RFC 8555 §6.3.
|
||||
|
||||
signingInput := protectedB64 + "." + payloadB64
|
||||
hash := sha256.Sum256([]byte(signingInput))
|
||||
|
||||
r, s, err := ecdsa.Sign(rand.Reader, k.Private, hash[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing: %w", err)
|
||||
}
|
||||
sigBytes := append(leftPad(r.Bytes(), 32), leftPad(s.Bytes(), 32)...)
|
||||
|
||||
jwsBody := map[string]string{
|
||||
"protected": protectedB64,
|
||||
"payload": payloadB64,
|
||||
"signature": b64(sigBytes),
|
||||
}
|
||||
return json.Marshal(jwsBody)
|
||||
}
|
||||
|
||||
func b64(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func leftPad(b []byte, size int) []byte {
|
||||
if len(b) >= size {
|
||||
return b
|
||||
}
|
||||
out := make([]byte, size)
|
||||
copy(out[size-len(b):], b)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Obtain drives the complete ACME issuance flow for one or more domains:
|
||||
// bootstrap, account registration, order, HTTP-01 challenge response via
|
||||
// responder, finalize, download. The caller is responsible for mounting
|
||||
// responder on a listener the CA's HTTP-01 validator can reach at
|
||||
// http://{domain}/.well-known/acme-challenge/{token} — this function only
|
||||
// populates the token->response map, it doesn't start any listener itself.
|
||||
func Obtain(directoryURL, contactEmail string, domains []string, accountKey *AccountKey, responder *ChallengeResponder) (certPEM, keyPEM []byte, err error) {
|
||||
client := NewClient(directoryURL, accountKey)
|
||||
if err := client.Bootstrap(); err != nil {
|
||||
return nil, nil, fmt.Errorf("bootstrap: %w", err)
|
||||
}
|
||||
if err := client.NewAccount(contactEmail); err != nil {
|
||||
return nil, nil, fmt.Errorf("account registration: %w", err)
|
||||
}
|
||||
|
||||
order, err := client.NewOrder(domains)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating order: %w", err)
|
||||
}
|
||||
|
||||
for _, authzURL := range order.Authorizations {
|
||||
authz, err := client.GetAuthorization(authzURL)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("fetching authorization: %w", err)
|
||||
}
|
||||
if authz.Status == "valid" {
|
||||
continue // already satisfied (e.g. from a very recent prior order)
|
||||
}
|
||||
|
||||
var httpChallenge *Challenge
|
||||
for i := range authz.Challenges {
|
||||
if authz.Challenges[i].Type == "http-01" {
|
||||
httpChallenge = &authz.Challenges[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if httpChallenge == nil {
|
||||
return nil, nil, fmt.Errorf("no http-01 challenge offered for %s", authz.Identifier.Value)
|
||||
}
|
||||
|
||||
keyAuth := client.KeyAuthorization(httpChallenge.Token)
|
||||
responder.Set(httpChallenge.Token, keyAuth)
|
||||
|
||||
if err := client.RespondToChallenge(httpChallenge.URL); err != nil {
|
||||
responder.Remove(httpChallenge.Token)
|
||||
return nil, nil, fmt.Errorf("responding to challenge for %s: %w", authz.Identifier.Value, err)
|
||||
}
|
||||
waitErr := client.WaitForAuthorizationValid(authzURL, 30*time.Second)
|
||||
responder.Remove(httpChallenge.Token)
|
||||
if waitErr != nil {
|
||||
return nil, nil, fmt.Errorf("waiting for validation of %s: %w", authz.Identifier.Value, waitErr)
|
||||
}
|
||||
}
|
||||
|
||||
certPEM, keyPEM, err = client.FinalizeAndDownload(order, domains, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("finalize/download: %w", err)
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
Reference in New Issue
Block a user