Files
metacrypt/internal/acme/handlers.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

877 lines
26 KiB
Go

package acme
import (
"context"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
)
// directoryResponse is the ACME directory object (RFC 8555 §7.1.1).
type directoryResponse struct {
NewNonce string `json:"newNonce"`
NewAccount string `json:"newAccount"`
NewOrder string `json:"newOrder"`
RevokeCert string `json:"revokeCert"`
KeyChange string `json:"keyChange"`
Meta *directoryMeta `json:"meta,omitempty"`
}
type directoryMeta struct {
TermsOfService string `json:"termsOfService,omitempty"`
Website string `json:"website,omitempty"`
CAAIdentities []string `json:"caaIdentities,omitempty"`
ExternalAccountRequired bool `json:"externalAccountRequired"`
}
// handleDirectory serves the ACME directory (GET /acme/{mount}/directory).
// No nonce or authentication required.
func (h *Handler) handleDirectory(w http.ResponseWriter, r *http.Request) {
base := h.baseURL + "/acme/" + h.mount
dir := directoryResponse{
NewNonce: base + "/new-nonce",
NewAccount: base + "/new-account",
NewOrder: base + "/new-order",
RevokeCert: base + "/revoke-cert",
KeyChange: base + "/key-change",
Meta: &directoryMeta{
ExternalAccountRequired: true,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(dir)
}
// handleNewNonce serves HEAD and GET /acme/{mount}/new-nonce.
func (h *Handler) handleNewNonce(w http.ResponseWriter, r *http.Request) {
h.addNonceHeader(w)
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusNoContent)
}
}
// newAccountPayload is the payload for the new-account request.
type newAccountPayload struct {
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
Contact []string `json:"contact,omitempty"`
ExternalAccountBinding json.RawMessage `json:"externalAccountBinding,omitempty"`
OnlyReturnExisting bool `json:"onlyReturnExisting"`
}
// handleNewAccount handles POST /acme/{mount}/new-account.
func (h *Handler) handleNewAccount(w http.ResponseWriter, r *http.Request) {
parsed, err := h.parseAndVerifyNewAccountJWS(r)
if err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, err.Error())
return
}
// Validate URL in header.
if parsed.Header.URL != h.baseURL+"/acme/"+h.mount+"/new-account" {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "JWS URL mismatch")
return
}
// Consume nonce.
if err := h.nonces.Consume(parsed.Header.Nonce); err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemBadNonce, "invalid or expired nonce")
return
}
var payload newAccountPayload
if len(parsed.Payload) > 0 {
if err := json.Unmarshal(parsed.Payload, &payload); err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid payload")
return
}
}
ctx := r.Context()
// Check if account already exists for this JWK.
jwkJSON, _ := json.Marshal(parsed.Header.JWK)
kid := thumbprintKey(jwkJSON)
existingPath := h.barrierPrefix() + "accounts/" + kid + ".json"
existing, _ := h.barrier.Get(ctx, existingPath)
if existing != nil {
var acc Account
if err := json.Unmarshal(existing, &acc); err == nil {
if payload.OnlyReturnExisting || acc.Status == StatusValid {
w.Header().Set("Location", h.accountURL(acc.ID))
h.writeJSON(w, http.StatusOK, h.accountToWire(&acc))
return
}
}
}
if payload.OnlyReturnExisting {
h.writeACMEError(w, http.StatusBadRequest, ProblemAccountDoesNotExist, "account does not exist")
return
}
// EAB is required.
if len(payload.ExternalAccountBinding) == 0 {
h.writeACMEError(w, http.StatusBadRequest, ProblemExternalAccountRequired, "external account binding required")
return
}
// Parse and verify EAB.
eabParsed, err := ParseJWS(payload.ExternalAccountBinding)
if err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid EAB JWS")
return
}
eabKID := eabParsed.Header.KID
eabCred, err := h.GetEAB(ctx, eabKID)
if err != nil {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, "unknown EAB key ID")
return
}
if eabCred.Used {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, "EAB key already used")
return
}
if err := VerifyEAB(payload.ExternalAccountBinding, eabKID, eabCred.HMACKey, jwkJSON); err != nil {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, "EAB verification failed")
return
}
if err := h.MarkEABUsed(ctx, eabKID); err != nil {
h.logger.Error("acme: mark EAB used", "error", err)
}
// Create account.
acc := &Account{
ID: kid,
Status: StatusValid,
Contact: payload.Contact,
JWK: jwkJSON,
CreatedAt: time.Now(),
MCIASUsername: eabCred.CreatedBy,
}
data, _ := json.Marshal(acc)
if err := h.barrier.Put(ctx, existingPath, data); err != nil {
h.logger.Error("acme: store account", "error", err)
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to store account")
return
}
w.Header().Set("Location", h.accountURL(acc.ID))
h.writeJSON(w, http.StatusCreated, h.accountToWire(acc))
}
// newOrderPayload is the payload for the new-order request.
type newOrderPayload struct {
Identifiers []Identifier `json:"identifiers"`
NotBefore string `json:"notBefore,omitempty"`
NotAfter string `json:"notAfter,omitempty"`
}
// handleNewOrder handles POST /acme/{mount}/new-order.
func (h *Handler) handleNewOrder(w http.ResponseWriter, r *http.Request) {
acc, parsed, err := h.authenticateRequest(r, h.baseURL+"/acme/"+h.mount+"/new-order")
if err != nil {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error())
return
}
var payload newOrderPayload
if err := json.Unmarshal(parsed.Payload, &payload); err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid payload")
return
}
if len(payload.Identifiers) == 0 {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "identifiers required")
return
}
// Validate identifier types.
for _, id := range payload.Identifiers {
if id.Type != IdentifierDNS && id.Type != IdentifierIP {
h.writeACMEError(w, http.StatusBadRequest, ProblemUnsupportedIdentifier,
fmt.Sprintf("unsupported identifier type: %s", id.Type))
return
}
}
ctx := r.Context()
// Look up default issuer from config.
cfg, _ := h.loadConfig(ctx)
if cfg.DefaultIssuer == "" {
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal,
"no default issuer configured for this ACME mount; set acme_issuer via the management API")
return
}
orderID := newID()
now := time.Now()
orderExpiry := now.Add(7 * 24 * time.Hour)
// Create one authorization per identifier.
var authzIDs []string
for _, id := range payload.Identifiers {
authzID := newID()
authzIDs = append(authzIDs, authzID)
// Create two challenges: http-01 and dns-01.
httpChallengeID := newID()
dnsChallengeID := newID()
httpChallenge := &Challenge{
ID: httpChallengeID,
AuthzID: authzID,
Type: ChallengeHTTP01,
Status: StatusPending,
Token: newToken(),
}
dnsChallenge := &Challenge{
ID: dnsChallengeID,
AuthzID: authzID,
Type: ChallengeDNS01,
Status: StatusPending,
Token: newToken(),
}
authz := &Authorization{
ID: authzID,
AccountID: acc.ID,
Status: StatusPending,
Identifier: id,
ChallengeIDs: []string{httpChallengeID, dnsChallengeID},
ExpiresAt: orderExpiry,
}
challPrefix := h.barrierPrefix() + "challenges/"
authzPrefix := h.barrierPrefix() + "authz/"
httpData, _ := json.Marshal(httpChallenge)
dnsData, _ := json.Marshal(dnsChallenge)
authzData, _ := json.Marshal(authz)
if err := h.barrier.Put(ctx, challPrefix+httpChallengeID+".json", httpData); err != nil {
h.logger.Error("acme: store challenge", "error", err)
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to create authorization")
return
}
if err := h.barrier.Put(ctx, challPrefix+dnsChallengeID+".json", dnsData); err != nil {
h.logger.Error("acme: store challenge", "error", err)
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to create authorization")
return
}
if err := h.barrier.Put(ctx, authzPrefix+authzID+".json", authzData); err != nil {
h.logger.Error("acme: store authz", "error", err)
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to create authorization")
return
}
}
order := &Order{
ID: orderID,
AccountID: acc.ID,
Status: StatusPending,
Identifiers: payload.Identifiers,
AuthzIDs: authzIDs,
ExpiresAt: orderExpiry,
CreatedAt: now,
IssuerName: cfg.DefaultIssuer,
}
orderData, _ := json.Marshal(order)
if err := h.barrier.Put(ctx, h.barrierPrefix()+"orders/"+orderID+".json", orderData); err != nil {
h.logger.Error("acme: store order", "error", err)
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to create order")
return
}
w.Header().Set("Location", h.orderURL(orderID))
h.writeJSON(w, http.StatusCreated, h.orderToWire(order))
}
// handleGetAuthz handles POST /acme/{mount}/authz/{id} (POST-as-GET, empty payload).
func (h *Handler) handleGetAuthz(w http.ResponseWriter, r *http.Request) {
authzID := chi.URLParam(r, "id")
reqURL := h.baseURL + "/acme/" + h.mount + "/authz/" + authzID
_, _, err := h.authenticateRequest(r, reqURL)
if err != nil {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error())
return
}
authz, err := h.loadAuthz(r.Context(), authzID)
if err != nil {
h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "authorization not found")
return
}
h.writeJSON(w, http.StatusOK, h.authzToWire(r.Context(), authz))
}
// handleChallenge handles POST /acme/{mount}/challenge/{type}/{id}.
func (h *Handler) handleChallenge(w http.ResponseWriter, r *http.Request) {
challType := chi.URLParam(r, "type")
challID := chi.URLParam(r, "id")
reqURL := h.baseURL + "/acme/" + h.mount + "/challenge/" + challType + "/" + challID
acc, _, err := h.authenticateRequest(r, reqURL)
if err != nil {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error())
return
}
ctx := r.Context()
chall, err := h.loadChallenge(ctx, challID)
if err != nil {
h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "challenge not found")
return
}
if chall.Status != StatusPending {
// Already processing or completed; return current state.
h.writeJSON(w, http.StatusOK, h.challengeToWire(chall))
return
}
// Mark as processing.
chall.Status = StatusProcessing
if err := h.saveChallenge(ctx, chall); err != nil {
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to update challenge")
return
}
// Return challenge immediately; validate asynchronously.
h.writeJSON(w, http.StatusOK, h.challengeToWire(chall))
// Launch validation goroutine.
go h.validateChallenge(context.Background(), chall, acc.JWK)
}
// handleFinalize handles POST /acme/{mount}/finalize/{id}.
func (h *Handler) handleFinalize(w http.ResponseWriter, r *http.Request) {
orderID := chi.URLParam(r, "id")
reqURL := h.baseURL + "/acme/" + h.mount + "/finalize/" + orderID
acc, parsed, err := h.authenticateRequest(r, reqURL)
if err != nil {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error())
return
}
ctx := r.Context()
order, err := h.loadOrder(ctx, orderID)
if err != nil {
h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "order not found")
return
}
if order.AccountID != acc.ID {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, "order belongs to different account")
return
}
if order.Status != StatusReady {
h.writeACMEError(w, http.StatusForbidden, ProblemOrderNotReady, "order is not ready")
return
}
// Parse payload: {"csr": "<base64url DER>"}
var finalizePayload struct {
CSR string `json:"csr"`
}
if err := json.Unmarshal(parsed.Payload, &finalizePayload); err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid payload")
return
}
csrDER, err := base64.RawURLEncoding.DecodeString(finalizePayload.CSR)
if err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemBadCSR, "invalid CSR encoding")
return
}
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemBadCSR, "invalid CSR: "+err.Error())
return
}
if err := csr.CheckSignature(); err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemBadCSR, "CSR signature invalid")
return
}
// Verify CSR identifiers match order identifiers.
if err := h.validateCSRIdentifiers(csr, order.Identifiers); err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemBadCSR, err.Error())
return
}
// Issue certificate via CA engine.
// Build SANs from order identifiers.
var dnsNames, ipAddrs []string
for _, id := range order.Identifiers {
switch id.Type {
case IdentifierDNS:
dnsNames = append(dnsNames, id.Value)
case IdentifierIP:
ipAddrs = append(ipAddrs, id.Value)
}
}
issueReq := &engine.Request{
Operation: "issue",
CallerInfo: &engine.CallerInfo{
Username: acc.MCIASUsername,
Roles: []string{"user"},
IsAdmin: false,
},
Data: map[string]interface{}{
"issuer": order.IssuerName,
"profile": "server",
"common_name": csr.Subject.CommonName,
"dns_names": dnsNames,
"ip_addresses": ipAddrs,
},
}
resp, err := h.engines.HandleRequest(ctx, h.mount, issueReq)
if err != nil {
h.logger.Error("acme: issue cert", "error", err)
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "certificate issuance failed")
return
}
certPEM, _ := resp.Data["chain_pem"].(string)
expiresStr, _ := resp.Data["expires_at"].(string)
certID := newID()
var expiresAt time.Time
if expiresStr != "" {
expiresAt, _ = time.Parse(time.RFC3339, expiresStr)
}
issuedCert := &IssuedCert{
ID: certID,
OrderID: orderID,
AccountID: acc.ID,
CertPEM: certPEM,
IssuedAt: time.Now(),
ExpiresAt: expiresAt,
}
certData, _ := json.Marshal(issuedCert)
if err := h.barrier.Put(ctx, h.barrierPrefix()+"certs/"+certID+".json", certData); err != nil {
h.logger.Error("acme: store cert", "error", err)
}
order.Status = StatusValid
order.CertID = certID
orderData, _ := json.Marshal(order)
h.barrier.Put(ctx, h.barrierPrefix()+"orders/"+orderID+".json", orderData)
h.writeJSON(w, http.StatusOK, h.orderToWire(order))
}
// handleGetCert handles POST /acme/{mount}/cert/{id} (POST-as-GET).
func (h *Handler) handleGetCert(w http.ResponseWriter, r *http.Request) {
certID := chi.URLParam(r, "id")
reqURL := h.baseURL + "/acme/" + h.mount + "/cert/" + certID
_, _, err := h.authenticateRequest(r, reqURL)
if err != nil {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error())
return
}
data, err := h.barrier.Get(r.Context(), h.barrierPrefix()+"certs/"+certID+".json")
if err != nil || data == nil {
h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "certificate not found")
return
}
var cert IssuedCert
if err := json.Unmarshal(data, &cert); err != nil {
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to load certificate")
return
}
if cert.Revoked {
h.writeACMEError(w, http.StatusNotFound, ProblemAlreadyRevoked, "certificate has been revoked")
return
}
h.addNonceHeader(w)
w.Header().Set("Content-Type", "application/pem-certificate-chain")
w.WriteHeader(http.StatusOK)
w.Write([]byte(cert.CertPEM))
}
// handleRevokeCert handles POST /acme/{mount}/revoke-cert.
func (h *Handler) handleRevokeCert(w http.ResponseWriter, r *http.Request) {
_, parsed, err := h.authenticateRequest(r, h.baseURL+"/acme/"+h.mount+"/revoke-cert")
if err != nil {
h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error())
return
}
var revokePayload struct {
Certificate string `json:"certificate"` // base64url DER
}
if err := json.Unmarshal(parsed.Payload, &revokePayload); err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid payload")
return
}
// Find cert by matching the DER bytes.
ctx := r.Context()
certDER, err := base64.RawURLEncoding.DecodeString(revokePayload.Certificate)
if err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid certificate encoding")
return
}
targetCert, err := x509.ParseCertificate(certDER)
if err != nil {
h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid certificate")
return
}
paths, err := h.barrier.List(ctx, h.barrierPrefix()+"certs/")
if err != nil {
h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to list certificates")
return
}
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
data, _ := h.barrier.Get(ctx, h.barrierPrefix()+"certs/"+p)
if data == nil {
continue
}
var cert IssuedCert
if err := json.Unmarshal(data, &cert); err != nil {
continue
}
// Match by serial number encoded in PEM.
issuedCertDER, err := pemToDER(cert.CertPEM)
if err != nil {
continue
}
issuedCert, err := x509.ParseCertificate(issuedCertDER)
if err != nil {
continue
}
if issuedCert.SerialNumber.Cmp(targetCert.SerialNumber) == 0 {
cert.Revoked = true
updated, _ := json.Marshal(cert)
h.barrier.Put(ctx, h.barrierPrefix()+"certs/"+p, updated)
h.addNonceHeader(w)
w.WriteHeader(http.StatusOK)
return
}
}
h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "certificate not found")
}
// --- Authentication helpers ---
// authenticateRequest parses and verifies a JWS request, consuming the nonce
// and validating the URL. Returns the account and parsed JWS.
// For new-account requests, use parseAndVerifyNewAccountJWS instead.
func (h *Handler) authenticateRequest(r *http.Request, expectedURL string) (*Account, *ParsedJWS, error) {
body, err := readBody(r)
if err != nil {
return nil, nil, fmt.Errorf("failed to read body: %w", err)
}
parsed, err := ParseJWS(body)
if err != nil {
return nil, nil, fmt.Errorf("invalid JWS: %w", err)
}
if parsed.Header.URL != expectedURL {
return nil, nil, errors.New("JWS URL mismatch")
}
if err := h.nonces.Consume(parsed.Header.Nonce); err != nil {
return nil, nil, errors.New("invalid or expired nonce")
}
// Look up account by KID.
if parsed.Header.KID == "" {
return nil, nil, errors.New("KID required for authenticated requests")
}
// KID is the full account URL; extract the ID.
accID := extractIDFromURL(parsed.Header.KID, "/account/")
if accID == "" {
// Try treating KID directly as the account ID (thumbprint).
accID = parsed.Header.KID
}
acc, err := h.loadAccount(r.Context(), accID)
if err != nil {
return nil, nil, errors.New("account not found")
}
if acc.Status != StatusValid {
return nil, nil, fmt.Errorf("account status is %s", acc.Status)
}
// Verify JWS signature against account key.
pubKey, err := ParseJWK(acc.JWK)
if err != nil {
return nil, nil, fmt.Errorf("invalid account key: %w", err)
}
if err := VerifyJWS(parsed, pubKey); err != nil {
return nil, nil, fmt.Errorf("signature verification failed: %w", err)
}
return acc, parsed, nil
}
// parseAndVerifyNewAccountJWS parses a new-account JWS where the key is
// embedded in the JWK header field (not a KID).
func (h *Handler) parseAndVerifyNewAccountJWS(r *http.Request) (*ParsedJWS, error) {
body, err := readBody(r)
if err != nil {
return nil, fmt.Errorf("failed to read body: %w", err)
}
parsed, err := ParseJWS(body)
if err != nil {
return nil, fmt.Errorf("invalid JWS: %w", err)
}
if len(parsed.Header.JWK) == 0 {
return nil, errors.New("JWK required in header for new-account")
}
pubKey, err := ParseJWK(parsed.Header.JWK)
if err != nil {
return nil, fmt.Errorf("invalid JWK: %w", err)
}
if err := VerifyJWS(parsed, pubKey); err != nil {
return nil, fmt.Errorf("signature verification failed: %w", err)
}
return parsed, nil
}
// --- Barrier helpers ---
func (h *Handler) loadAccount(ctx context.Context, id string) (*Account, error) {
data, err := h.barrier.Get(ctx, h.barrierPrefix()+"accounts/"+id+".json")
if err != nil || data == nil {
return nil, errors.New("account not found")
}
var acc Account
return &acc, json.Unmarshal(data, &acc)
}
func (h *Handler) loadOrder(ctx context.Context, id string) (*Order, error) {
data, err := h.barrier.Get(ctx, h.barrierPrefix()+"orders/"+id+".json")
if err != nil || data == nil {
return nil, errors.New("order not found")
}
var order Order
return &order, json.Unmarshal(data, &order)
}
func (h *Handler) loadAuthz(ctx context.Context, id string) (*Authorization, error) {
data, err := h.barrier.Get(ctx, h.barrierPrefix()+"authz/"+id+".json")
if err != nil || data == nil {
return nil, errors.New("authorization not found")
}
var authz Authorization
return &authz, json.Unmarshal(data, &authz)
}
func (h *Handler) loadChallenge(ctx context.Context, id string) (*Challenge, error) {
paths, err := h.barrier.List(ctx, h.barrierPrefix()+"challenges/")
if err != nil {
return nil, errors.New("challenge not found")
}
for _, p := range paths {
if !strings.Contains(p, id) {
continue
}
data, err := h.barrier.Get(ctx, h.barrierPrefix()+"challenges/"+p)
if err != nil || data == nil {
continue
}
var chall Challenge
if err := json.Unmarshal(data, &chall); err != nil {
continue
}
if chall.ID == id {
return &chall, nil
}
}
return nil, errors.New("challenge not found")
}
func (h *Handler) saveChallenge(ctx context.Context, chall *Challenge) error {
data, err := json.Marshal(chall)
if err != nil {
return err
}
return h.barrier.Put(ctx, h.barrierPrefix()+"challenges/"+chall.ID+".json", data)
}
// --- Wire format helpers ---
func (h *Handler) accountToWire(acc *Account) map[string]interface{} {
return map[string]interface{}{
"status": acc.Status,
"contact": acc.Contact,
"orders": h.baseURL + "/acme/" + h.mount + "/account/" + acc.ID + "/orders",
}
}
func (h *Handler) orderToWire(order *Order) map[string]interface{} {
authzURLs := make([]string, len(order.AuthzIDs))
for i, id := range order.AuthzIDs {
authzURLs[i] = h.authzURL(id)
}
m := map[string]interface{}{
"status": order.Status,
"expires": order.ExpiresAt.Format(time.RFC3339),
"identifiers": order.Identifiers,
"authorizations": authzURLs,
"finalize": h.finalizeURL(order.ID),
}
if order.CertID != "" {
m["certificate"] = h.certURL(order.CertID)
}
return m
}
func (h *Handler) authzToWire(ctx context.Context, authz *Authorization) map[string]interface{} {
var challenges []map[string]interface{}
for _, challID := range authz.ChallengeIDs {
chall, err := h.loadChallenge(ctx, challID)
if err != nil {
continue
}
challenges = append(challenges, h.challengeToWire(chall))
}
return map[string]interface{}{
"status": authz.Status,
"expires": authz.ExpiresAt.Format(time.RFC3339),
"identifier": authz.Identifier,
"challenges": challenges,
}
}
func (h *Handler) challengeToWire(chall *Challenge) map[string]interface{} {
m := map[string]interface{}{
"type": chall.Type,
"status": chall.Status,
"url": h.challengeURL(chall.Type, chall.ID),
"token": chall.Token,
}
if chall.ValidatedAt != nil {
m["validated"] = chall.ValidatedAt.Format(time.RFC3339)
}
if chall.Error != nil {
m["error"] = chall.Error
}
return m
}
// --- Validation helpers ---
func (h *Handler) validateCSRIdentifiers(csr *x509.CertificateRequest, identifiers []Identifier) error {
// Build expected sets from order.
expectedDNS := make(map[string]bool)
expectedIP := make(map[string]bool)
for _, id := range identifiers {
switch id.Type {
case IdentifierDNS:
expectedDNS[id.Value] = true
case IdentifierIP:
expectedIP[id.Value] = true
}
}
// Verify DNS SANs match.
for _, name := range csr.DNSNames {
if !expectedDNS[name] {
return fmt.Errorf("CSR contains unexpected DNS SAN: %s", name)
}
delete(expectedDNS, name)
}
if len(expectedDNS) > 0 {
missing := make([]string, 0, len(expectedDNS))
for k := range expectedDNS {
missing = append(missing, k)
}
return fmt.Errorf("CSR missing DNS SANs: %s", strings.Join(missing, ", "))
}
// Verify IP SANs match.
for _, ip := range csr.IPAddresses {
ipStr := ip.String()
if !expectedIP[ipStr] {
return fmt.Errorf("CSR contains unexpected IP SAN: %s", ipStr)
}
delete(expectedIP, ipStr)
}
if len(expectedIP) > 0 {
missing := make([]string, 0, len(expectedIP))
for k := range expectedIP {
missing = append(missing, k)
}
return fmt.Errorf("CSR missing IP SANs: %s", strings.Join(missing, ", "))
}
return nil
}
// --- Misc helpers ---
// newID generates a random URL-safe ID.
func newID() string {
b := make([]byte, 16)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// newToken generates a random 32-byte base64url-encoded ACME challenge token.
func newToken() string {
b := make([]byte, 32)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// thumbprintKey returns the JWK thumbprint of a JSON-encoded public key,
// used as the account ID / barrier key.
func thumbprintKey(jwk []byte) string {
t, err := ThumbprintJWK(jwk)
if err != nil {
return newID()
}
return t
}
// extractIDFromURL extracts the last path segment after a known prefix.
func extractIDFromURL(url, prefix string) string {
idx := strings.LastIndex(url, prefix)
if idx < 0 {
return ""
}
return url[idx+len(prefix):]
}
// readBody reads and returns the full request body.
func readBody(r *http.Request) ([]byte, error) {
if r.Body == nil {
return nil, errors.New("empty body")
}
defer r.Body.Close()
buf := make([]byte, 0, 4096)
tmp := make([]byte, 512)
for {
n, err := r.Body.Read(tmp)
buf = append(buf, tmp[:n]...)
if err != nil {
break
}
if len(buf) > 1<<20 {
return nil, errors.New("request body too large")
}
}
return buf, nil
}