144 lines
4.1 KiB
Go
144 lines
4.1 KiB
Go
// 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
|
|
}
|