// 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 }