mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:41:29 +00:00
feat(m27): certificate export (PEM/PKCS#12) and S/MIME EKU support
Add certificate export in PEM (JSON or file download) and PKCS#12 formats. Private keys are never included — they stay on agents. Add EKU-aware issuance threading profile EKUs (serverAuth, clientAuth, codeSigning, emailProtection, timeStamping) through the full issuance pipeline. Fix agent CSR SAN splitting for email addresses, adaptive KeyUsage flags for S/MIME vs TLS, and a pre-existing generateID collision bug in deployment job creation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// ExportService defines the service interface for certificate export operations.
|
||||
type ExportService interface {
|
||||
ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error)
|
||||
ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error)
|
||||
}
|
||||
|
||||
// ExportHandler handles HTTP requests for certificate export operations.
|
||||
type ExportHandler struct {
|
||||
svc ExportService
|
||||
}
|
||||
|
||||
// NewExportHandler creates a new ExportHandler with a service dependency.
|
||||
func NewExportHandler(svc ExportService) ExportHandler {
|
||||
return ExportHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ExportPEM exports a certificate and its chain in PEM format.
|
||||
// GET /api/v1/certificates/{id}/export/pem
|
||||
func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pem
|
||||
id := extractCertIDFromExportPath(r.URL.Path)
|
||||
if id == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ExportPEM(r.Context(), id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if client wants file download via Accept header or ?download=true query param
|
||||
if r.URL.Query().Get("download") == "true" {
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.pem\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(result.FullPEM))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ExportPKCS12 exports a certificate and chain in PKCS#12 format.
|
||||
// POST /api/v1/certificates/{id}/export/pkcs12
|
||||
// Body: { "password": "optional-password" }
|
||||
func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pkcs12
|
||||
id := extractCertIDFromExportPath(r.URL.Path)
|
||||
if id == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional password from request body (may be empty)
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
// Body is optional — empty body means empty password
|
||||
_ = parseJSONBody(r, &req)
|
||||
|
||||
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-pkcs12")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.p12\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(pfxData)
|
||||
}
|
||||
|
||||
// extractCertIDFromExportPath extracts the certificate ID from an export path.
|
||||
// Path format: /api/v1/certificates/{id}/export/pem or /api/v1/certificates/{id}/export/pkcs12
|
||||
func extractCertIDFromExportPath(path string) string {
|
||||
prefix := "/api/v1/certificates/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
// rest should be "{id}/export/pem" or "{id}/export/pkcs12"
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) < 3 || parts[1] != "export" {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
// parseJSONBody is a helper that decodes JSON from the request body.
|
||||
// Returns an error if the body is malformed, nil if body is empty.
|
||||
func parseJSONBody(r *http.Request, v interface{}) error {
|
||||
if r.Body == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// MockExportService is a mock implementation of ExportService interface.
|
||||
type MockExportService struct {
|
||||
ExportPEMFn func(ctx context.Context, certID string) (*service.ExportPEMResult, error)
|
||||
ExportPKCS12Fn func(ctx context.Context, certID string, password string) ([]byte, error)
|
||||
}
|
||||
|
||||
func (m *MockExportService) ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error) {
|
||||
if m.ExportPEMFn != nil {
|
||||
return m.ExportPEMFn(ctx, certID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockExportService) ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error) {
|
||||
if m.ExportPKCS12Fn != nil {
|
||||
return m.ExportPKCS12Fn(ctx, certID, password)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestExportPEM_Success(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, certID string) (*service.ExportPEMResult, error) {
|
||||
if certID != "mc-test-1" {
|
||||
t.Errorf("expected certID mc-test-1, got %s", certID)
|
||||
}
|
||||
return &service.ExportPEMResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n",
|
||||
FullPEM: "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected application/json content type, got %s", ct)
|
||||
}
|
||||
|
||||
var result service.ExportPEMResult
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if result.CertPEM == "" {
|
||||
t.Error("expected non-empty CertPEM")
|
||||
}
|
||||
if result.ChainPEM == "" {
|
||||
t.Error("expected non-empty ChainPEM")
|
||||
}
|
||||
if result.FullPEM == "" {
|
||||
t.Error("expected non-empty FullPEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_Download(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||
return &service.ExportPEMResult{
|
||||
CertPEM: "cert",
|
||||
ChainPEM: "chain",
|
||||
FullPEM: "full-pem-content",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem?download=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/x-pem-file" {
|
||||
t.Errorf("expected application/x-pem-file, got %s", ct)
|
||||
}
|
||||
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="certificate.pem"` {
|
||||
t.Errorf("expected Content-Disposition attachment, got %s", cd)
|
||||
}
|
||||
if w.Body.String() != "full-pem-content" {
|
||||
t.Errorf("expected full-pem-content body, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_NotFound(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||
return nil, fmt.Errorf("certificate not found")
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/nonexistent/export/pem", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_ServiceError(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||
return nil, fmt.Errorf("internal error")
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_MethodNotAllowed(t *testing.T) {
|
||||
h := NewExportHandler(&MockExportService{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_Success(t *testing.T) {
|
||||
pfxData := []byte{0x30, 0x82, 0x01, 0x00} // mock PKCS#12 data
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, certID string, password string) ([]byte, error) {
|
||||
if certID != "mc-test-1" {
|
||||
t.Errorf("expected certID mc-test-1, got %s", certID)
|
||||
}
|
||||
if password != "mysecret" {
|
||||
t.Errorf("expected password mysecret, got %s", password)
|
||||
}
|
||||
return pfxData, nil
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
body := strings.NewReader(`{"password":"mysecret"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", body)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/x-pkcs12" {
|
||||
t.Errorf("expected application/x-pkcs12, got %s", ct)
|
||||
}
|
||||
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="certificate.p12"` {
|
||||
t.Errorf("expected Content-Disposition attachment, got %s", cd)
|
||||
}
|
||||
if len(w.Body.Bytes()) != len(pfxData) {
|
||||
t.Errorf("expected %d bytes, got %d", len(pfxData), len(w.Body.Bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_EmptyPassword(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, password string) ([]byte, error) {
|
||||
if password != "" {
|
||||
t.Errorf("expected empty password, got %s", password)
|
||||
}
|
||||
return []byte{0x30}, nil
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
// Empty body — password defaults to ""
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_NotFound(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("certificate not found")
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/nonexistent/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_ServiceError(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("encoding error")
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_MethodNotAllowed(t *testing.T) {
|
||||
h := NewExportHandler(&MockExportService{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCertIDFromExportPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{"/api/v1/certificates/mc-test-1/export/pem", "mc-test-1"},
|
||||
{"/api/v1/certificates/mc-api-prod/export/pkcs12", "mc-api-prod"},
|
||||
{"/api/v1/certificates//export/pem", ""},
|
||||
{"/api/v1/other/mc-test-1/export/pem", ""},
|
||||
{"/api/v1/certificates/mc-test-1", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := extractCertIDFromExportPath(tt.path)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractCertIDFromExportPath(%q) = %q, want %q", tt.path, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user