Files
metacrypt/internal/acme/server.go
Kyle Isom 167db48eb4 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.
2026-03-15 08:09:12 -07:00

125 lines
3.6 KiB
Go

// Package acme implements an RFC 8555 ACME server.
package acme
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
)
// Handler implements the ACME protocol for a single CA mount.
type Handler struct {
mount string
barrier barrier.Barrier
engines *engine.Registry
nonces *NonceStore
baseURL string
logger *slog.Logger
}
// NewHandler creates an ACME handler for the given CA mount.
func NewHandler(mount string, b barrier.Barrier, engines *engine.Registry, baseURL string, logger *slog.Logger) *Handler {
return &Handler{
mount: mount,
barrier: b,
engines: engines,
nonces: NewNonceStore(),
baseURL: baseURL,
logger: logger,
}
}
// RegisterRoutes registers all ACME protocol routes on r.
// r should be a subrouter already scoped to "/acme/{mount}".
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/directory", h.handleDirectory)
r.Head("/new-nonce", h.handleNewNonce)
r.Get("/new-nonce", h.handleNewNonce)
r.Post("/new-account", h.handleNewAccount)
r.Post("/new-order", h.handleNewOrder)
r.Post("/authz/{id}", h.handleGetAuthz)
r.Post("/challenge/{type}/{id}", h.handleChallenge)
r.Post("/finalize/{id}", h.handleFinalize)
r.Post("/cert/{id}", h.handleGetCert)
r.Post("/revoke-cert", h.handleRevokeCert)
}
// barrierPrefix returns the barrier key prefix for this mount.
func (h *Handler) barrierPrefix() string {
return "acme/" + h.mount + "/"
}
func (h *Handler) accountURL(id string) string {
return h.baseURL + "/acme/" + h.mount + "/account/" + id
}
func (h *Handler) orderURL(id string) string {
return h.baseURL + "/acme/" + h.mount + "/order/" + id
}
func (h *Handler) authzURL(id string) string {
return h.baseURL + "/acme/" + h.mount + "/authz/" + id
}
func (h *Handler) challengeURL(typ, id string) string {
return h.baseURL + "/acme/" + h.mount + "/challenge/" + typ + "/" + id
}
func (h *Handler) finalizeURL(id string) string {
return h.baseURL + "/acme/" + h.mount + "/finalize/" + id
}
func (h *Handler) certURL(id string) string {
return h.baseURL + "/acme/" + h.mount + "/cert/" + id
}
// addNonceHeader issues a fresh nonce and adds it to the Replay-Nonce header.
// RFC 8555 §6.5: every response must include a fresh nonce.
func (h *Handler) addNonceHeader(w http.ResponseWriter) {
nonce, err := h.nonces.Issue()
if err != nil {
h.logger.Error("acme: failed to issue nonce", "error", err)
return
}
w.Header().Set("Replay-Nonce", nonce)
}
// writeACMEError writes an RFC 7807 problem detail response.
func (h *Handler) writeACMEError(w http.ResponseWriter, status int, typ, detail string) {
h.addNonceHeader(w)
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{
"type": typ,
"detail": detail,
})
}
// writeJSON writes a JSON response with the given status code and value.
func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) {
h.addNonceHeader(w)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// loadConfig loads the ACME config for this mount from the barrier.
// Returns a zero-value config if none exists.
func (h *Handler) loadConfig(ctx context.Context) (*ACMEConfig, error) {
data, err := h.barrier.Get(ctx, h.barrierPrefix()+"config.json")
if err != nil || data == nil {
return &ACMEConfig{}, nil
}
var cfg ACMEConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return &ACMEConfig{}, nil
}
return &cfg, nil
}