first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user