package acme import ( "bytes" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) // setupACMERouter creates a Handler with an in-memory barrier and registers all // ACME routes on a chi router. All handler tests route through chi so that // chi.URLParam works correctly. func setupACMERouter(t *testing.T) (*Handler, chi.Router) { t.Helper() h := testHandler(t) r := chi.NewRouter() h.RegisterRoutes(r) return h, r } // doACME sends an HTTP request through the chi router and returns the recorder. func doACME(t *testing.T, r chi.Router, method, path string, body []byte) *httptest.ResponseRecorder { t.Helper() var bodyReader io.Reader if body != nil { bodyReader = bytes.NewReader(body) } req := httptest.NewRequest(method, path, bodyReader) w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } // --- Directory --- func TestHandleDirectory(t *testing.T) { _, r := setupACMERouter(t) w := doACME(t, r, http.MethodGet, "/directory", nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if ct := w.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("expected Content-Type application/json, got %s", ct) } var dir directoryResponse if err := json.Unmarshal(w.Body.Bytes(), &dir); err != nil { t.Fatalf("unmarshal directory: %v", err) } base := "https://acme.test/acme/test-pki" if dir.NewNonce != base+"/new-nonce" { t.Fatalf("newNonce = %s, want %s/new-nonce", dir.NewNonce, base) } if dir.NewAccount != base+"/new-account" { t.Fatalf("newAccount = %s, want %s/new-account", dir.NewAccount, base) } if dir.NewOrder != base+"/new-order" { t.Fatalf("newOrder = %s, want %s/new-order", dir.NewOrder, base) } if dir.RevokeCert != base+"/revoke-cert" { t.Fatalf("revokeCert = %s, want %s/revoke-cert", dir.RevokeCert, base) } if dir.Meta == nil { t.Fatalf("meta is nil") } if !dir.Meta.ExternalAccountRequired { t.Fatalf("externalAccountRequired should be true") } } // --- Nonce endpoints --- func TestHandleNewNonceHEAD(t *testing.T) { _, r := setupACMERouter(t) w := doACME(t, r, http.MethodHead, "/new-nonce", nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if nonce := w.Header().Get("Replay-Nonce"); nonce == "" { t.Fatalf("Replay-Nonce header missing") } if cc := w.Header().Get("Cache-Control"); cc != "no-store" { t.Fatalf("Cache-Control = %q, want no-store", cc) } } func TestHandleNewNonceGET(t *testing.T) { _, r := setupACMERouter(t) w := doACME(t, r, http.MethodGet, "/new-nonce", nil) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d", w.Code) } if nonce := w.Header().Get("Replay-Nonce"); nonce == "" { t.Fatalf("Replay-Nonce header missing") } } // --- New Account --- func TestHandleNewAccountSuccess(t *testing.T) { h, r := setupACMERouter(t) ctx := context.Background() // Generate a key pair for the new account. key, jwk := generateES256Key(t) // Create an EAB credential. eab, err := h.CreateEAB(ctx, "testuser") if err != nil { t.Fatalf("create EAB: %v", err) } // Build EAB inner JWS. eabJWS := signEAB(t, eab.KID, eab.HMACKey, jwk) // Build outer payload with EAB. payload, err := json.Marshal(newAccountPayload{ TermsOfServiceAgreed: true, Contact: []string{"mailto:test@example.com"}, ExternalAccountBinding: eabJWS, }) if err != nil { t.Fatalf("marshal payload: %v", err) } nonce := getNonce(t, h) header := JWSHeader{ Alg: "ES256", Nonce: nonce, URL: "https://acme.test/acme/test-pki/new-account", JWK: jwk, } body := signJWS(t, key, "ES256", header, payload) w := doACME(t, r, http.MethodPost, "/new-account", body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d; body: %s", w.Code, w.Body.String()) } loc := w.Header().Get("Location") if loc == "" { t.Fatalf("Location header missing") } if !strings.HasPrefix(loc, "https://acme.test/acme/test-pki/account/") { t.Fatalf("Location = %s, want prefix https://acme.test/acme/test-pki/account/", loc) } var resp map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if resp["status"] != StatusValid { t.Fatalf("status = %v, want %s", resp["status"], StatusValid) } } func TestHandleNewAccountMissingEAB(t *testing.T) { h, r := setupACMERouter(t) key, jwk := generateES256Key(t) // Payload with no externalAccountBinding. payload, err := json.Marshal(newAccountPayload{ TermsOfServiceAgreed: true, }) if err != nil { t.Fatalf("marshal payload: %v", err) } nonce := getNonce(t, h) header := JWSHeader{ Alg: "ES256", Nonce: nonce, URL: "https://acme.test/acme/test-pki/new-account", JWK: jwk, } body := signJWS(t, key, "ES256", header, payload) w := doACME(t, r, http.MethodPost, "/new-account", body) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "externalAccountRequired") { t.Fatalf("response should mention externalAccountRequired, got: %s", w.Body.String()) } } func TestHandleNewAccountBadNonce(t *testing.T) { _, r := setupACMERouter(t) key, jwk := generateES256Key(t) payload, err := json.Marshal(newAccountPayload{ TermsOfServiceAgreed: true, }) if err != nil { t.Fatalf("marshal payload: %v", err) } // Use a random nonce that was never issued. header := JWSHeader{ Alg: "ES256", Nonce: "never-issued-fake-nonce", URL: "https://acme.test/acme/test-pki/new-account", JWK: jwk, } body := signJWS(t, key, "ES256", header, payload) w := doACME(t, r, http.MethodPost, "/new-account", body) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "badNonce") { t.Fatalf("response should contain badNonce, got: %s", w.Body.String()) } } // --- New Order --- // buildKIDJWS creates a JWS signed with KID authentication (for all requests // except new-account). The KID is the full account URL. func buildKIDJWS(t *testing.T, h *Handler, key *ecdsa.PrivateKey, accID, url string, payload []byte) []byte { t.Helper() nonce := getNonce(t, h) header := JWSHeader{ Alg: "ES256", Nonce: nonce, URL: url, KID: h.accountURL(accID), } return signJWS(t, key, "ES256", header, payload) } func TestHandleNewOrderSuccess(t *testing.T) { h, r := setupACMERouter(t) acc, key, _ := createTestAccount(t, h) payload, err := json.Marshal(newOrderPayload{ Identifiers: []Identifier{{Type: IdentifierDNS, Value: "example.com"}}, }) if err != nil { t.Fatalf("marshal payload: %v", err) } url := "https://acme.test/acme/test-pki/new-order" body := buildKIDJWS(t, h, key, acc.ID, url, payload) w := doACME(t, r, http.MethodPost, "/new-order", body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if resp["status"] == nil { t.Fatalf("response missing 'status'") } if resp["identifiers"] == nil { t.Fatalf("response missing 'identifiers'") } authzs, ok := resp["authorizations"].([]interface{}) if !ok || len(authzs) == 0 { t.Fatalf("expected at least 1 authorization URL, got %v", resp["authorizations"]) } if resp["finalize"] == nil { t.Fatalf("response missing 'finalize'") } finalize, ok := resp["finalize"].(string) if !ok || !strings.HasPrefix(finalize, "https://acme.test/acme/test-pki/finalize/") { t.Fatalf("finalize = %v, want prefix https://acme.test/acme/test-pki/finalize/", finalize) } } func TestHandleNewOrderMultipleIdentifiers(t *testing.T) { h, r := setupACMERouter(t) acc, key, _ := createTestAccount(t, h) payload, err := json.Marshal(newOrderPayload{ Identifiers: []Identifier{ {Type: IdentifierDNS, Value: "example.com"}, {Type: IdentifierDNS, Value: "www.example.com"}, }, }) if err != nil { t.Fatalf("marshal payload: %v", err) } url := "https://acme.test/acme/test-pki/new-order" body := buildKIDJWS(t, h, key, acc.ID, url, payload) w := doACME(t, r, http.MethodPost, "/new-order", body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } authzs, ok := resp["authorizations"].([]interface{}) if !ok { t.Fatalf("authorizations is not an array: %v", resp["authorizations"]) } if len(authzs) != 2 { t.Fatalf("expected 2 authorization URLs, got %d", len(authzs)) } } func TestHandleNewOrderEmptyIdentifiers(t *testing.T) { h, r := setupACMERouter(t) acc, key, _ := createTestAccount(t, h) payload, err := json.Marshal(newOrderPayload{ Identifiers: []Identifier{}, }) if err != nil { t.Fatalf("marshal payload: %v", err) } url := "https://acme.test/acme/test-pki/new-order" body := buildKIDJWS(t, h, key, acc.ID, url, payload) w := doACME(t, r, http.MethodPost, "/new-order", body) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String()) } } func TestHandleNewOrderUnsupportedType(t *testing.T) { h, r := setupACMERouter(t) acc, key, _ := createTestAccount(t, h) payload, err := json.Marshal(newOrderPayload{ Identifiers: []Identifier{{Type: "email", Value: "user@example.com"}}, }) if err != nil { t.Fatalf("marshal payload: %v", err) } url := "https://acme.test/acme/test-pki/new-order" body := buildKIDJWS(t, h, key, acc.ID, url, payload) w := doACME(t, r, http.MethodPost, "/new-order", body) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "unsupportedIdentifier") { t.Fatalf("response should contain unsupportedIdentifier, got: %s", w.Body.String()) } } // --- Get Authorization --- // createTestOrder creates an account and an order in the barrier, returning all // the objects needed for subsequent tests. func createTestOrder(t *testing.T, h *Handler, domains ...string) (*Account, *ecdsa.PrivateKey, *Order) { t.Helper() ctx := context.Background() acc, key, _ := createTestAccount(t, h) if len(domains) == 0 { domains = []string{"example.com"} } var identifiers []Identifier var authzIDs []string for _, domain := range domains { authzID := newID() authzIDs = append(authzIDs, authzID) httpChallID := newID() dnsChallID := newID() httpChall := &Challenge{ ID: httpChallID, AuthzID: authzID, Type: ChallengeHTTP01, Status: StatusPending, Token: newToken(), } dnsChall := &Challenge{ ID: dnsChallID, AuthzID: authzID, Type: ChallengeDNS01, Status: StatusPending, Token: newToken(), } identifier := Identifier{Type: IdentifierDNS, Value: domain} identifiers = append(identifiers, identifier) authz := &Authorization{ ID: authzID, AccountID: acc.ID, Status: StatusPending, Identifier: identifier, ChallengeIDs: []string{httpChallID, dnsChallID}, ExpiresAt: time.Now().Add(7 * 24 * time.Hour), } challPrefix := h.barrierPrefix() + "challenges/" authzPrefix := h.barrierPrefix() + "authz/" httpData, _ := json.Marshal(httpChall) dnsData, _ := json.Marshal(dnsChall) authzData, _ := json.Marshal(authz) if err := h.barrier.Put(ctx, challPrefix+httpChallID+".json", httpData); err != nil { t.Fatalf("store http challenge: %v", err) } if err := h.barrier.Put(ctx, challPrefix+dnsChallID+".json", dnsData); err != nil { t.Fatalf("store dns challenge: %v", err) } if err := h.barrier.Put(ctx, authzPrefix+authzID+".json", authzData); err != nil { t.Fatalf("store authz: %v", err) } } orderID := newID() order := &Order{ ID: orderID, AccountID: acc.ID, Status: StatusPending, Identifiers: identifiers, AuthzIDs: authzIDs, ExpiresAt: time.Now().Add(7 * 24 * time.Hour), CreatedAt: time.Now(), IssuerName: "test-issuer", } orderData, _ := json.Marshal(order) if err := h.barrier.Put(ctx, h.barrierPrefix()+"orders/"+orderID+".json", orderData); err != nil { t.Fatalf("store order: %v", err) } return acc, key, order } func TestHandleGetAuthzSuccess(t *testing.T) { h, r := setupACMERouter(t) acc, key, order := createTestOrder(t, h) authzID := order.AuthzIDs[0] reqURL := "https://acme.test/acme/test-pki/authz/" + authzID // POST-as-GET: empty payload. body := buildKIDJWS(t, h, key, acc.ID, reqURL, nil) w := doACME(t, r, http.MethodPost, "/authz/"+authzID, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if resp["status"] == nil { t.Fatalf("response missing 'status'") } if resp["identifier"] == nil { t.Fatalf("response missing 'identifier'") } challenges, ok := resp["challenges"].([]interface{}) if !ok || len(challenges) == 0 { t.Fatalf("expected non-empty challenges array, got %v", resp["challenges"]) } } // --- Challenge --- func TestHandleChallengeTriggersProcessing(t *testing.T) { h, r := setupACMERouter(t) acc, key, order := createTestOrder(t, h) ctx := context.Background() // Load the first authz to get a challenge ID. authzID := order.AuthzIDs[0] authz, err := h.loadAuthz(ctx, authzID) if err != nil { t.Fatalf("load authz: %v", err) } // Find the http-01 challenge. var httpChallID string for _, challID := range authz.ChallengeIDs { chall, err := h.loadChallenge(ctx, challID) if err != nil { continue } if chall.Type == ChallengeHTTP01 { httpChallID = chall.ID break } } if httpChallID == "" { t.Fatalf("no http-01 challenge found") } challPath := "/challenge/" + ChallengeHTTP01 + "/" + httpChallID reqURL := "https://acme.test/acme/test-pki" + challPath body := buildKIDJWS(t, h, key, acc.ID, reqURL, []byte("{}")) w := doACME(t, r, http.MethodPost, challPath, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if resp["status"] != StatusProcessing { t.Fatalf("status = %v, want %s", resp["status"], StatusProcessing) } } // --- Finalize --- func TestHandleFinalizeOrderNotReady(t *testing.T) { h, r := setupACMERouter(t) acc, key, order := createTestOrder(t, h) // Order is in "pending" status, not "ready". csrKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) template := &x509.CertificateRequest{ DNSNames: []string{"example.com"}, } csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, csrKey) if err != nil { t.Fatalf("create CSR: %v", err) } csrB64 := base64.RawURLEncoding.EncodeToString(csrDER) payload, err := json.Marshal(map[string]string{"csr": csrB64}) if err != nil { t.Fatalf("marshal finalize payload: %v", err) } finalizePath := "/finalize/" + order.ID reqURL := "https://acme.test/acme/test-pki" + finalizePath body := buildKIDJWS(t, h, key, acc.ID, reqURL, payload) w := doACME(t, r, http.MethodPost, finalizePath, body) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d; body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "orderNotReady") { t.Fatalf("response should contain orderNotReady, got: %s", w.Body.String()) } } // --- Get Certificate --- func TestHandleGetCertSuccess(t *testing.T) { h, r := setupACMERouter(t) acc, key, _ := createTestAccount(t, h) ctx := context.Background() certPEM := "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n" cert := &IssuedCert{ ID: "test-cert-id", OrderID: "test-order-id", AccountID: acc.ID, CertPEM: certPEM, IssuedAt: time.Now(), ExpiresAt: time.Now().Add(90 * 24 * time.Hour), Revoked: false, } certData, _ := json.Marshal(cert) if err := h.barrier.Put(ctx, h.barrierPrefix()+"certs/test-cert-id.json", certData); err != nil { t.Fatalf("store cert: %v", err) } certPath := "/cert/test-cert-id" reqURL := "https://acme.test/acme/test-pki" + certPath body := buildKIDJWS(t, h, key, acc.ID, reqURL, nil) w := doACME(t, r, http.MethodPost, certPath, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "application/pem-certificate-chain" { t.Fatalf("Content-Type = %s, want application/pem-certificate-chain", ct) } if !strings.Contains(w.Body.String(), "BEGIN CERTIFICATE") { t.Fatalf("response body should contain PEM certificate, got: %s", w.Body.String()) } } func TestHandleGetCertNotFound(t *testing.T) { h, r := setupACMERouter(t) acc, key, _ := createTestAccount(t, h) certPath := "/cert/nonexistent-cert-id" reqURL := "https://acme.test/acme/test-pki" + certPath body := buildKIDJWS(t, h, key, acc.ID, reqURL, nil) w := doACME(t, r, http.MethodPost, certPath, body) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d; body: %s", w.Code, w.Body.String()) } } func TestHandleGetCertRevoked(t *testing.T) { h, r := setupACMERouter(t) acc, key, _ := createTestAccount(t, h) ctx := context.Background() cert := &IssuedCert{ ID: "revoked-cert-id", OrderID: "test-order-id", AccountID: acc.ID, CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n", IssuedAt: time.Now(), ExpiresAt: time.Now().Add(90 * 24 * time.Hour), Revoked: true, } certData, _ := json.Marshal(cert) if err := h.barrier.Put(ctx, h.barrierPrefix()+"certs/revoked-cert-id.json", certData); err != nil { t.Fatalf("store cert: %v", err) } certPath := "/cert/revoked-cert-id" reqURL := "https://acme.test/acme/test-pki" + certPath body := buildKIDJWS(t, h, key, acc.ID, reqURL, nil) w := doACME(t, r, http.MethodPost, certPath, body) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d; body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "alreadyRevoked") { t.Fatalf("response should contain alreadyRevoked, got: %s", w.Body.String()) } } // --- CSR Validation (pure function) --- func TestValidateCSRIdentifiersDNSMatch(t *testing.T) { h := testHandler(t) csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("generate CSR key: %v", err) } template := &x509.CertificateRequest{ DNSNames: []string{"example.com", "www.example.com"}, } csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, csrKey) if err != nil { t.Fatalf("create CSR: %v", err) } csr, err := x509.ParseCertificateRequest(csrDER) if err != nil { t.Fatalf("parse CSR: %v", err) } identifiers := []Identifier{ {Type: IdentifierDNS, Value: "example.com"}, {Type: IdentifierDNS, Value: "www.example.com"}, } if err := h.validateCSRIdentifiers(csr, identifiers); err != nil { t.Fatalf("validateCSRIdentifiers() unexpected error: %v", err) } } func TestValidateCSRIdentifiersDNSMismatch(t *testing.T) { h := testHandler(t) csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("generate CSR key: %v", err) } // CSR has an extra SAN (evil.com) not in the order. template := &x509.CertificateRequest{ DNSNames: []string{"example.com", "evil.com"}, } csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, csrKey) if err != nil { t.Fatalf("create CSR: %v", err) } csr, err := x509.ParseCertificateRequest(csrDER) if err != nil { t.Fatalf("parse CSR: %v", err) } identifiers := []Identifier{ {Type: IdentifierDNS, Value: "example.com"}, } if err := h.validateCSRIdentifiers(csr, identifiers); err == nil { t.Fatalf("expected error for CSR with extra SAN, got nil") } } func TestValidateCSRIdentifiersMissing(t *testing.T) { h := testHandler(t) csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("generate CSR key: %v", err) } // CSR is missing www.example.com that the order requires. template := &x509.CertificateRequest{ DNSNames: []string{"example.com"}, } csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, csrKey) if err != nil { t.Fatalf("create CSR: %v", err) } csr, err := x509.ParseCertificateRequest(csrDER) if err != nil { t.Fatalf("parse CSR: %v", err) } identifiers := []Identifier{ {Type: IdentifierDNS, Value: "example.com"}, {Type: IdentifierDNS, Value: "www.example.com"}, } if err := h.validateCSRIdentifiers(csr, identifiers); err == nil { t.Fatalf("expected error for CSR missing a SAN, got nil") } } // --- Helper function tests --- func TestExtractIDFromURL(t *testing.T) { url := "https://acme.test/acme/ca/account/abc123" id := extractIDFromURL(url, "/account/") if id != "abc123" { t.Fatalf("extractIDFromURL() = %q, want %q", id, "abc123") } // Test with a URL that does not contain the prefix. id = extractIDFromURL("https://acme.test/other/path", "/account/") if id != "" { t.Fatalf("extractIDFromURL() with no match = %q, want empty", id) } } func TestNewIDFormat(t *testing.T) { id := newID() // 16 bytes base64url-encoded = 22 characters (no padding). if len(id) != 22 { t.Fatalf("newID() length = %d, want 22", len(id)) } // Verify it is valid base64url. decoded, err := base64.RawURLEncoding.DecodeString(id) if err != nil { t.Fatalf("newID() produced invalid base64url: %v", err) } if len(decoded) != 16 { t.Fatalf("newID() decoded to %d bytes, want 16", len(decoded)) } } func TestNewTokenFormat(t *testing.T) { tok := newToken() // 32 bytes base64url-encoded = 43 characters (no padding). if len(tok) != 43 { t.Fatalf("newToken() length = %d, want 43", len(tok)) } decoded, err := base64.RawURLEncoding.DecodeString(tok) if err != nil { t.Fatalf("newToken() produced invalid base64url: %v", err) } if len(decoded) != 32 { t.Fatalf("newToken() decoded to %d bytes, want 32", len(decoded)) } }