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:
2026-03-15 01:31:52 -07:00
parent aa9a378685
commit 167db48eb4
19 changed files with 2743 additions and 5 deletions

118
internal/acme/eab.go Normal file
View File

@@ -0,0 +1,118 @@
package acme
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// CreateEAB generates a new EAB credential for the given MCIAS user.
// The credential is stored in the barrier and must be consumed on first use.
func (h *Handler) CreateEAB(ctx context.Context, mciasUsername string) (*EABCredential, error) {
kidBytes := make([]byte, 16)
if _, err := rand.Read(kidBytes); err != nil {
return nil, fmt.Errorf("acme: generate EAB kid: %w", err)
}
hmacKey := make([]byte, 32)
if _, err := rand.Read(hmacKey); err != nil {
return nil, fmt.Errorf("acme: generate EAB key: %w", err)
}
cred := &EABCredential{
KID: base64.RawURLEncoding.EncodeToString(kidBytes),
HMACKey: hmacKey,
Used: false,
CreatedBy: mciasUsername,
CreatedAt: time.Now(),
}
data, err := json.Marshal(cred)
if err != nil {
return nil, fmt.Errorf("acme: marshal EAB: %w", err)
}
path := h.barrierPrefix() + "eab/" + cred.KID + ".json"
if err := h.barrier.Put(ctx, path, data); err != nil {
return nil, fmt.Errorf("acme: store EAB: %w", err)
}
return cred, nil
}
// GetEAB retrieves an EAB credential by KID.
func (h *Handler) GetEAB(ctx context.Context, kid string) (*EABCredential, error) {
path := h.barrierPrefix() + "eab/" + kid + ".json"
data, err := h.barrier.Get(ctx, path)
if err != nil || data == nil {
return nil, fmt.Errorf("acme: EAB not found")
}
var cred EABCredential
if err := json.Unmarshal(data, &cred); err != nil {
return nil, fmt.Errorf("acme: unmarshal EAB: %w", err)
}
return &cred, nil
}
// MarkEABUsed marks an EAB credential as consumed so it cannot be reused.
func (h *Handler) MarkEABUsed(ctx context.Context, kid string) error {
cred, err := h.GetEAB(ctx, kid)
if err != nil {
return err
}
cred.Used = true
data, err := json.Marshal(cred)
if err != nil {
return fmt.Errorf("acme: marshal EAB: %w", err)
}
return h.barrier.Put(ctx, h.barrierPrefix()+"eab/"+kid+".json", data)
}
// ListAccounts returns all ACME accounts for this mount.
func (h *Handler) ListAccounts(ctx context.Context) ([]*Account, error) {
paths, err := h.barrier.List(ctx, h.barrierPrefix()+"accounts/")
if err != nil {
return nil, err
}
var accounts []*Account
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
data, err := h.barrier.Get(ctx, h.barrierPrefix()+"accounts/"+p)
if err != nil || data == nil {
continue
}
var acc Account
if err := json.Unmarshal(data, &acc); err != nil {
continue
}
accounts = append(accounts, &acc)
}
return accounts, nil
}
// ListOrders returns all ACME orders for this mount.
func (h *Handler) ListOrders(ctx context.Context) ([]*Order, error) {
paths, err := h.barrier.List(ctx, h.barrierPrefix()+"orders/")
if err != nil {
return nil, err
}
var orders []*Order
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
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
}
orders = append(orders, &order)
}
return orders, nil
}

876
internal/acme/handlers.go Normal file
View File

@@ -0,0 +1,876 @@
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
}

320
internal/acme/jws.go Normal file
View File

@@ -0,0 +1,320 @@
package acme
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
)
// JWSFlat is the wire format of a flattened JWS (RFC 7515 §7.2.2).
type JWSFlat struct {
Protected string `json:"protected"`
Payload string `json:"payload"`
Signature string `json:"signature"`
}
// JWSHeader is the decoded JOSE protected header from an ACME request.
type JWSHeader struct {
Alg string `json:"alg"`
Nonce string `json:"nonce"`
URL string `json:"url"`
JWK json.RawMessage `json:"jwk,omitempty"` // present for new-account / new-order with new key
KID string `json:"kid,omitempty"` // present for subsequent requests
}
// ParsedJWS is the result of parsing a JWS request body.
// Signature verification is NOT performed here; call VerifyJWS separately.
type ParsedJWS struct {
Header JWSHeader
Payload []byte // decoded payload bytes; empty-string payload decodes to nil
SigningInput []byte // protected + "." + payload (ASCII; used for signature verification)
RawSignature []byte // decoded signature bytes
RawBody JWSFlat
}
// ParseJWS decodes the flattened JWS from body without verifying the signature.
// Callers must call VerifyJWS after resolving the public key.
func ParseJWS(body []byte) (*ParsedJWS, error) {
var flat JWSFlat
if err := json.Unmarshal(body, &flat); err != nil {
return nil, fmt.Errorf("invalid JWS: %w", err)
}
headerBytes, err := base64.RawURLEncoding.DecodeString(flat.Protected)
if err != nil {
return nil, fmt.Errorf("invalid JWS protected header encoding: %w", err)
}
var header JWSHeader
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, fmt.Errorf("invalid JWS protected header: %w", err)
}
var payload []byte
if flat.Payload != "" {
payload, err = base64.RawURLEncoding.DecodeString(flat.Payload)
if err != nil {
return nil, fmt.Errorf("invalid JWS payload encoding: %w", err)
}
}
sig, err := base64.RawURLEncoding.DecodeString(flat.Signature)
if err != nil {
return nil, fmt.Errorf("invalid JWS signature encoding: %w", err)
}
// Signing input is the ASCII bytes of "protected.payload" (not decoded).
signingInput := []byte(flat.Protected + "." + flat.Payload)
return &ParsedJWS{
Header: header,
Payload: payload,
SigningInput: signingInput,
RawSignature: sig,
RawBody: flat,
}, nil
}
// VerifyJWS verifies the signature of a parsed JWS against the given public key.
// Supported algorithms: ES256, ES384, ES512 (ECDSA), RS256 (RSA-PKCS1v15).
func VerifyJWS(parsed *ParsedJWS, pubKey crypto.PublicKey) error {
switch parsed.Header.Alg {
case "ES256":
return verifyECDSA(parsed.SigningInput, parsed.RawSignature, pubKey, crypto.SHA256)
case "ES384":
return verifyECDSA(parsed.SigningInput, parsed.RawSignature, pubKey, crypto.SHA384)
case "ES512":
return verifyECDSA(parsed.SigningInput, parsed.RawSignature, pubKey, crypto.SHA512)
case "RS256":
return verifyRSA(parsed.SigningInput, parsed.RawSignature, pubKey)
default:
return fmt.Errorf("unsupported algorithm: %s", parsed.Header.Alg)
}
}
func verifyECDSA(input, sig []byte, pubKey crypto.PublicKey, hash crypto.Hash) error {
ecKey, ok := pubKey.(*ecdsa.PublicKey)
if !ok {
return errors.New("expected ECDSA public key")
}
var digest []byte
switch hash {
case crypto.SHA256:
h := sha256.Sum256(input)
digest = h[:]
case crypto.SHA384:
h := sha512.Sum384(input)
digest = h[:]
case crypto.SHA512:
h := sha512.Sum512(input)
digest = h[:]
default:
return errors.New("unsupported hash")
}
// ECDSA JWS signatures are the concatenation of R and S, each padded to
// the curve's byte length (RFC 7518 §3.4).
keyBytes := (ecKey.Curve.Params().BitSize + 7) / 8
if len(sig) != 2*keyBytes {
return fmt.Errorf("ECDSA signature has wrong length: got %d, want %d", len(sig), 2*keyBytes)
}
r := new(big.Int).SetBytes(sig[:keyBytes])
s := new(big.Int).SetBytes(sig[keyBytes:])
if !ecdsa.Verify(ecKey, digest, r, s) {
return errors.New("ECDSA signature verification failed")
}
return nil
}
func verifyRSA(input, sig []byte, pubKey crypto.PublicKey) error {
rsaKey, ok := pubKey.(*rsa.PublicKey)
if !ok {
return errors.New("expected RSA public key")
}
digest := sha256.Sum256(input)
return rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, digest[:], sig)
}
// VerifyEAB verifies the External Account Binding inner JWS (RFC 8555 §7.3.4).
// The inner JWS is a MAC over the account JWK bytes using the EAB HMAC key.
// kid must match the KID in the inner JWS header.
// accountJWK is the canonical JSON of the new account's public key (the outer JWK).
func VerifyEAB(eabJWS json.RawMessage, kid string, hmacKey, accountJWK []byte) error {
parsed, err := ParseJWS(eabJWS)
if err != nil {
return fmt.Errorf("invalid EAB JWS: %w", err)
}
if parsed.Header.Alg != "HS256" {
return fmt.Errorf("EAB must use HS256, got %s", parsed.Header.Alg)
}
if parsed.Header.KID != kid {
return fmt.Errorf("EAB kid mismatch: got %q, want %q", parsed.Header.KID, kid)
}
// The EAB payload must be the account JWK (base64url-encoded).
expectedPayload := base64.RawURLEncoding.EncodeToString(accountJWK)
if parsed.RawBody.Payload != expectedPayload {
// Canonical form may differ; compare decoded bytes.
if string(parsed.Payload) != string(accountJWK) {
return errors.New("EAB payload does not match account JWK")
}
}
// Verify HMAC-SHA256.
mac := hmac.New(sha256.New, hmacKey)
mac.Write(parsed.SigningInput)
expected := mac.Sum(nil)
sig, err := base64.RawURLEncoding.DecodeString(parsed.RawBody.Signature)
if err != nil {
return fmt.Errorf("invalid EAB signature encoding: %w", err)
}
if !hmac.Equal(sig, expected) {
return errors.New("EAB HMAC verification failed")
}
return nil
}
// JWK types for JSON unmarshaling.
type jwkEC struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
type jwkRSA struct {
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
}
// ParseJWK parses a JWK (JSON Web Key) into a Go public key.
// Supports EC keys (P-256, P-384, P-521) and RSA keys.
func ParseJWK(jwk json.RawMessage) (crypto.PublicKey, error) {
var kty struct {
Kty string `json:"kty"`
}
if err := json.Unmarshal(jwk, &kty); err != nil {
return nil, fmt.Errorf("invalid JWK: %w", err)
}
switch kty.Kty {
case "EC":
var key jwkEC
if err := json.Unmarshal(jwk, &key); err != nil {
return nil, fmt.Errorf("invalid EC JWK: %w", err)
}
var curve elliptic.Curve
switch key.Crv {
case "P-256":
curve = elliptic.P256()
case "P-384":
curve = elliptic.P384()
case "P-521":
curve = elliptic.P521()
default:
return nil, fmt.Errorf("unsupported EC curve: %s", key.Crv)
}
xBytes, err := base64.RawURLEncoding.DecodeString(key.X)
if err != nil {
return nil, fmt.Errorf("invalid EC JWK x: %w", err)
}
yBytes, err := base64.RawURLEncoding.DecodeString(key.Y)
if err != nil {
return nil, fmt.Errorf("invalid EC JWK y: %w", err)
}
return &ecdsa.PublicKey{
Curve: curve,
X: new(big.Int).SetBytes(xBytes),
Y: new(big.Int).SetBytes(yBytes),
}, nil
case "RSA":
var key jwkRSA
if err := json.Unmarshal(jwk, &key); err != nil {
return nil, fmt.Errorf("invalid RSA JWK: %w", err)
}
nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
if err != nil {
return nil, fmt.Errorf("invalid RSA JWK n: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
if err != nil {
return nil, fmt.Errorf("invalid RSA JWK e: %w", err)
}
// e is a big-endian integer, typically 65537.
eInt := 0
for _, b := range eBytes {
eInt = eInt<<8 | int(b)
}
return &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: eInt,
}, nil
default:
return nil, fmt.Errorf("unsupported key type: %s", kty.Kty)
}
}
// ThumbprintJWK computes the RFC 7638 JWK thumbprint (SHA-256) of a public key JWK.
// Used to compute the key authorization for challenges.
func ThumbprintJWK(jwk json.RawMessage) (string, error) {
var kty struct {
Kty string `json:"kty"`
}
if err := json.Unmarshal(jwk, &kty); err != nil {
return "", fmt.Errorf("invalid JWK: %w", err)
}
// RFC 7638 §3: thumbprint input is a JSON object with only the required
// members in lexicographic order, with no whitespace.
var canonical []byte
var err error
switch kty.Kty {
case "EC":
var key jwkEC
if err = json.Unmarshal(jwk, &key); err != nil {
return "", err
}
canonical, err = json.Marshal(map[string]string{
"crv": key.Crv,
"kty": key.Kty,
"x": key.X,
"y": key.Y,
})
case "RSA":
var key jwkRSA
if err = json.Unmarshal(jwk, &key); err != nil {
return "", err
}
canonical, err = json.Marshal(map[string]string{
"e": key.E,
"kty": key.Kty,
"n": key.N,
})
default:
return "", fmt.Errorf("unsupported key type: %s", kty.Kty)
}
if err != nil {
return "", err
}
digest := sha256.Sum256(canonical)
return base64.RawURLEncoding.EncodeToString(digest[:]), nil
}
// KeyAuthorization computes the key authorization string for a challenge token
// and account JWK: token + "." + base64url(SHA-256(JWK thumbprint)).
func KeyAuthorization(token string, accountJWK json.RawMessage) (string, error) {
thumbprint, err := ThumbprintJWK(accountJWK)
if err != nil {
return "", err
}
return token + "." + thumbprint, nil
}

71
internal/acme/nonce.go Normal file
View File

@@ -0,0 +1,71 @@
package acme
import (
"crypto/rand"
"encoding/base64"
"errors"
"sync"
"time"
)
const nonceLifetime = 10 * time.Minute
// NonceStore is a thread-safe single-use nonce store with expiry.
// Nonces are short-lived per RFC 8555 §7.2.
type NonceStore struct {
mu sync.Mutex
nonces map[string]time.Time
issued int
}
// NewNonceStore creates a new nonce store.
func NewNonceStore() *NonceStore {
return &NonceStore{
nonces: make(map[string]time.Time),
}
}
// Issue generates, stores, and returns a new base64url-encoded nonce.
func (s *NonceStore) Issue() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
nonce := base64.RawURLEncoding.EncodeToString(b)
s.mu.Lock()
defer s.mu.Unlock()
s.nonces[nonce] = time.Now().Add(nonceLifetime)
s.issued++
// Purge expired nonces every 100 issues to bound memory.
if s.issued%100 == 0 {
s.purgeExpiredLocked()
}
return nonce, nil
}
// Consume validates that the nonce exists and has not expired, then removes it.
// Returns an error if the nonce is unknown, expired, or already consumed.
func (s *NonceStore) Consume(nonce string) error {
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.nonces[nonce]
if !ok {
return errors.New("unknown or already-consumed nonce")
}
delete(s.nonces, nonce)
if time.Now().After(exp) {
return errors.New("nonce expired")
}
return nil
}
// purgeExpiredLocked removes all expired nonces. Caller must hold s.mu.
func (s *NonceStore) purgeExpiredLocked() {
now := time.Now()
for n, exp := range s.nonces {
if now.After(exp) {
delete(s.nonces, n)
}
}
}

124
internal/acme/server.go Normal file
View File

@@ -0,0 +1,124 @@
// 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
}

130
internal/acme/types.go Normal file
View File

@@ -0,0 +1,130 @@
package acme
import "time"
// Account represents an ACME account (RFC 8555 §7.1.2).
type Account struct {
ID string `json:"id"`
Status string `json:"status"` // "valid", "deactivated", "revoked"
Contact []string `json:"contact,omitempty"`
JWK []byte `json:"jwk"` // canonical JSON of account public key
CreatedAt time.Time `json:"created_at"`
MCIASUsername string `json:"mcias_username"` // MCIAS user who created via EAB
}
// EABCredential is an External Account Binding credential (RFC 8555 §7.3.4).
type EABCredential struct {
KID string `json:"kid"`
HMACKey []byte `json:"hmac_key"` // raw 32-byte secret
Used bool `json:"used"`
CreatedBy string `json:"created_by"` // MCIAS username
CreatedAt time.Time `json:"created_at"`
}
// Order represents an ACME certificate order (RFC 8555 §7.1.3).
type Order struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
Status string `json:"status"` // "pending","ready","processing","valid","invalid"
Identifiers []Identifier `json:"identifiers"`
AuthzIDs []string `json:"authz_ids"`
CertID string `json:"cert_id,omitempty"`
NotBefore *time.Time `json:"not_before,omitempty"`
NotAfter *time.Time `json:"not_after,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
IssuerName string `json:"issuer_name"` // which CA issuer to sign with
}
// Identifier is a domain name or IP address in an order.
type Identifier struct {
Type string `json:"type"` // "dns" or "ip"
Value string `json:"value"`
}
// Authorization represents an ACME authorization (RFC 8555 §7.1.4).
type Authorization struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
Status string `json:"status"` // "pending","valid","invalid","expired","deactivated","revoked"
Identifier Identifier `json:"identifier"`
ChallengeIDs []string `json:"challenge_ids"`
ExpiresAt time.Time `json:"expires_at"`
}
// Challenge represents an ACME challenge (RFC 8555 §8).
type Challenge struct {
ID string `json:"id"`
AuthzID string `json:"authz_id"`
Type string `json:"type"` // "http-01" or "dns-01"
Status string `json:"status"` // "pending","processing","valid","invalid"
Token string `json:"token"` // base64url, 43 chars (32 random bytes)
Error *ProblemDetail `json:"error,omitempty"`
ValidatedAt *time.Time `json:"validated_at,omitempty"`
}
// ProblemDetail is an RFC 7807 problem detail for ACME errors.
type ProblemDetail struct {
Type string `json:"type"`
Detail string `json:"detail"`
}
// IssuedCert stores the PEM and metadata for a certificate issued via ACME.
type IssuedCert struct {
ID string `json:"id"`
OrderID string `json:"order_id"`
AccountID string `json:"account_id"`
CertPEM string `json:"cert_pem"` // full chain PEM
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
Revoked bool `json:"revoked"`
}
// ACMEConfig is per-mount ACME configuration stored in the barrier.
type ACMEConfig struct {
DefaultIssuer string `json:"default_issuer"` // CA issuer name to use for ACME certs
}
// Status constants.
const (
StatusValid = "valid"
StatusPending = "pending"
StatusProcessing = "processing"
StatusReady = "ready"
StatusInvalid = "invalid"
StatusDeactivated = "deactivated"
StatusRevoked = "revoked"
ChallengeHTTP01 = "http-01"
ChallengeDNS01 = "dns-01"
IdentifierDNS = "dns"
IdentifierIP = "ip"
)
// ACME problem type URIs (RFC 8555 §6.7).
const (
ProblemAccountDoesNotExist = "urn:ietf:params:acme:error:accountDoesNotExist"
ProblemAlreadyRevoked = "urn:ietf:params:acme:error:alreadyRevoked"
ProblemBadCSR = "urn:ietf:params:acme:error:badCSR"
ProblemBadNonce = "urn:ietf:params:acme:error:badNonce"
ProblemBadPublicKey = "urn:ietf:params:acme:error:badPublicKey"
ProblemBadRevocationReason = "urn:ietf:params:acme:error:badRevocationReason"
ProblemBadSignatureAlg = "urn:ietf:params:acme:error:badSignatureAlgorithm"
ProblemCAA = "urn:ietf:params:acme:error:caa"
ProblemConnection = "urn:ietf:params:acme:error:connection"
ProblemDNS = "urn:ietf:params:acme:error:dns"
ProblemExternalAccountRequired = "urn:ietf:params:acme:error:externalAccountRequired"
ProblemIncorrectResponse = "urn:ietf:params:acme:error:incorrectResponse"
ProblemInvalidContact = "urn:ietf:params:acme:error:invalidContact"
ProblemMalformed = "urn:ietf:params:acme:error:malformed"
ProblemOrderNotReady = "urn:ietf:params:acme:error:orderNotReady"
ProblemRateLimited = "urn:ietf:params:acme:error:rateLimited"
ProblemRejectedIdentifier = "urn:ietf:params:acme:error:rejectedIdentifier"
ProblemServerInternal = "urn:ietf:params:acme:error:serverInternal"
ProblemTLS = "urn:ietf:params:acme:error:tls"
ProblemUnauthorized = "urn:ietf:params:acme:error:unauthorized"
ProblemUnsupportedContact = "urn:ietf:params:acme:error:unsupportedContact"
ProblemUnsupportedIdentifier = "urn:ietf:params:acme:error:unsupportedIdentifier"
ProblemUserActionRequired = "urn:ietf:params:acme:error:userActionRequired"
)

274
internal/acme/validate.go Normal file
View 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
}