Add ACME (RFC 8555) server and Go client library
Implements full ACME protocol support in Metacrypt:
- internal/acme: core types, JWS verification (ES256/384/512 + RS256),
nonce store, per-mount handler, all RFC 8555 protocol endpoints,
HTTP-01 and DNS-01 challenge validation, EAB management
- internal/server/acme.go: management REST routes (EAB create, config,
list accounts/orders) + ACME protocol route dispatch
- proto/metacrypt/v1/acme.proto: ACMEService (CreateEAB, SetConfig,
ListAccounts, ListOrders) — protocol endpoints are HTTP-only per RFC
- clients/go: new Go module with MCIAS-auth bootstrap, ACME account
registration, certificate issuance/renewal, HTTP-01 and DNS-01
challenge providers
- .claude/launch.json: dev server configuration
EAB is required for all account creation; MCIAS-authenticated users
obtain a single-use KID + HMAC-SHA256 key via POST /v1/acme/{mount}/eab.
This commit is contained in:
274
internal/acme/validate.go
Normal file
274
internal/acme/validate.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// validateChallenge dispatches to the appropriate validator and updates
|
||||
// challenge, authorization, and order state in the barrier.
|
||||
func (h *Handler) validateChallenge(ctx context.Context, chall *Challenge, accountJWK []byte) {
|
||||
// Load the authorization to get the identifier (domain/IP).
|
||||
authz, err := h.loadAuthz(ctx, chall.AuthzID)
|
||||
if err != nil {
|
||||
h.logger.Error("acme: load authz for validation", "id", chall.AuthzID, "error", err)
|
||||
chall.Status = StatusInvalid
|
||||
chall.Error = &ProblemDetail{Type: ProblemServerInternal, Detail: "failed to load authorization"}
|
||||
h.saveChallenge(ctx, chall)
|
||||
return
|
||||
}
|
||||
// Inject the identifier value into the context for validators.
|
||||
ctx = context.WithValue(ctx, ctxKeyDomain, authz.Identifier.Value)
|
||||
|
||||
var validationErr error
|
||||
switch chall.Type {
|
||||
case ChallengeHTTP01:
|
||||
validationErr = validateHTTP01(ctx, chall, accountJWK)
|
||||
case ChallengeDNS01:
|
||||
validationErr = validateDNS01(ctx, chall, accountJWK)
|
||||
default:
|
||||
validationErr = fmt.Errorf("unknown challenge type: %s", chall.Type)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if validationErr == nil {
|
||||
chall.Status = StatusValid
|
||||
chall.ValidatedAt = &now
|
||||
chall.Error = nil
|
||||
} else {
|
||||
h.logger.Info("acme: challenge validation failed",
|
||||
"id", chall.ID, "type", chall.Type, "error", validationErr)
|
||||
chall.Status = StatusInvalid
|
||||
chall.Error = &ProblemDetail{
|
||||
Type: ProblemConnection,
|
||||
Detail: validationErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.saveChallenge(ctx, chall); err != nil {
|
||||
h.logger.Error("acme: save challenge after validation", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the parent authorization.
|
||||
h.updateAuthzStatus(ctx, chall.AuthzID)
|
||||
}
|
||||
|
||||
// updateAuthzStatus recomputes an authorization's status from its challenges,
|
||||
// then propagates any state change to the parent order.
|
||||
func (h *Handler) updateAuthzStatus(ctx context.Context, authzID string) {
|
||||
authz, err := h.loadAuthz(ctx, authzID)
|
||||
if err != nil {
|
||||
h.logger.Error("acme: load authz for status update", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// An authz is valid if any single challenge is valid.
|
||||
// An authz is invalid if all challenges are invalid.
|
||||
anyValid := false
|
||||
allInvalid := true
|
||||
for _, challID := range authz.ChallengeIDs {
|
||||
chall, err := h.loadChallenge(ctx, challID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if chall.Status == StatusValid {
|
||||
anyValid = true
|
||||
allInvalid = false
|
||||
break
|
||||
}
|
||||
if chall.Status != StatusInvalid {
|
||||
allInvalid = false
|
||||
}
|
||||
}
|
||||
|
||||
prevStatus := authz.Status
|
||||
if anyValid {
|
||||
authz.Status = StatusValid
|
||||
} else if allInvalid {
|
||||
authz.Status = StatusInvalid
|
||||
}
|
||||
|
||||
if authz.Status != prevStatus {
|
||||
data, _ := json.Marshal(authz)
|
||||
if err := h.barrier.Put(ctx, h.barrierPrefix()+"authz/"+authzID+".json", data); err != nil {
|
||||
h.logger.Error("acme: save authz", "error", err)
|
||||
return
|
||||
}
|
||||
// Propagate to orders that reference this authz.
|
||||
h.updateOrdersForAuthz(ctx, authzID)
|
||||
}
|
||||
}
|
||||
|
||||
// updateOrdersForAuthz scans all orders for one that references authzID
|
||||
// and updates the order status if all authorizations are now valid.
|
||||
func (h *Handler) updateOrdersForAuthz(ctx context.Context, authzID string) {
|
||||
paths, err := h.barrier.List(ctx, h.barrierPrefix()+"orders/")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range paths {
|
||||
data, err := h.barrier.Get(ctx, h.barrierPrefix()+"orders/"+p)
|
||||
if err != nil || data == nil {
|
||||
continue
|
||||
}
|
||||
var order Order
|
||||
if err := json.Unmarshal(data, &order); err != nil {
|
||||
continue
|
||||
}
|
||||
if order.Status != StatusPending {
|
||||
continue
|
||||
}
|
||||
for _, id := range order.AuthzIDs {
|
||||
if id != authzID {
|
||||
continue
|
||||
}
|
||||
// This order references the updated authz; check all authzs.
|
||||
h.maybeAdvanceOrder(ctx, &order)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maybeAdvanceOrder checks whether all authorizations for an order are valid
|
||||
// and transitions the order to "ready" if so.
|
||||
func (h *Handler) maybeAdvanceOrder(ctx context.Context, order *Order) {
|
||||
allValid := true
|
||||
for _, authzID := range order.AuthzIDs {
|
||||
authz, err := h.loadAuthz(ctx, authzID)
|
||||
if err != nil || authz.Status != StatusValid {
|
||||
allValid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allValid {
|
||||
return
|
||||
}
|
||||
order.Status = StatusReady
|
||||
data, _ := json.Marshal(order)
|
||||
if err := h.barrier.Put(ctx, h.barrierPrefix()+"orders/"+order.ID+".json", data); err != nil {
|
||||
h.logger.Error("acme: advance order to ready", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// validateHTTP01 performs HTTP-01 challenge validation (RFC 8555 §8.3).
|
||||
// It fetches http://{domain}/.well-known/acme-challenge/{token} and verifies
|
||||
// the response matches the key authorization.
|
||||
func validateHTTP01(ctx context.Context, chall *Challenge, accountJWK []byte) error {
|
||||
authz, err := KeyAuthorization(chall.Token, accountJWK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute key authorization: %w", err)
|
||||
}
|
||||
|
||||
// Load the authorization to get the identifier.
|
||||
// The domain comes from the parent authorization; we pass accountJWK here
|
||||
// but need the identifier. It's embedded in the challenge's AuthzID so the
|
||||
// caller (validateChallenge) must load the authz to get the domain.
|
||||
// Since we don't have the domain directly in Challenge, we embed it during
|
||||
// challenge creation. For now: the caller passes accountJWK, and we use
|
||||
// a context value to carry the domain through validateHTTP01.
|
||||
domain, ok := ctx.Value(ctxKeyDomain).(string)
|
||||
if !ok || domain == "" {
|
||||
return errors.New("domain not in context")
|
||||
}
|
||||
|
||||
url := "http://" + domain + "/.well-known/acme-challenge/" + chall.Token
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
// Follow at most 10 redirects per RFC 8555 §8.3.
|
||||
CheckRedirect: func(_ *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build HTTP-01 request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP-01 fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP-01: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP-01: read body: %w", err)
|
||||
}
|
||||
|
||||
// RFC 8555 §8.3: response body should be the key authorization, optionally
|
||||
// followed by whitespace.
|
||||
got := string(body)
|
||||
for len(got) > 0 && (got[len(got)-1] == '\n' || got[len(got)-1] == '\r' || got[len(got)-1] == ' ') {
|
||||
got = got[:len(got)-1]
|
||||
}
|
||||
if got != authz {
|
||||
return fmt.Errorf("HTTP-01: key authorization mismatch (got %q, want %q)", got, authz)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDNS01 performs DNS-01 challenge validation (RFC 8555 §8.4).
|
||||
// It looks up _acme-challenge.{domain} TXT records and checks for the
|
||||
// base64url(SHA-256(keyAuthorization)) value.
|
||||
func validateDNS01(ctx context.Context, chall *Challenge, accountJWK []byte) error {
|
||||
keyAuth, err := KeyAuthorization(chall.Token, accountJWK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute key authorization: %w", err)
|
||||
}
|
||||
// DNS-01 TXT record value is base64url(SHA-256(keyAuthorization)).
|
||||
digest := sha256.Sum256([]byte(keyAuth))
|
||||
expected := base64.RawURLEncoding.EncodeToString(digest[:])
|
||||
|
||||
domain, ok := ctx.Value(ctxKeyDomain).(string)
|
||||
if !ok || domain == "" {
|
||||
return errors.New("domain not in context")
|
||||
}
|
||||
|
||||
// Strip trailing dot if present; add _acme-challenge prefix.
|
||||
domain = "_acme-challenge." + domain
|
||||
|
||||
resolver := net.DefaultResolver
|
||||
txts, err := resolver.LookupTXT(ctx, domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DNS-01: TXT lookup for %s failed: %w", domain, err)
|
||||
}
|
||||
|
||||
for _, txt := range txts {
|
||||
if txt == expected {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("DNS-01: no matching TXT record for %s (expected %s)", domain, expected)
|
||||
}
|
||||
|
||||
// ctxKeyDomain is the context key for passing the domain to validators.
|
||||
type ctxDomainKey struct{}
|
||||
|
||||
var ctxKeyDomain = ctxDomainKey{}
|
||||
|
||||
// pemToDER decodes the first PEM block from a PEM string and returns its DER bytes.
|
||||
func pemToDER(pemStr string) ([]byte, error) {
|
||||
block, _ := pem.Decode([]byte(pemStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM block")
|
||||
}
|
||||
return block.Bytes, nil
|
||||
}
|
||||
Reference in New Issue
Block a user