first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+45
View File
@@ -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))
}
+301
View File
@@ -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)
}
+143
View File
@@ -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
}
+67
View File
@@ -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
}