All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
877 lines
26 KiB
Go
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/mc/metacrypt/internal/engine"
|
|
)
|
|
|
|
// directoryResponse is the ACME directory object (RFC 8555 §7.1.1).
|
|
type directoryResponse struct {
|
|
Meta *directoryMeta `json:"meta,omitempty"`
|
|
NewNonce string `json:"newNonce"`
|
|
NewAccount string `json:"newAccount"`
|
|
NewOrder string `json:"newOrder"`
|
|
RevokeCert string `json:"revokeCert"`
|
|
KeyChange string `json:"keyChange"`
|
|
}
|
|
|
|
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 {
|
|
Contact []string `json:"contact,omitempty"`
|
|
ExternalAccountBinding json.RawMessage `json:"externalAccountBinding,omitempty"`
|
|
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
|
|
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 {
|
|
NotBefore string `json:"notBefore,omitempty"`
|
|
NotAfter string `json:"notAfter,omitempty"`
|
|
Identifiers []Identifier `json:"identifiers"`
|
|
}
|
|
|
|
// 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) //nolint:gosec
|
|
}
|
|
|
|
// 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 func() { _ = 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
|
|
}
|