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:
876
internal/acme/handlers.go
Normal file
876
internal/acme/handlers.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user