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": ""} 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 }