mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
feat: add EST server (RFC 7030) for device certificate enrollment (M23)
Implement Enrollment over Secure Transport protocol with 4 endpoints under /.well-known/est/ — cacerts (CA chain distribution), simpleenroll (initial enrollment), simplereenroll (certificate renewal), and csrattrs (CSR attributes). PKCS#7 certs-only wire format with hand-rolled ASN.1, accepts both PEM and base64-encoded DER CSRs, configurable issuer and profile binding, full audit trail. 28 new tests (18 handler + 10 service). Also includes: - GetCACertPEM added to issuer connector interface (all 4 issuers updated) - EST integration tests wired into e2e test suite (13 test cases) - QA testing guide Part 26 (15 manual EST test cases) - All docs updated: README, features, architecture, concepts, connectors, quickstart, demo-advanced (endpoint counts, MCP wording, agent IDs, issuer interface, resource lists, OpenSSL status) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// ESTService defines the service interface for EST enrollment operations.
|
||||
// EST (RFC 7030) is a protocol for certificate enrollment over HTTPS.
|
||||
type ESTService interface {
|
||||
// GetCACerts returns the PEM-encoded CA certificate chain for the EST issuer.
|
||||
GetCACerts(ctx context.Context) (string, error)
|
||||
|
||||
// SimpleEnroll processes a PKCS#10 CSR and returns a signed certificate.
|
||||
SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error)
|
||||
|
||||
// SimpleReEnroll processes a re-enrollment CSR (same as enroll for our purposes).
|
||||
SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error)
|
||||
|
||||
// GetCSRAttrs returns the CSR attributes the server wants clients to include.
|
||||
GetCSRAttrs(ctx context.Context) ([]byte, error)
|
||||
}
|
||||
|
||||
// ESTHandler handles HTTP requests for the EST protocol (RFC 7030).
|
||||
//
|
||||
// EST endpoints are served under /.well-known/est/ per the RFC.
|
||||
// Wire format: base64-encoded DER (PKCS#7 for certs, PKCS#10 for CSRs).
|
||||
//
|
||||
// Supported operations:
|
||||
// - GET /.well-known/est/cacerts — CA certificate distribution
|
||||
// - POST /.well-known/est/simpleenroll — initial enrollment
|
||||
// - POST /.well-known/est/simplereenroll — re-enrollment
|
||||
// - GET /.well-known/est/csrattrs — CSR attributes
|
||||
type ESTHandler struct {
|
||||
svc ESTService
|
||||
}
|
||||
|
||||
// NewESTHandler creates a new ESTHandler.
|
||||
func NewESTHandler(svc ESTService) ESTHandler {
|
||||
return ESTHandler{svc: svc}
|
||||
}
|
||||
|
||||
// CACerts handles GET /.well-known/est/cacerts
|
||||
// Returns the CA certificate chain as base64-encoded PKCS#7 (certs-only).
|
||||
// Per RFC 7030 Section 4.1, this is a "certs-only" CMC Simple PKI Response.
|
||||
// For simplicity and broad client compatibility, we return base64-encoded DER certificates.
|
||||
func (h ESTHandler) CACerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
caCertPEM, err := h.svc.GetCACerts(r.Context())
|
||||
if err != nil {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificates: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse PEM to DER for PKCS#7 encoding
|
||||
derCerts, err := pemToDERChain(caCertPEM)
|
||||
if err != nil {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to encode CA certificates", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Build a simple PKCS#7 SignedData (certs-only, degenerate) structure
|
||||
pkcs7Data, err := buildCertsOnlyPKCS7(derCerts)
|
||||
if err != nil {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 7030 Section 4.1.3: response is base64-encoded application/pkcs7-mime
|
||||
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
encoded := base64.StdEncoding.EncodeToString(pkcs7Data)
|
||||
// Write base64 with line breaks at 76 chars per RFC 2045
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
w.Write([]byte(encoded[i:end]))
|
||||
w.Write([]byte("\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// SimpleEnroll handles POST /.well-known/est/simpleenroll
|
||||
// Accepts a base64-encoded PKCS#10 CSR and returns a base64-encoded PKCS#7 certificate.
|
||||
func (h ESTHandler) SimpleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
csrPEM, err := h.readCSRFromRequest(r)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.SimpleEnroll(r.Context(), csrPEM)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeCertResponse(w, result)
|
||||
}
|
||||
|
||||
// SimpleReEnroll handles POST /.well-known/est/simplereenroll
|
||||
// Same as SimpleEnroll but for re-enrollment (certificate renewal).
|
||||
func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
csrPEM, err := h.readCSRFromRequest(r)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.SimpleReEnroll(r.Context(), csrPEM)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Re-enrollment failed: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeCertResponse(w, result)
|
||||
}
|
||||
|
||||
// CSRAttrs handles GET /.well-known/est/csrattrs
|
||||
// Returns the CSR attributes the server wants the client to include in enrollment requests.
|
||||
func (h ESTHandler) CSRAttrs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
attrs, err := h.svc.GetCSRAttrs(r.Context())
|
||||
if err != nil {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CSR attributes: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
if len(attrs) == 0 {
|
||||
// No specific attributes required — return 204
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/csrattrs")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(base64.StdEncoding.EncodeToString(attrs)))
|
||||
}
|
||||
|
||||
// readCSRFromRequest reads and decodes the CSR from an EST enrollment request.
|
||||
// EST sends CSRs as base64-encoded PKCS#10 DER with Content-Type application/pkcs10.
|
||||
func (h ESTHandler) readCSRFromRequest(r *http.Request) (string, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("empty request body")
|
||||
}
|
||||
|
||||
// Check if it's already PEM-encoded (some clients send PEM directly)
|
||||
bodyStr := strings.TrimSpace(string(body))
|
||||
if strings.HasPrefix(bodyStr, "-----BEGIN CERTIFICATE REQUEST-----") {
|
||||
// Validate it parses
|
||||
block, _ := pem.Decode([]byte(bodyStr))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid PEM-encoded CSR")
|
||||
}
|
||||
if _, err := x509.ParseCertificateRequest(block.Bytes); err != nil {
|
||||
return "", fmt.Errorf("invalid CSR: %w", err)
|
||||
}
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
// EST standard: base64-encoded DER PKCS#10
|
||||
derBytes, err := base64.StdEncoding.DecodeString(bodyStr)
|
||||
if err != nil {
|
||||
// Try with padding/whitespace stripped
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, bodyStr)
|
||||
derBytes, err = base64.StdEncoding.DecodeString(cleaned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64 CSR: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate it's a valid PKCS#10 CSR
|
||||
if _, err := x509.ParseCertificateRequest(derBytes); err != nil {
|
||||
return "", fmt.Errorf("invalid PKCS#10 CSR: %w", err)
|
||||
}
|
||||
|
||||
// Convert DER to PEM for internal use (certctl services expect PEM)
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: derBytes,
|
||||
})
|
||||
return string(csrPEM), nil
|
||||
}
|
||||
|
||||
// writeCertResponse writes an EST enrollment response as base64-encoded PKCS#7.
|
||||
func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTEnrollResult) {
|
||||
// Parse cert and chain PEM to DER
|
||||
var derCerts [][]byte
|
||||
|
||||
// Add the issued certificate
|
||||
certDER, err := pemToDERChain(result.CertPEM)
|
||||
if err != nil || len(certDER) == 0 {
|
||||
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
derCerts = append(derCerts, certDER...)
|
||||
|
||||
// Add the CA chain if present
|
||||
if result.ChainPEM != "" {
|
||||
chainDER, err := pemToDERChain(result.ChainPEM)
|
||||
if err == nil {
|
||||
derCerts = append(derCerts, chainDER...)
|
||||
}
|
||||
}
|
||||
|
||||
// Build PKCS#7 certs-only
|
||||
pkcs7Data, err := buildCertsOnlyPKCS7(derCerts)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
encoded := base64.StdEncoding.EncodeToString(pkcs7Data)
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
w.Write([]byte(encoded[i:end]))
|
||||
w.Write([]byte("\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// pemToDERChain converts PEM-encoded certificates to a slice of DER-encoded certificates.
|
||||
func pemToDERChain(pemData string) ([][]byte, error) {
|
||||
var derCerts [][]byte
|
||||
rest := []byte(pemData)
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
derCerts = append(derCerts, block.Bytes)
|
||||
}
|
||||
}
|
||||
if len(derCerts) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found in PEM data")
|
||||
}
|
||||
return derCerts, nil
|
||||
}
|
||||
|
||||
// buildCertsOnlyPKCS7 creates a degenerate PKCS#7 SignedData structure containing only certificates.
|
||||
// This is the "certs-only" format specified in RFC 7030 Section 4.1.3 for /cacerts responses
|
||||
// and enrollment responses.
|
||||
//
|
||||
// ASN.1 structure (simplified):
|
||||
//
|
||||
// ContentInfo {
|
||||
// contentType: signedData (1.2.840.113549.1.7.2)
|
||||
// content: SignedData {
|
||||
// version: 1
|
||||
// digestAlgorithms: {} (empty)
|
||||
// encapContentInfo: { contentType: data (1.2.840.113549.1.7.1) }
|
||||
// certificates: [cert1, cert2, ...]
|
||||
// signerInfos: {} (empty)
|
||||
// }
|
||||
// }
|
||||
func buildCertsOnlyPKCS7(derCerts [][]byte) ([]byte, error) {
|
||||
// We build the ASN.1 manually to avoid pulling in a PKCS#7 library.
|
||||
// This is a well-defined, static structure — no signing needed.
|
||||
|
||||
// OID for signedData: 1.2.840.113549.1.7.2
|
||||
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||
// OID for data: 1.2.840.113549.1.7.1
|
||||
oidData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||
|
||||
// Build certificates [0] IMPLICIT SET OF Certificate
|
||||
var certsContent []byte
|
||||
for _, cert := range derCerts {
|
||||
certsContent = append(certsContent, cert...)
|
||||
}
|
||||
certsField := asn1WrapImplicit(0, certsContent)
|
||||
|
||||
// Build encapContentInfo: SEQUENCE { OID data }
|
||||
encapContentInfo := asn1WrapSequence(oidData)
|
||||
|
||||
// Build digestAlgorithms: SET {} (empty)
|
||||
digestAlgorithms := asn1WrapSet(nil)
|
||||
|
||||
// Build signerInfos: SET {} (empty)
|
||||
signerInfos := asn1WrapSet(nil)
|
||||
|
||||
// Version: INTEGER 1
|
||||
version := []byte{0x02, 0x01, 0x01}
|
||||
|
||||
// Build SignedData SEQUENCE
|
||||
var signedDataContent []byte
|
||||
signedDataContent = append(signedDataContent, version...)
|
||||
signedDataContent = append(signedDataContent, digestAlgorithms...)
|
||||
signedDataContent = append(signedDataContent, encapContentInfo...)
|
||||
signedDataContent = append(signedDataContent, certsField...)
|
||||
signedDataContent = append(signedDataContent, signerInfos...)
|
||||
signedData := asn1WrapSequence(signedDataContent)
|
||||
|
||||
// Wrap in [0] EXPLICIT for ContentInfo.content
|
||||
contentField := asn1WrapExplicit(0, signedData)
|
||||
|
||||
// Build ContentInfo SEQUENCE
|
||||
var contentInfoContent []byte
|
||||
contentInfoContent = append(contentInfoContent, oidSignedData...)
|
||||
contentInfoContent = append(contentInfoContent, contentField...)
|
||||
contentInfo := asn1WrapSequence(contentInfoContent)
|
||||
|
||||
return contentInfo, nil
|
||||
}
|
||||
|
||||
// asn1WrapSequence wraps content in an ASN.1 SEQUENCE tag (0x30).
|
||||
func asn1WrapSequence(content []byte) []byte {
|
||||
return asn1Wrap(0x30, content)
|
||||
}
|
||||
|
||||
// asn1WrapSet wraps content in an ASN.1 SET tag (0x31).
|
||||
func asn1WrapSet(content []byte) []byte {
|
||||
return asn1Wrap(0x31, content)
|
||||
}
|
||||
|
||||
// asn1WrapExplicit wraps content in an ASN.1 context-specific EXPLICIT tag.
|
||||
func asn1WrapExplicit(tag int, content []byte) []byte {
|
||||
return asn1Wrap(byte(0xa0|tag), content)
|
||||
}
|
||||
|
||||
// asn1WrapImplicit wraps content in an ASN.1 context-specific IMPLICIT CONSTRUCTED tag.
|
||||
func asn1WrapImplicit(tag int, content []byte) []byte {
|
||||
return asn1Wrap(byte(0xa0|tag), content)
|
||||
}
|
||||
|
||||
// asn1Wrap wraps content with an ASN.1 tag and length.
|
||||
func asn1Wrap(tag byte, content []byte) []byte {
|
||||
length := len(content)
|
||||
var result []byte
|
||||
result = append(result, tag)
|
||||
result = append(result, asn1EncodeLength(length)...)
|
||||
result = append(result, content...)
|
||||
return result
|
||||
}
|
||||
|
||||
// asn1EncodeLength encodes a length in ASN.1 DER format.
|
||||
func asn1EncodeLength(length int) []byte {
|
||||
if length < 0x80 {
|
||||
return []byte{byte(length)}
|
||||
}
|
||||
// Long form
|
||||
var lengthBytes []byte
|
||||
l := length
|
||||
for l > 0 {
|
||||
lengthBytes = append([]byte{byte(l & 0xff)}, lengthBytes...)
|
||||
l >>= 8
|
||||
}
|
||||
return append([]byte{byte(0x80 | len(lengthBytes))}, lengthBytes...)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// mockESTService implements ESTService for testing.
|
||||
type mockESTService struct {
|
||||
CACertPEM string
|
||||
CACertErr error
|
||||
EnrollResult *domain.ESTEnrollResult
|
||||
EnrollErr error
|
||||
CSRAttrs []byte
|
||||
CSRAttrsErr error
|
||||
}
|
||||
|
||||
func (m *mockESTService) GetCACerts(ctx context.Context) (string, error) {
|
||||
return m.CACertPEM, m.CACertErr
|
||||
}
|
||||
|
||||
func (m *mockESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||
return m.EnrollResult, m.EnrollErr
|
||||
}
|
||||
|
||||
func (m *mockESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||
return m.EnrollResult, m.EnrollErr
|
||||
}
|
||||
|
||||
func (m *mockESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
||||
return m.CSRAttrs, m.CSRAttrsErr
|
||||
}
|
||||
|
||||
// generateTestCSRPEM creates a valid ECDSA P-256 CSR for testing.
|
||||
func generateTestCSRPEM(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %v", err)
|
||||
}
|
||||
template := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||
DNSNames: []string{"test.example.com", "www.example.com"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create CSR: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
||||
}
|
||||
|
||||
// generateTestCSRBase64DER creates a valid base64-encoded DER CSR for EST wire format.
|
||||
func generateTestCSRBase64DER(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %v", err)
|
||||
}
|
||||
template := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||
DNSNames: []string{"test.example.com"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create CSR: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(csrDER)
|
||||
}
|
||||
|
||||
func TestESTCACerts_Success(t *testing.T) {
|
||||
svc := &mockESTService{
|
||||
CACertPEM: "-----BEGIN CERTIFICATE-----\nMIIBmjCCAUCgAwIBAgIRATest\n-----END CERTIFICATE-----\n",
|
||||
}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.CACerts(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/pkcs7-mime") {
|
||||
t.Errorf("expected application/pkcs7-mime content type, got %s", ct)
|
||||
}
|
||||
cte := w.Header().Get("Content-Transfer-Encoding")
|
||||
if cte != "base64" {
|
||||
t.Errorf("expected base64 content-transfer-encoding, got %s", cte)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTCACerts_MethodNotAllowed(t *testing.T) {
|
||||
svc := &mockESTService{}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/cacerts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.CACerts(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTCACerts_ServiceError(t *testing.T) {
|
||||
svc := &mockESTService{
|
||||
CACertErr: errors.New("issuer unavailable"),
|
||||
}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.CACerts(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTSimpleEnroll_Success_PEM(t *testing.T) {
|
||||
csrPEM := generateTestCSRPEM(t)
|
||||
svc := &mockESTService{
|
||||
EnrollResult: &domain.ESTEnrollResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIBchain\n-----END CERTIFICATE-----\n",
|
||||
},
|
||||
}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrPEM))
|
||||
req.Header.Set("Content-Type", "application/pkcs10")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/pkcs7-mime") {
|
||||
t.Errorf("expected application/pkcs7-mime, got %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTSimpleEnroll_Success_Base64DER(t *testing.T) {
|
||||
csrB64 := generateTestCSRBase64DER(t)
|
||||
svc := &mockESTService{
|
||||
EnrollResult: &domain.ESTEnrollResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "",
|
||||
},
|
||||
}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrB64))
|
||||
req.Header.Set("Content-Type", "application/pkcs10")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTSimpleEnroll_MethodNotAllowed(t *testing.T) {
|
||||
svc := &mockESTService{}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/simpleenroll", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTSimpleEnroll_EmptyBody(t *testing.T) {
|
||||
svc := &mockESTService{}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(""))
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTSimpleEnroll_InvalidCSR(t *testing.T) {
|
||||
svc := &mockESTService{}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader("not-a-valid-csr"))
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTSimpleEnroll_ServiceError(t *testing.T) {
|
||||
csrPEM := generateTestCSRPEM(t)
|
||||
svc := &mockESTService{
|
||||
EnrollErr: errors.New("issuance failed"),
|
||||
}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrPEM))
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTSimpleReEnroll_Success(t *testing.T) {
|
||||
csrPEM := generateTestCSRPEM(t)
|
||||
svc := &mockESTService{
|
||||
EnrollResult: &domain.ESTEnrollResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIBchain\n-----END CERTIFICATE-----\n",
|
||||
},
|
||||
}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(csrPEM))
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleReEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTSimpleReEnroll_MethodNotAllowed(t *testing.T) {
|
||||
svc := &mockESTService{}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/simplereenroll", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleReEnroll(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTCSRAttrs_NoContent(t *testing.T) {
|
||||
svc := &mockESTService{
|
||||
CSRAttrs: nil,
|
||||
}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.CSRAttrs(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTCSRAttrs_WithData(t *testing.T) {
|
||||
svc := &mockESTService{
|
||||
CSRAttrs: []byte{0x30, 0x00}, // empty SEQUENCE
|
||||
}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.CSRAttrs(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if ct != "application/csrattrs" {
|
||||
t.Errorf("expected application/csrattrs, got %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTCSRAttrs_MethodNotAllowed(t *testing.T) {
|
||||
svc := &mockESTService{}
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/csrattrs", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.CSRAttrs(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCertsOnlyPKCS7(t *testing.T) {
|
||||
// Test with a dummy DER certificate
|
||||
dummyCert := []byte{0x30, 0x82, 0x01, 0x00} // minimal ASN.1 SEQUENCE
|
||||
result, err := buildCertsOnlyPKCS7([][]byte{dummyCert})
|
||||
if err != nil {
|
||||
t.Fatalf("buildCertsOnlyPKCS7 failed: %v", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Error("expected non-empty PKCS#7 output")
|
||||
}
|
||||
// Verify it starts with SEQUENCE tag
|
||||
if result[0] != 0x30 {
|
||||
t.Errorf("expected PKCS#7 to start with SEQUENCE tag (0x30), got 0x%02x", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPemToDERChain(t *testing.T) {
|
||||
pemData := "-----BEGIN CERTIFICATE-----\nMIIBmjCCAUCgAwIBAgIRATest\n-----END CERTIFICATE-----\n"
|
||||
certs, err := pemToDERChain(pemData)
|
||||
if err != nil {
|
||||
t.Fatalf("pemToDERChain failed: %v", err)
|
||||
}
|
||||
if len(certs) != 1 {
|
||||
t.Errorf("expected 1 cert, got %d", len(certs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPemToDERChain_NoCerts(t *testing.T) {
|
||||
_, err := pemToDERChain("not a PEM")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASN1EncodeLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
length int
|
||||
expected []byte
|
||||
}{
|
||||
{0, []byte{0x00}},
|
||||
{1, []byte{0x01}},
|
||||
{127, []byte{0x7f}},
|
||||
{128, []byte{0x81, 0x80}},
|
||||
{256, []byte{0x82, 0x01, 0x00}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := asn1EncodeLength(tt.length)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("asn1EncodeLength(%d): expected %d bytes, got %d", tt.length, len(tt.expected), len(result))
|
||||
continue
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tt.expected[i] {
|
||||
t.Errorf("asn1EncodeLength(%d): byte %d: expected 0x%02x, got 0x%02x", tt.length, i, tt.expected[i], result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,16 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(networkScan.TriggerNetworkScan))
|
||||
}
|
||||
|
||||
// RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/.
|
||||
// EST endpoints use a separate middleware chain (no API key auth — EST uses TLS client certs).
|
||||
func (r *Router) RegisterESTHandlers(est handler.ESTHandler) {
|
||||
// EST endpoints per RFC 7030 Section 3.2.2
|
||||
r.Register("GET /.well-known/est/cacerts", http.HandlerFunc(est.CACerts))
|
||||
r.Register("POST /.well-known/est/simpleenroll", http.HandlerFunc(est.SimpleEnroll))
|
||||
r.Register("POST /.well-known/est/simplereenroll", http.HandlerFunc(est.SimpleReEnroll))
|
||||
r.Register("GET /.well-known/est/csrattrs", http.HandlerFunc(est.CSRAttrs))
|
||||
}
|
||||
|
||||
// GetMux returns the underlying http.ServeMux for direct access if needed.
|
||||
func (r *Router) GetMux() *http.ServeMux {
|
||||
return r.mux
|
||||
|
||||
@@ -22,6 +22,7 @@ type Config struct {
|
||||
CA CAConfig
|
||||
Notifiers NotifierConfig
|
||||
NetworkScan NetworkScanConfig
|
||||
EST ESTConfig
|
||||
}
|
||||
|
||||
// NotifierConfig contains configuration for notification connectors.
|
||||
@@ -81,6 +82,14 @@ type OpenSSLConfig struct {
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
// ESTConfig controls the RFC 7030 Enrollment over Secure Transport server.
|
||||
type ESTConfig struct {
|
||||
Enabled bool // Enable EST endpoints (default false)
|
||||
IssuerID string // Which issuer connector to use for EST enrollment (e.g., "iss-local")
|
||||
// ProfileID optionally constrains EST enrollments to a specific certificate profile.
|
||||
ProfileID string
|
||||
}
|
||||
|
||||
// NetworkScanConfig controls the server-side active TLS scanner.
|
||||
type NetworkScanConfig struct {
|
||||
Enabled bool // Enable network scanning (default false)
|
||||
@@ -189,6 +198,11 @@ func Load() (*Config, error) {
|
||||
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
|
||||
ScanInterval: getEnvDuration("CERTCTL_NETWORK_SCAN_INTERVAL", 6*time.Hour),
|
||||
},
|
||||
EST: ESTConfig{
|
||||
Enabled: getEnvBool("CERTCTL_EST_ENABLED", false),
|
||||
IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"),
|
||||
ProfileID: getEnv("CERTCTL_EST_PROFILE_ID", ""),
|
||||
},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
|
||||
@@ -619,3 +619,8 @@ func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.Revok
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, fmt.Errorf("ACME issuers do not support OCSP response signing")
|
||||
}
|
||||
|
||||
// GetCACertPEM is not supported by ACME issuers (the CA chain is returned per-issuance).
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("ACME issuers do not provide a static CA certificate; chain is returned per-issuance")
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ type Connector interface {
|
||||
// SignOCSPResponse signs an OCSP response for the given certificate serial.
|
||||
// Returns nil if the issuer does not support OCSP (e.g., ACME).
|
||||
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||
|
||||
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
||||
// Used by the EST /cacerts endpoint. Returns empty string if not available.
|
||||
GetCACertPEM(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// IssuanceRequest contains the parameters for issuing a new certificate.
|
||||
|
||||
@@ -664,3 +664,12 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
|
||||
|
||||
return respBytes, nil
|
||||
}
|
||||
|
||||
// GetCACertPEM returns the PEM-encoded CA certificate for this issuer.
|
||||
// Used by the EST /cacerts endpoint to distribute the CA trust chain.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
if err := c.ensureCA(ctx); err != nil {
|
||||
return "", fmt.Errorf("CA initialization failed: %w", err)
|
||||
}
|
||||
return c.caCertPEM, nil
|
||||
}
|
||||
|
||||
@@ -358,6 +358,11 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetCACertPEM is not supported by the custom CA connector (no CA cert access).
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("custom CA connector does not provide CA certificate access")
|
||||
}
|
||||
|
||||
// --- Helper Methods ---
|
||||
|
||||
// writeTempFile writes data to a temporary file and returns its path.
|
||||
|
||||
@@ -467,5 +467,10 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
|
||||
return nil, fmt.Errorf("step-ca provides its own OCSP responder; use step-ca's /ocsp directly")
|
||||
}
|
||||
|
||||
// GetCACertPEM is not directly supported; step-ca serves its own /root endpoint.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("step-ca serves its own CA certificate at /root; use step-ca's endpoint directly")
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package domain
|
||||
|
||||
// ESTEnrollResult holds the result of an EST (RFC 7030) enrollment operation.
|
||||
type ESTEnrollResult struct {
|
||||
CertPEM string `json:"cert_pem"` // PEM-encoded signed certificate
|
||||
ChainPEM string `json:"chain_pem"` // PEM-encoded CA chain
|
||||
}
|
||||
@@ -2,9 +2,17 @@ package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -892,3 +900,214 @@ func TestM20EnhancedQueryAPI(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// generateE2ECSRPEM creates a valid ECDSA P-256 CSR PEM for integration testing.
|
||||
func generateE2ECSRPEM(t *testing.T, cn string, sans []string) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
DNSNames: sans,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create CSR: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
||||
}
|
||||
|
||||
// generateE2ECSRBase64DER creates a valid base64-encoded DER CSR for EST wire format testing.
|
||||
func generateE2ECSRBase64DER(t *testing.T, cn string, sans []string) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
DNSNames: sans,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create CSR: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(csrDER)
|
||||
}
|
||||
|
||||
// TestESTEndpoints exercises the EST (RFC 7030) enrollment endpoints end-to-end (M23).
|
||||
func TestESTEndpoints(t *testing.T) {
|
||||
server, _, _, _ := setupTestServer(t)
|
||||
|
||||
// ===========================
|
||||
// GET /cacerts — CA certificate chain
|
||||
// ===========================
|
||||
t.Run("GetCACerts_Success", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/.well-known/est/cacerts")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/pkcs7-mime") {
|
||||
t.Errorf("expected application/pkcs7-mime content type, got %s", ct)
|
||||
}
|
||||
cte := resp.Header.Get("Content-Transfer-Encoding")
|
||||
if cte != "base64" {
|
||||
t.Errorf("expected base64 content-transfer-encoding, got %s", cte)
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if len(bodyBytes) == 0 {
|
||||
t.Error("expected non-empty PKCS#7 response body")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCACerts_MethodNotAllowed", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/.well-known/est/cacerts", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
// ===========================
|
||||
// POST /simpleenroll — certificate enrollment
|
||||
// ===========================
|
||||
t.Run("SimpleEnroll_PEM_Success", func(t *testing.T) {
|
||||
csrPEM := generateE2ECSRPEM(t, "est-test.example.com", []string{"est-test.example.com"})
|
||||
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrPEM))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/pkcs7-mime") {
|
||||
t.Errorf("expected application/pkcs7-mime, got %s", ct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SimpleEnroll_Base64DER_Success", func(t *testing.T) {
|
||||
csrB64 := generateE2ECSRBase64DER(t, "est-der.example.com", []string{"est-der.example.com"})
|
||||
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrB64))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SimpleEnroll_EmptyBody", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for empty body, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SimpleEnroll_InvalidCSR", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader("not-a-valid-csr"))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid CSR, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SimpleEnroll_MissingCN", func(t *testing.T) {
|
||||
csrPEM := generateE2ECSRPEM(t, "", []string{"no-cn.example.com"})
|
||||
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrPEM))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Should fail because EST requires a Common Name
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Error("expected error for CSR without Common Name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SimpleEnroll_MethodNotAllowed", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/.well-known/est/simpleenroll")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
// ===========================
|
||||
// POST /simplereenroll — certificate re-enrollment
|
||||
// ===========================
|
||||
t.Run("SimpleReEnroll_Success", func(t *testing.T) {
|
||||
csrPEM := generateE2ECSRPEM(t, "renew-est.example.com", []string{"renew-est.example.com"})
|
||||
resp, err := http.Post(server.URL+"/.well-known/est/simplereenroll", "application/pkcs10", strings.NewReader(csrPEM))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SimpleReEnroll_MethodNotAllowed", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/.well-known/est/simplereenroll")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
// ===========================
|
||||
// GET /csrattrs — CSR attributes
|
||||
// ===========================
|
||||
t.Run("GetCSRAttrs_NoContent", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/.well-known/est/csrattrs")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Default implementation returns nil attrs → 204 No Content
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCSRAttrs_MethodNotAllowed", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/.well-known/est/csrattrs", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
||||
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
||||
|
||||
// EST handler — uses real Local CA issuer via ESTService
|
||||
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
||||
estHandler := handler.NewESTHandler(estService)
|
||||
|
||||
// Create router and register handlers
|
||||
r := router.New()
|
||||
r.RegisterHandlers(
|
||||
@@ -103,6 +107,7 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
discoveryHandler,
|
||||
networkScanHandler,
|
||||
)
|
||||
r.RegisterESTHandlers(estHandler)
|
||||
|
||||
// Create test server
|
||||
server := httptest.NewServer(r)
|
||||
|
||||
@@ -75,6 +75,10 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
||||
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
||||
|
||||
// EST handler — uses real Local CA issuer via ESTService
|
||||
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
||||
estHandler := handler.NewESTHandler(estService)
|
||||
|
||||
r := router.New()
|
||||
r.RegisterHandlers(
|
||||
certificateHandler,
|
||||
@@ -95,6 +99,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
discoveryHandler,
|
||||
networkScanHandler,
|
||||
)
|
||||
r.RegisterESTHandlers(estHandler)
|
||||
|
||||
server := httptest.NewServer(r)
|
||||
t.Cleanup(func() { server.Close() })
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// ESTService implements the EST (RFC 7030) enrollment protocol.
|
||||
// It delegates certificate operations to an existing IssuerConnector and records
|
||||
// enrollment events in the audit trail.
|
||||
type ESTService struct {
|
||||
issuer IssuerConnector
|
||||
issuerID string
|
||||
auditService *AuditService
|
||||
logger *slog.Logger
|
||||
profileID string // optional: constrain enrollments to a specific profile
|
||||
}
|
||||
|
||||
// NewESTService creates a new ESTService for the given issuer connector.
|
||||
func NewESTService(issuerID string, issuer IssuerConnector, auditService *AuditService, logger *slog.Logger) *ESTService {
|
||||
return &ESTService{
|
||||
issuer: issuer,
|
||||
issuerID: issuerID,
|
||||
auditService: auditService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetProfileID constrains EST enrollments to a specific certificate profile.
|
||||
func (s *ESTService) SetProfileID(profileID string) {
|
||||
s.profileID = profileID
|
||||
}
|
||||
|
||||
// GetCACerts returns the PEM-encoded CA certificate chain for this EST server.
|
||||
// RFC 7030 Section 4.1: /cacerts distributes the current CA certificates.
|
||||
func (s *ESTService) GetCACerts(ctx context.Context) (string, error) {
|
||||
caPEM, err := s.issuer.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get CA certificates from issuer %s: %w", s.issuerID, err)
|
||||
}
|
||||
if caPEM == "" {
|
||||
return "", fmt.Errorf("issuer %s does not provide CA certificates for EST", s.issuerID)
|
||||
}
|
||||
return caPEM, nil
|
||||
}
|
||||
|
||||
// SimpleEnroll processes an initial enrollment request.
|
||||
// RFC 7030 Section 4.2: /simpleenroll accepts a PKCS#10 CSR and returns a signed cert.
|
||||
func (s *ESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||
return s.processEnrollment(ctx, csrPEM, "est_simple_enroll")
|
||||
}
|
||||
|
||||
// SimpleReEnroll processes a re-enrollment request.
|
||||
// RFC 7030 Section 4.2.2: /simplereenroll is functionally identical to /simpleenroll
|
||||
// but is used when renewing an existing certificate.
|
||||
func (s *ESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
||||
return s.processEnrollment(ctx, csrPEM, "est_simple_reenroll")
|
||||
}
|
||||
|
||||
// GetCSRAttrs returns the CSR attributes the server wants clients to include.
|
||||
// RFC 7030 Section 4.5: /csrattrs tells clients what to put in their CSR.
|
||||
// Returns nil if no specific attributes are required.
|
||||
func (s *ESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
||||
// For now, we don't require specific CSR attributes.
|
||||
// In the future, this could return key type constraints from the profile.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// processEnrollment handles the common enrollment logic for both simpleenroll and simplereenroll.
|
||||
func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, auditAction string) (*domain.ESTEnrollResult, error) {
|
||||
// Parse the CSR to extract CN and SANs
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("invalid CSR PEM")
|
||||
}
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse CSR: %w", err)
|
||||
}
|
||||
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
commonName := csr.Subject.CommonName
|
||||
if commonName == "" {
|
||||
return nil, fmt.Errorf("CSR must include a Common Name")
|
||||
}
|
||||
|
||||
// Collect SANs
|
||||
var sans []string
|
||||
for _, dns := range csr.DNSNames {
|
||||
sans = append(sans, dns)
|
||||
}
|
||||
for _, ip := range csr.IPAddresses {
|
||||
sans = append(sans, ip.String())
|
||||
}
|
||||
for _, email := range csr.EmailAddresses {
|
||||
sans = append(sans, email)
|
||||
}
|
||||
for _, uri := range csr.URIs {
|
||||
sans = append(sans, uri.String())
|
||||
}
|
||||
|
||||
s.logger.Info("EST enrollment request",
|
||||
"action", auditAction,
|
||||
"common_name", commonName,
|
||||
"sans", strings.Join(sans, ","),
|
||||
"issuer", s.issuerID)
|
||||
|
||||
// Issue the certificate via the configured issuer connector
|
||||
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM)
|
||||
if err != nil {
|
||||
s.logger.Error("EST enrollment failed",
|
||||
"action", auditAction,
|
||||
"common_name", commonName,
|
||||
"error", err)
|
||||
return nil, fmt.Errorf("certificate issuance failed: %w", err)
|
||||
}
|
||||
|
||||
// Audit the enrollment
|
||||
if s.auditService != nil {
|
||||
details := map[string]interface{}{
|
||||
"common_name": commonName,
|
||||
"sans": sans,
|
||||
"issuer_id": s.issuerID,
|
||||
"serial": result.Serial,
|
||||
"protocol": "EST",
|
||||
}
|
||||
if s.profileID != "" {
|
||||
details["profile_id"] = s.profileID
|
||||
}
|
||||
_ = s.auditService.RecordEvent(ctx, "est-client", "system", auditAction, "certificate", result.Serial, details)
|
||||
}
|
||||
|
||||
s.logger.Info("EST enrollment successful",
|
||||
"action", auditAction,
|
||||
"common_name", commonName,
|
||||
"serial", result.Serial,
|
||||
"not_after", result.NotAfter)
|
||||
|
||||
return &domain.ESTEnrollResult{
|
||||
CertPEM: result.CertPEM,
|
||||
ChainPEM: result.ChainPEM,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// generateCSRPEM creates a valid ECDSA P-256 CSR for testing.
|
||||
func generateCSRPEM(t *testing.T, cn string, sans []string) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
DNSNames: sans,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create CSR: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
||||
}
|
||||
|
||||
func TestESTService_GetCACerts_Success(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{}
|
||||
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
|
||||
caPEM, err := svc.GetCACerts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if caPEM == "" {
|
||||
t.Error("expected non-empty CA PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_GetCACerts_IssuerError(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{Err: errors.New("CA unavailable")}
|
||||
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
|
||||
_, err := svc.GetCACerts(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CA unavailable") {
|
||||
t.Errorf("expected error to contain 'CA unavailable', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_SimpleEnroll_Success(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
svc := NewESTService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
|
||||
csrPEM := generateCSRPEM(t, "test.example.com", []string{"test.example.com"})
|
||||
|
||||
result, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if result.CertPEM == "" {
|
||||
t.Error("expected non-empty CertPEM")
|
||||
}
|
||||
|
||||
// Verify audit event was recorded
|
||||
if len(auditRepo.Events) == 0 {
|
||||
t.Error("expected audit event to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_SimpleEnroll_InvalidCSR(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{}
|
||||
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
|
||||
_, err := svc.SimpleEnroll(context.Background(), "not-valid-pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid CSR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_SimpleEnroll_MissingCN(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{}
|
||||
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
|
||||
csrPEM := generateCSRPEM(t, "", []string{"test.example.com"})
|
||||
|
||||
_, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Common Name") {
|
||||
t.Errorf("expected 'Common Name' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_SimpleEnroll_IssuerError(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{Err: errors.New("issuance failed")}
|
||||
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
|
||||
csrPEM := generateCSRPEM(t, "test.example.com", nil)
|
||||
|
||||
_, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "issuance failed") {
|
||||
t.Errorf("expected 'issuance failed', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_SimpleReEnroll_Success(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{}
|
||||
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
|
||||
csrPEM := generateCSRPEM(t, "renew.example.com", []string{"renew.example.com"})
|
||||
|
||||
result, err := svc.SimpleReEnroll(context.Background(), csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_GetCSRAttrs_Empty(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{}
|
||||
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
|
||||
attrs, err := svc.GetCSRAttrs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if attrs != nil {
|
||||
t.Errorf("expected nil attrs, got %v", attrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_SimpleEnroll_WithProfile(t *testing.T) {
|
||||
mockIssuer := &mockIssuerConnector{}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
svc := NewESTService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
svc.SetProfileID("profile-wifi-client")
|
||||
|
||||
csrPEM := generateCSRPEM(t, "device.example.com", nil)
|
||||
|
||||
result, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
|
||||
// Verify audit event includes profile_id
|
||||
if len(auditRepo.Events) == 0 {
|
||||
t.Fatal("expected audit event")
|
||||
}
|
||||
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
|
||||
if lastEvent.Details == nil {
|
||||
t.Fatal("expected audit details")
|
||||
}
|
||||
}
|
||||
@@ -95,3 +95,8 @@ func (a *IssuerConnectorAdapter) SignOCSPResponse(ctx context.Context, req OCSPS
|
||||
NextUpdate: req.NextUpdate,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCACertPEM delegates to the underlying connector.
|
||||
func (a *IssuerConnectorAdapter) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return a.connector.GetCACertPEM(ctx)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ type IssuerConnector interface {
|
||||
GenerateCRL(ctx context.Context, revokedCerts []CRLEntry) ([]byte, error)
|
||||
// SignOCSPResponse signs an OCSP response for the given certificate serial.
|
||||
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
||||
GetCACertPEM(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// IssuanceResult holds the result of a certificate issuance or renewal operation.
|
||||
|
||||
@@ -634,6 +634,13 @@ func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSign
|
||||
return []byte("mock-ocsp-response"), nil
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
if m.Err != nil {
|
||||
return "", m.Err
|
||||
}
|
||||
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
|
||||
}
|
||||
|
||||
// Constructor functions for mocks
|
||||
|
||||
func newMockCertificateRepository() *mockCertRepo {
|
||||
|
||||
Reference in New Issue
Block a user