Files
gomail/internal/acme/jws.go
T

144 lines
4.1 KiB
Go
Raw Normal View History

2026-08-09 18:03:09 +01:00
// 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
}