diff --git a/backend/go.mod b/backend/go.mod index 5c7ff0ef..34962868 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -99,4 +99,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.48.2 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 6aba58d7..f385ec49 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -269,3 +269,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8= +software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go index 658bd6a9..e5a1f1dd 100644 --- a/backend/internal/api/handlers/certificate_handler.go +++ b/backend/internal/api/handlers/certificate_handler.go @@ -2,14 +2,18 @@ package handlers import ( "fmt" + "io" "net/http" "strconv" + "strings" "sync" "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" ) @@ -28,9 +32,10 @@ type CertificateHandler struct { service *services.CertificateService backupService BackupServiceInterface notificationService *services.NotificationService + db *gorm.DB // Rate limiting for notifications notificationMu sync.Mutex - lastNotificationTime map[uint]time.Time + lastNotificationTime map[string]time.Time } func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler { @@ -38,10 +43,18 @@ func NewCertificateHandler(service *services.CertificateService, backupService B service: service, backupService: backupService, notificationService: ns, - lastNotificationTime: make(map[uint]time.Time), + lastNotificationTime: make(map[string]time.Time), } } +// SetDB sets the database connection for user lookups (export re-auth). +func (h *CertificateHandler) SetDB(db *gorm.DB) { + h.db = db +} + +// maxFileSize is 1MB for certificate file uploads. +const maxFileSize = 1 << 20 + func (h *CertificateHandler) List(c *gin.Context) { certs, err := h.service.ListCertificates() if err != nil { @@ -53,34 +66,41 @@ func (h *CertificateHandler) List(c *gin.Context) { c.JSON(http.StatusOK, certs) } -type UploadCertificateRequest struct { - Name string `form:"name" binding:"required"` - Certificate string `form:"certificate"` // PEM content - PrivateKey string `form:"private_key"` // PEM content +func (h *CertificateHandler) Get(c *gin.Context) { + certUUID := c.Param("uuid") + if certUUID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"}) + return + } + + detail, err := h.service.GetCertificate(certUUID) + if err != nil { + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).Error("failed to get certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get certificate"}) + return + } + + c.JSON(http.StatusOK, detail) } func (h *CertificateHandler) Upload(c *gin.Context) { - // Handle multipart form name := c.PostForm("name") if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } - // Read files + // Read certificate file certFile, err := c.FormFile("certificate_file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"}) return } - keyFile, err := c.FormFile("key_file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"}) - return - } - - // Open and read content certSrc, err := certFile.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"}) @@ -92,35 +112,70 @@ func (h *CertificateHandler) Upload(c *gin.Context) { } }() - keySrc, err := keyFile.Open() + certBytes, err := io.ReadAll(io.LimitReader(certSrc, maxFileSize)) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read certificate file"}) return } - defer func() { - if errClose := keySrc.Close(); errClose != nil { - logger.Log().WithError(errClose).Warn("failed to close key file") + certPEM := string(certBytes) + + // Read private key file (optional for PFX) + var keyPEM string + keyFile, err := c.FormFile("key_file") + if err == nil { + keySrc, errOpen := keyFile.Open() + if errOpen != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"}) + return } - }() + defer func() { + if errClose := keySrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close key file") + } + }() - // Read to string - // Limit size to avoid DoS (e.g. 1MB) - certBytes := make([]byte, 1024*1024) - n, _ := certSrc.Read(certBytes) - certPEM := string(certBytes[:n]) + keyBytes, errRead := io.ReadAll(io.LimitReader(keySrc, maxFileSize)) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read key file"}) + return + } + keyPEM = string(keyBytes) + } else if !strings.HasSuffix(strings.ToLower(certFile.Filename), ".pfx") && + !strings.HasSuffix(strings.ToLower(certFile.Filename), ".p12") { + c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required for PEM certificates"}) + return + } - keyBytes := make([]byte, 1024*1024) - n, _ = keySrc.Read(keyBytes) - keyPEM := string(keyBytes[:n]) + // Read chain file (optional) + var chainPEM string + chainFile, err := c.FormFile("chain_file") + if err == nil { + chainSrc, errOpen := chainFile.Open() + if errOpen != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open chain file"}) + return + } + defer func() { + if errClose := chainSrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close chain file") + } + }() - cert, err := h.service.UploadCertificate(name, certPEM, keyPEM) + chainBytes, errRead := io.ReadAll(io.LimitReader(chainSrc, maxFileSize)) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read chain file"}) + return + } + chainPEM = string(chainBytes) + } + + cert, err := h.service.UploadCertificate(name, certPEM, keyPEM, chainPEM) if err != nil { logger.Log().WithError(err).Error("failed to upload certificate") - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload certificate"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Send Notification if h.notificationService != nil { h.notificationService.SendExternal(c.Request.Context(), "cert", @@ -137,24 +192,250 @@ func (h *CertificateHandler) Upload(c *gin.Context) { c.JSON(http.StatusCreated, cert) } +type updateCertificateRequest struct { + Name string `json:"name" binding:"required"` +} + +func (h *CertificateHandler) Update(c *gin.Context) { + certUUID := c.Param("uuid") + if certUUID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"}) + return + } + + var req updateCertificateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + info, err := h.service.UpdateCertificate(certUUID, req.Name) + if err != nil { + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).Error("failed to update certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update certificate"}) + return + } + + c.JSON(http.StatusOK, info) +} + +func (h *CertificateHandler) Validate(c *gin.Context) { + // Read certificate file + certFile, err := c.FormFile("certificate_file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"}) + return + } + + certSrc, err := certFile.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"}) + return + } + defer func() { + if errClose := certSrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close certificate file") + } + }() + + certBytes, err := io.ReadAll(io.LimitReader(certSrc, maxFileSize)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read certificate file"}) + return + } + + // Read optional key file + var keyPEM string + keyFile, err := c.FormFile("key_file") + if err == nil { + keySrc, errOpen := keyFile.Open() + if errOpen != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"}) + return + } + defer func() { + if errClose := keySrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close key file") + } + }() + + keyBytes, errRead := io.ReadAll(io.LimitReader(keySrc, maxFileSize)) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read key file"}) + return + } + keyPEM = string(keyBytes) + } + + // Read optional chain file + var chainPEM string + chainFile, err := c.FormFile("chain_file") + if err == nil { + chainSrc, errOpen := chainFile.Open() + if errOpen != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open chain file"}) + return + } + defer func() { + if errClose := chainSrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close chain file") + } + }() + + chainBytes, errRead := io.ReadAll(io.LimitReader(chainSrc, maxFileSize)) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read chain file"}) + return + } + chainPEM = string(chainBytes) + } + + result, err := h.service.ValidateCertificate(string(certBytes), keyPEM, chainPEM) + if err != nil { + logger.Log().WithError(err).Error("failed to validate certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "validation failed"}) + return + } + + c.JSON(http.StatusOK, result) +} + +type exportCertificateRequest struct { + Format string `json:"format" binding:"required"` + IncludeKey bool `json:"include_key"` + PFXPassword string `json:"pfx_password"` + Password string `json:"password"` +} + +func (h *CertificateHandler) Export(c *gin.Context) { + certUUID := c.Param("uuid") + if certUUID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"}) + return + } + + var req exportCertificateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "format is required"}) + return + } + + // Re-authenticate when requesting private key + if req.IncludeKey { + if req.Password == "" { + c.JSON(http.StatusForbidden, gin.H{"error": "password required to export private key"}) + return + } + + userVal, exists := c.Get("user") + if !exists || h.db == nil { + c.JSON(http.StatusForbidden, gin.H{"error": "authentication required"}) + return + } + + userMap, ok := userVal.(map[string]any) + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "invalid session"}) + return + } + + userID, ok := userMap["id"] + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "invalid session"}) + return + } + + var user models.User + if err := h.db.First(&user, userID).Error; err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "user not found"}) + return + } + + if !user.CheckPassword(req.Password) { + c.JSON(http.StatusForbidden, gin.H{"error": "incorrect password"}) + return + } + } + + data, filename, err := h.service.ExportCertificate(certUUID, req.Format, req.IncludeKey) + if err != nil { + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).Error("failed to export certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export certificate"}) + return + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + c.Data(http.StatusOK, "application/octet-stream", data) +} + func (h *CertificateHandler) Delete(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { + idStr := c.Param("uuid") + + // Support both numeric ID (legacy) and UUID + if numID, err := strconv.ParseUint(idStr, 10, 32); err == nil && numID > 0 { + inUse, err := h.service.IsCertificateInUse(uint(numID)) + if err != nil { + logger.Log().WithError(err).WithField("certificate_id", numID).Error("failed to check certificate usage") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"}) + return + } + if inUse { + c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"}) + return + } + + if h.backupService != nil { + if availableSpace, err := h.backupService.GetAvailableSpace(); err != nil { + logger.Log().WithError(err).Warn("unable to check disk space, proceeding with backup") + } else if availableSpace < 100*1024*1024 { + logger.Log().WithField("available_bytes", availableSpace).Warn("low disk space, skipping backup") + c.JSON(http.StatusInsufficientStorage, gin.H{"error": "insufficient disk space for backup"}) + return + } + + if _, err := h.backupService.CreateBackup(); err != nil { + logger.Log().WithError(err).Error("failed to create backup before deletion") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup before deletion"}) + return + } + } + + if err := h.service.DeleteCertificateByID(uint(numID)); err != nil { + if err == services.ErrCertInUse { + c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"}) + return + } + logger.Log().WithError(err).WithField("certificate_id", numID).Error("failed to delete certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"}) + return + } + + h.sendDeleteNotification(c, fmt.Sprintf("%d", numID)) + c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"}) + return + } + + // UUID path - value isn't numeric, validate it looks like a UUID + if idStr == "" || idStr == "0" || len(idStr) < 32 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } - // Validate ID range - if id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) - return - } - - // Check if certificate is in use before proceeding - inUse, err := h.service.IsCertificateInUse(uint(id)) + inUse, err := h.service.IsCertificateInUseByUUID(idStr) if err != nil { - logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to check certificate usage") + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).WithField("certificate_uuid", idStr).Error("failed to check certificate usage") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"}) return } @@ -163,13 +444,10 @@ func (h *CertificateHandler) Delete(c *gin.Context) { return } - // Create backup before deletion if h.backupService != nil { - // Check disk space before backup (require at least 100MB free) if availableSpace, err := h.backupService.GetAvailableSpace(); err != nil { logger.Log().WithError(err).Warn("unable to check disk space, proceeding with backup") } else if availableSpace < 100*1024*1024 { - logger.Log().WithField("available_bytes", availableSpace).Warn("low disk space, skipping backup") c.JSON(http.StatusInsufficientStorage, gin.H{"error": "insufficient disk space for backup"}) return } @@ -181,38 +459,46 @@ func (h *CertificateHandler) Delete(c *gin.Context) { } } - // Proceed with deletion - if err := h.service.DeleteCertificate(uint(id)); err != nil { + if err := h.service.DeleteCertificate(idStr); err != nil { if err == services.ErrCertInUse { c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"}) return } - logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to delete certificate") + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).WithField("certificate_uuid", idStr).Error("failed to delete certificate") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"}) return } - // Send Notification with rate limiting (1 per cert per 10 seconds) - if h.notificationService != nil { - h.notificationMu.Lock() - lastTime, exists := h.lastNotificationTime[uint(id)] - if !exists || time.Since(lastTime) > 10*time.Second { - h.lastNotificationTime[uint(id)] = time.Now() - h.notificationMu.Unlock() - h.notificationService.SendExternal(c.Request.Context(), - "cert", - "Certificate Deleted", - fmt.Sprintf("Certificate ID %d deleted", id), - map[string]any{ - "ID": id, - "Action": "deleted", - }, - ) - } else { - h.notificationMu.Unlock() - logger.Log().WithField("certificate_id", id).Debug("notification rate limited") - } - } - + h.sendDeleteNotification(c, idStr) c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"}) } + +func (h *CertificateHandler) sendDeleteNotification(c *gin.Context, certRef string) { + if h.notificationService == nil { + return + } + + h.notificationMu.Lock() + lastTime, exists := h.lastNotificationTime[certRef] + if exists && time.Since(lastTime) < 10*time.Second { + h.notificationMu.Unlock() + logger.Log().WithField("certificate_ref", certRef).Debug("notification rate limited") + return + } + h.lastNotificationTime[certRef] = time.Now() + h.notificationMu.Unlock() + + h.notificationService.SendExternal(c.Request.Context(), + "cert", + "Certificate Deleted", + fmt.Sprintf("Certificate %s deleted", certRef), + map[string]any{ + "Ref": certRef, + "Action": "deleted", + }, + ) +} diff --git a/backend/internal/api/handlers/certificate_handler_coverage_test.go b/backend/internal/api/handlers/certificate_handler_coverage_test.go index acf70e3d..146fd158 100644 --- a/backend/internal/api/handlers/certificate_handler_coverage_test.go +++ b/backend/internal/api/handlers/certificate_handler_coverage_test.go @@ -18,7 +18,7 @@ func TestCertificateHandler_List_DBError(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -34,7 +34,7 @@ func TestCertificateHandler_Delete_InvalidID(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) @@ -50,7 +50,7 @@ func TestCertificateHandler_Delete_NotFound(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) @@ -70,7 +70,7 @@ func TestCertificateHandler_Delete_NoBackupService(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // No backup service h := NewCertificateHandler(svc, nil, nil) @@ -95,7 +95,7 @@ func TestCertificateHandler_Delete_CheckUsageDBError(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) @@ -115,7 +115,7 @@ func TestCertificateHandler_List_WithCertificates(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -135,7 +135,7 @@ func TestCertificateHandler_Delete_ZeroID(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) @@ -169,7 +169,7 @@ func TestCertificateHandler_DBSetupOrdering(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) diff --git a/backend/internal/api/handlers/certificate_handler_security_test.go b/backend/internal/api/handlers/certificate_handler_security_test.go index a118fa7f..08a062ad 100644 --- a/backend/internal/api/handlers/certificate_handler_security_test.go +++ b/backend/internal/api/handlers/certificate_handler_security_test.go @@ -30,7 +30,7 @@ func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) { r.Use(func(c *gin.Context) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) }) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) @@ -59,7 +59,7 @@ func TestCertificateHandler_List_RequiresAuth(t *testing.T) { r.Use(func(c *gin.Context) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) }) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -88,7 +88,7 @@ func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) { r.Use(func(c *gin.Context) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) }) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -125,7 +125,7 @@ func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock backup service that reports low disk space mockBackup := &mockBackupService{ @@ -177,7 +177,7 @@ func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) mockBackup := &mockBackupService{ createFunc: func() (string, error) { diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index 7971bcbc..f8b23797 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -39,7 +39,7 @@ func setupCertTestRouter(t *testing.T, db *gorm.DB) *gin.Engine { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) return r @@ -111,7 +111,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService backupCalled := false @@ -164,7 +164,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService that fails mockBackupService := &mockBackupService{ @@ -217,7 +217,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService backupCalled := false @@ -295,7 +295,7 @@ func TestCertificateHandler_List(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -321,7 +321,7 @@ func TestCertificateHandler_Upload_MissingName(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -348,7 +348,7 @@ func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -378,7 +378,7 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -404,7 +404,7 @@ func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -447,7 +447,7 @@ func TestCertificateHandler_Upload_Success(t *testing.T) { // Create a mock CertificateService that returns a created certificate // Create a temporary services.CertificateService with a temp dir and DB tmpDir := t.TempDir() - svc := services.NewCertificateService(tmpDir, db) + svc := services.NewCertificateService(tmpDir, db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -519,7 +519,7 @@ func TestCertificateHandler_Upload_WithNotificationService(t *testing.T) { r.Use(mockAuthMiddleware()) tmpDir := t.TempDir() - svc := services.NewCertificateService(tmpDir, db) + svc := services.NewCertificateService(tmpDir, db, nil) ns := services.NewNotificationService(db, nil) h := NewCertificateHandler(svc, nil, ns) r.POST("/api/certificates", h.Upload) @@ -555,7 +555,7 @@ func TestDeleteCertificate_InvalidID(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) @@ -580,7 +580,7 @@ func TestDeleteCertificate_ZeroID(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) @@ -611,7 +611,7 @@ func TestDeleteCertificate_LowDiskSpace(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService with low disk space mockBackupService := &mockBackupService{ @@ -659,7 +659,7 @@ func TestDeleteCertificate_DiskSpaceCheckError(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService with space check error but backup succeeds mockBackupService := &mockBackupService{ @@ -717,7 +717,7 @@ func TestDeleteCertificate_ExpiredLetsEncrypt_NotInUse(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) mockBS := &mockBackupService{ createFunc: func() (string, error) { @@ -775,7 +775,7 @@ func TestDeleteCertificate_ValidLetsEncrypt_NotInUse(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) mockBS := &mockBackupService{ createFunc: func() (string, error) { @@ -820,7 +820,7 @@ func TestDeleteCertificate_UsageCheckError(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.DELETE("/api/certificates/:id", h.Delete) @@ -857,7 +857,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) ns := services.NewNotificationService(db, nil) mockBackupService := &mockBackupService{ diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index dc6d0925..0cb00e98 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -152,6 +152,14 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) caddyManager = caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) } + + // Wire encryption service to Caddy manager for decrypting certificate private keys + if cfg.EncryptionKey != "" { + if svc, err := crypto.NewEncryptionService(cfg.EncryptionKey); err == nil { + caddyManager.SetEncryptionService(svc) + } + } + if cerb == nil { cerb = cerberus.New(cfg.Security, db) } @@ -666,11 +674,38 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM // where ACME and certificates are stored (e.g. /data). caddyDataDir := cfg.CaddyConfigDir + "/data" logger.Log().WithField("caddy_data_dir", caddyDataDir).Info("Using Caddy data directory for certificates scan") - certService := services.NewCertificateService(caddyDataDir, db) + var certEncSvc *crypto.EncryptionService + if cfg.EncryptionKey != "" { + svc, err := crypto.NewEncryptionService(cfg.EncryptionKey) + if err != nil { + logger.Log().WithError(err).Warn("Failed to initialize encryption service for certificate key storage") + } else { + certEncSvc = svc + } + } + certService := services.NewCertificateService(caddyDataDir, db, certEncSvc) certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService) + certHandler.SetDB(db) + + // Migrate unencrypted private keys + if err := certService.MigratePrivateKeys(); err != nil { + logger.Log().WithError(err).Warn("Failed to migrate certificate private keys") + } + management.GET("/certificates", certHandler.List) management.POST("/certificates", certHandler.Upload) - management.DELETE("/certificates/:id", certHandler.Delete) + management.POST("/certificates/validate", certHandler.Validate) + management.GET("/certificates/:uuid", certHandler.Get) + management.PUT("/certificates/:uuid", certHandler.Update) + management.POST("/certificates/:uuid/export", certHandler.Export) + management.DELETE("/certificates/:uuid", certHandler.Delete) + + // Start certificate expiry checker + warningDays := 30 + if cfg.CertExpiryWarningDays > 0 { + warningDays = cfg.CertExpiryWarningDays + } + go certService.StartExpiryChecker(context.Background(), notificationService, warningDays) // Proxy Hosts & Remote Servers proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index 9f8f8dfc..28a3ed16 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -773,7 +773,7 @@ func TestRegister_CertificateRoutes(t *testing.T) { // Certificate routes assert.True(t, routeMap["/api/v1/certificates"]) - assert.True(t, routeMap["/api/v1/certificates/:id"]) + assert.True(t, routeMap["/api/v1/certificates/:uuid"]) } // TestRegister_NilHandlers verifies registration behavior with minimal/nil components diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 63a8b893..75863f70 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/pkg/dnsprovider" @@ -15,7 +16,7 @@ import ( // GenerateConfig creates a Caddy JSON configuration from proxy hosts. // This is the core transformation layer from our database model to Caddy config. -func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { +func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { // Define log file paths for Caddy access logs. // When CrowdSec is enabled, we use /var/log/caddy/access.log which is the standard // location that CrowdSec's acquis.yaml is configured to monitor. @@ -427,16 +428,47 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir } if len(customCerts) > 0 { + // Resolve encryption service from variadic parameter + var certEncSvc *crypto.EncryptionService + if len(encSvc) > 0 && encSvc[0] != nil { + certEncSvc = encSvc[0] + } + var loadPEM []LoadPEMConfig for _, cert := range customCerts { - // Validate that custom cert has both certificate and key - if cert.Certificate == "" || cert.PrivateKey == "" { - logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate or key, skipping") + // Determine private key: prefer encrypted, fall back to plaintext for migration + var keyPEM string + if cert.PrivateKeyEncrypted != "" && certEncSvc != nil { + decrypted, err := certEncSvc.Decrypt(cert.PrivateKeyEncrypted) + if err != nil { + logger.Log().WithField("cert", cert.Name).WithError(err).Warn("Failed to decrypt private key, skipping certificate") + continue + } + keyPEM = string(decrypted) + } else if cert.PrivateKeyEncrypted != "" { + logger.Log().WithField("cert", cert.Name).Warn("Certificate has encrypted key but no encryption service available, skipping") + continue + } else if cert.PrivateKey != "" { + keyPEM = cert.PrivateKey + } else { + logger.Log().WithField("cert", cert.Name).Warn("Custom certificate has no encrypted key, skipping") continue } + + if cert.Certificate == "" { + logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate PEM, skipping") + continue + } + + // Concatenate chain with leaf certificate + fullCert := cert.Certificate + if cert.CertificateChain != "" { + fullCert = fullCert + "\n" + cert.CertificateChain + } + loadPEM = append(loadPEM, LoadPEMConfig{ - Certificate: cert.Certificate, - Key: cert.PrivateKey, + Certificate: fullCert, + Key: keyPEM, Tags: []string{cert.UUID}, }) } diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index c2cfab9d..4b6c6af1 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -73,6 +73,7 @@ type Manager struct { frontendDir string acmeStaging bool securityCfg config.SecurityConfig + encSvc *crypto.EncryptionService } // NewManager creates a configuration manager. @@ -87,6 +88,11 @@ func NewManager(client CaddyClient, db *gorm.DB, configDir, frontendDir string, } } +// SetEncryptionService configures the encryption service for decrypting private keys in Caddy config generation. +func (m *Manager) SetEncryptionService(svc *crypto.EncryptionService) { + m.encSvc = svc +} + // ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure. func (m *Manager) ApplyConfig(ctx context.Context) error { // Fetch all proxy hosts from database @@ -418,7 +424,7 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { } } - generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs) + generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs, m.encSvc) if err != nil { return fmt.Errorf("generate config: %w", err) } diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go index 4dd48846..41881de7 100644 --- a/backend/internal/caddy/manager_additional_test.go +++ b/backend/internal/caddy/manager_additional_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" @@ -422,7 +423,7 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) { // stub generateConfigFunc to always return error orig := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { return nil, fmt.Errorf("generate fail") } defer func() { generateConfigFunc = orig }() @@ -600,7 +601,7 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T) // Stub generateConfigFunc to capture adminWhitelist var capturedAdmin string orig := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { capturedAdmin = adminWhitelist // return minimal config return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil @@ -651,7 +652,7 @@ func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) { var capturedRules []models.SecurityRuleSet orig := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { capturedRules = rulesets return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil } @@ -706,7 +707,7 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) { var capturedWafEnabled bool var capturedRulesets []models.SecurityRuleSet origGen := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { capturedWafEnabled = wafEnabled capturedRulesets = rulesets return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs) @@ -811,7 +812,7 @@ func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) { // Capture rulesetPaths from GenerateConfig var capturedPaths map[string]string origGen := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { capturedPaths = rulesetPaths return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs) } diff --git a/backend/internal/caddy/manager_patch_coverage_test.go b/backend/internal/caddy/manager_patch_coverage_test.go index 5939b322..370ecec5 100644 --- a/backend/internal/caddy/manager_patch_coverage_test.go +++ b/backend/internal/caddy/manager_patch_coverage_test.go @@ -57,7 +57,7 @@ func TestManagerApplyConfig_DNSProviders_NoKey_SkipsDecryption(t *testing.T) { generateConfigFunc = origGen validateConfigFunc = origVal }() - generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) { capturedLen = len(dnsProviderConfigs) return &Config{}, nil } @@ -111,7 +111,7 @@ func TestManagerApplyConfig_DNSProviders_UsesFallbackEnvKeys(t *testing.T) { generateConfigFunc = origGen validateConfigFunc = origVal }() - generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) { captured = append([]DNSProviderConfig(nil), dnsProviderConfigs...) return &Config{}, nil } @@ -175,7 +175,7 @@ func TestManagerApplyConfig_DNSProviders_SkipsDecryptOrJSONFailures(t *testing.T generateConfigFunc = origGen validateConfigFunc = origVal }() - generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) { captured = append([]DNSProviderConfig(nil), dnsProviderConfigs...) return &Config{}, nil } diff --git a/backend/internal/caddy/manager_ssl_provider_test.go b/backend/internal/caddy/manager_ssl_provider_test.go index 39f8b8a9..f5c5072d 100644 --- a/backend/internal/caddy/manager_ssl_provider_test.go +++ b/backend/internal/caddy/manager_ssl_provider_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,8 +18,8 @@ import ( ) // mockGenerateConfigFunc creates a mock config generator that captures parameters -func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig) (*Config, error) { - return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { +func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig, ...*crypto.EncryptionService) (*Config, error) { + return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { *capturedProvider = sslProvider *capturedStaging = acmeStaging return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a21db711..bf1634df 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,6 +33,7 @@ type Config struct { CaddyLogDir string CrowdSecLogDir string Debug bool + CertExpiryWarningDays int Security SecurityConfig Emergency EmergencyConfig } @@ -109,6 +110,20 @@ func Load() (Config, error) { Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true", } + // Certificate expiry warning days + if days := getEnvAny("30", "CHARON_CERT_EXPIRY_WARNING_DAYS"); days != "" { + if n, err := strconv.Atoi(days); err == nil && n > 0 { + cfg.CertExpiryWarningDays = n + } + } + + // Certificate expiry warning days + if days := getEnvAny("30", "CHARON_CERT_EXPIRY_WARNING_DAYS"); days != "" { + if n, err := strconv.Atoi(days); err == nil && n > 0 { + cfg.CertExpiryWarningDays = n + } + } + // Set JWTSecret using os.Getenv directly so no string literal flows into the // field — prevents CodeQL go/parse-jwt-with-hardcoded-key taint from any fallback. cfg.JWTSecret = os.Getenv("CHARON_JWT_SECRET") diff --git a/backend/internal/models/ssl_certificate.go b/backend/internal/models/ssl_certificate.go index 8734a789..05878634 100644 --- a/backend/internal/models/ssl_certificate.go +++ b/backend/internal/models/ssl_certificate.go @@ -7,15 +7,24 @@ import ( // SSLCertificate represents TLS certificates managed by Charon. // Can be Let's Encrypt auto-generated or custom uploaded certs. type SSLCertificate struct { - ID uint `json:"-" gorm:"primaryKey"` - UUID string `json:"uuid" gorm:"uniqueIndex"` - Name string `json:"name" gorm:"index"` - Provider string `json:"provider" gorm:"index"` // "letsencrypt", "letsencrypt-staging", "custom" - Domains string `json:"domains" gorm:"index"` // comma-separated list of domains - Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded certificate - PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded private key - ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"` - AutoRenew bool `json:"auto_renew" gorm:"default:false"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Name string `json:"name" gorm:"index"` + Provider string `json:"provider" gorm:"index"` + Domains string `json:"domains" gorm:"index"` + CommonName string `json:"common_name"` + Certificate string `json:"-" gorm:"type:text"` + CertificateChain string `json:"-" gorm:"type:text"` + PrivateKeyEncrypted string `json:"-" gorm:"column:private_key_enc;type:text"` + PrivateKey string `json:"-" gorm:"-"` + KeyVersion int `json:"-" gorm:"default:1"` + Fingerprint string `json:"fingerprint"` + SerialNumber string `json:"serial_number"` + IssuerOrg string `json:"issuer_org"` + KeyType string `json:"key_type"` + ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"` + NotBefore *time.Time `json:"not_before,omitempty"` + AutoRenew bool `json:"auto_renew" gorm:"default:false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/network/internal_service_client_test.go b/backend/internal/network/internal_service_client_test.go index 2f48e5ab..5ebdc498 100644 --- a/backend/internal/network/internal_service_client_test.go +++ b/backend/internal/network/internal_service_client_test.go @@ -27,6 +27,7 @@ func TestNewInternalServiceHTTPClient(t *testing.T) { client := NewInternalServiceHTTPClient(tt.timeout) if client == nil { t.Fatal("NewInternalServiceHTTPClient() returned nil") + return } if client.Timeout != tt.timeout { t.Errorf("expected timeout %v, got %v", tt.timeout, client.Timeout) diff --git a/backend/internal/network/safeclient_test.go b/backend/internal/network/safeclient_test.go index 1216f2e2..c9a62b19 100644 --- a/backend/internal/network/safeclient_test.go +++ b/backend/internal/network/safeclient_test.go @@ -179,6 +179,7 @@ func TestNewSafeHTTPClient_DefaultOptions(t *testing.T) { client := NewSafeHTTPClient() if client == nil { t.Fatal("NewSafeHTTPClient() returned nil") + return } if client.Timeout != 10*time.Second { t.Errorf("expected default timeout of 10s, got %v", client.Timeout) @@ -190,6 +191,7 @@ func TestNewSafeHTTPClient_WithTimeout(t *testing.T) { client := NewSafeHTTPClient(WithTimeout(10 * time.Second)) if client == nil { t.Fatal("NewSafeHTTPClient() returned nil") + return } if client.Timeout != 10*time.Second { t.Errorf("expected timeout of 10s, got %v", client.Timeout) @@ -848,6 +850,7 @@ func TestClientOptions_AllFunctionalOptions(t *testing.T) { if client == nil { t.Fatal("NewSafeHTTPClient() returned nil with all options") + return } if client.Timeout != 15*time.Second { t.Errorf("expected timeout of 15s, got %v", client.Timeout) diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go index f6806d8a..b96f1c28 100644 --- a/backend/internal/services/certificate_service.go +++ b/backend/internal/services/certificate_service.go @@ -1,15 +1,19 @@ package services import ( + "context" + crand "crypto/rand" "crypto/x509" "encoding/pem" "fmt" + "math/big" "os" "path/filepath" "strings" "sync" "time" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/util" @@ -22,22 +26,73 @@ import ( // ErrCertInUse is returned when a certificate is linked to one or more proxy hosts. var ErrCertInUse = fmt.Errorf("certificate is in use by one or more proxy hosts") -// CertificateInfo represents parsed certificate details. +// ErrCertNotFound is returned when a certificate cannot be found by UUID. +var ErrCertNotFound = fmt.Errorf("certificate not found") + +// CertificateInfo represents parsed certificate details for list responses. type CertificateInfo struct { - ID uint `json:"id,omitempty"` - UUID string `json:"uuid,omitempty"` - Name string `json:"name,omitempty"` - Domain string `json:"domain"` + UUID string `json:"uuid"` + Name string `json:"name,omitempty"` + CommonName string `json:"common_name,omitempty"` + Domains string `json:"domains"` + Issuer string `json:"issuer"` + IssuerOrg string `json:"issuer_org,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + KeyType string `json:"key_type,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + NotBefore time.Time `json:"not_before,omitempty"` + Status string `json:"status"` + Provider string `json:"provider"` + ChainDepth int `json:"chain_depth,omitempty"` + HasKey bool `json:"has_key"` + InUse bool `json:"in_use"` +} + +// AssignedHostInfo represents a proxy host assigned to a certificate. +type AssignedHostInfo struct { + UUID string `json:"uuid"` + Name string `json:"name"` + DomainNames string `json:"domain_names"` +} + +// ChainEntry represents a single certificate in the chain. +type ChainEntry struct { + Subject string `json:"subject"` Issuer string `json:"issuer"` ExpiresAt time.Time `json:"expires_at"` - Status string `json:"status"` // "valid", "expiring", "expired", "untrusted" - Provider string `json:"provider"` // "letsencrypt", "letsencrypt-staging", "custom" +} + +// CertificateDetail contains full certificate metadata for detail responses. +type CertificateDetail struct { + UUID string `json:"uuid"` + Name string `json:"name,omitempty"` + CommonName string `json:"common_name,omitempty"` + Domains string `json:"domains"` + Issuer string `json:"issuer"` + IssuerOrg string `json:"issuer_org,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + KeyType string `json:"key_type,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + NotBefore time.Time `json:"not_before,omitempty"` + Status string `json:"status"` + Provider string `json:"provider"` + ChainDepth int `json:"chain_depth,omitempty"` + HasKey bool `json:"has_key"` + InUse bool `json:"in_use"` + AssignedHosts []AssignedHostInfo `json:"assigned_hosts"` + Chain []ChainEntry `json:"chain"` + AutoRenew bool `json:"auto_renew"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CertificateService manages certificate retrieval and parsing. type CertificateService struct { dataDir string db *gorm.DB + encSvc *crypto.EncryptionService cache []CertificateInfo cacheMu sync.RWMutex lastScan time.Time @@ -46,11 +101,12 @@ type CertificateService struct { } // NewCertificateService creates a new certificate service. -func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService { +func NewCertificateService(dataDir string, db *gorm.DB, encSvc *crypto.EncryptionService) *CertificateService { svc := &CertificateService{ dataDir: dataDir, db: db, - scanTTL: 5 * time.Minute, // Only rescan disk every 5 minutes + encSvc: encSvc, + scanTTL: 5 * time.Minute, } return svc } @@ -224,15 +280,18 @@ func (s *CertificateService) refreshCacheFromDB() error { return fmt.Errorf("failed to fetch certs from DB: %w", err) } - // Build a map of domain -> proxy host name for quick lookup + // Build a set of certificate IDs that are in use + certInUse := make(map[uint]bool) var proxyHosts []models.ProxyHost s.db.Find(&proxyHosts) domainToName := make(map[string]string) for _, ph := range proxyHosts { + if ph.CertificateID != nil { + certInUse[*ph.CertificateID] = true + } if ph.Name == "" { continue } - // Handle comma-separated domains domains := strings.Split(ph.DomainNames, ",") for _, d := range domains { d = strings.TrimSpace(strings.ToLower(d)) @@ -244,27 +303,20 @@ func (s *CertificateService) refreshCacheFromDB() error { certs := make([]CertificateInfo, 0, len(dbCerts)) for _, c := range dbCerts { - status := "valid" - - // Staging certificates are untrusted by browsers - if strings.Contains(c.Provider, "staging") { - status = "untrusted" - } else if c.ExpiresAt != nil { - if time.Now().After(*c.ExpiresAt) { - status = "expired" - } else if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) { - status = "expiring" - } - } + status := certStatus(c) expires := time.Time{} if c.ExpiresAt != nil { expires = *c.ExpiresAt } + notBefore := time.Time{} + if c.NotBefore != nil { + notBefore = *c.NotBefore + } + // Try to get name from proxy host, fall back to cert name or domain name := c.Name - // Check all domains in the cert against proxy hosts certDomains := strings.Split(c.Domains, ",") for _, d := range certDomains { d = strings.TrimSpace(strings.ToLower(d)) @@ -274,15 +326,36 @@ func (s *CertificateService) refreshCacheFromDB() error { } } + chainDepth := 0 + if c.CertificateChain != "" { + rest := []byte(c.CertificateChain) + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + chainDepth++ + } + } + certs = append(certs, CertificateInfo{ - ID: c.ID, - UUID: c.UUID, - Name: name, - Domain: c.Domains, - Issuer: c.Provider, - ExpiresAt: expires, - Status: status, - Provider: c.Provider, + UUID: c.UUID, + Name: name, + CommonName: c.CommonName, + Domains: c.Domains, + Issuer: c.Provider, + IssuerOrg: c.IssuerOrg, + Fingerprint: c.Fingerprint, + SerialNumber: c.SerialNumber, + KeyType: c.KeyType, + ExpiresAt: expires, + NotBefore: notBefore, + Status: status, + Provider: c.Provider, + ChainDepth: chainDepth, + HasKey: c.PrivateKeyEncrypted != "", + InUse: certInUse[c.ID], }) } @@ -290,6 +363,21 @@ func (s *CertificateService) refreshCacheFromDB() error { return nil } +func certStatus(c models.SSLCertificate) string { + if strings.Contains(c.Provider, "staging") { + return "untrusted" + } + if c.ExpiresAt != nil { + if time.Now().After(*c.ExpiresAt) { + return "expired" + } + if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) { + return "expiring" + } + } + return "valid" +} + // ListCertificates returns cached certificate info. // Fast path: returns from cache if available. // Triggers background rescan if cache is stale. @@ -342,45 +430,205 @@ func (s *CertificateService) InvalidateCache() { s.cacheMu.Unlock() } -// UploadCertificate saves a new custom certificate. -func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM string) (*models.SSLCertificate, error) { - // Validate PEM - block, _ := pem.Decode([]byte(certPEM)) - if block == nil { - return nil, fmt.Errorf("invalid certificate PEM") - } - - cert, err := x509.ParseCertificate(block.Bytes) +// UploadCertificate saves a new custom certificate with full validation and encryption. +func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM, chainPEM string) (*CertificateInfo, error) { + parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "") if err != nil { - return nil, fmt.Errorf("failed to parse certificate: %w", err) + return nil, fmt.Errorf("failed to parse certificate input: %w", err) } - // Create DB entry + // Validate key matches certificate if key is provided + if parsed.PrivateKey != nil { + if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil { + return nil, fmt.Errorf("key validation failed: %w", err) + } + } + + // Extract metadata + meta := ExtractCertificateMetadata(parsed.Leaf) + + domains := meta.CommonName + if len(parsed.Leaf.DNSNames) > 0 { + domains = strings.Join(parsed.Leaf.DNSNames, ",") + } + + notAfter := parsed.Leaf.NotAfter + notBefore := parsed.Leaf.NotBefore + sslCert := &models.SSLCertificate{ - UUID: uuid.New().String(), - Name: name, - Provider: "custom", - Domains: cert.Subject.CommonName, // Or SANs - Certificate: certPEM, - PrivateKey: keyPEM, - ExpiresAt: &cert.NotAfter, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + UUID: uuid.New().String(), + Name: name, + Provider: "custom", + Domains: domains, + CommonName: meta.CommonName, + Certificate: parsed.CertPEM, + CertificateChain: parsed.ChainPEM, + Fingerprint: meta.Fingerprint, + SerialNumber: meta.SerialNumber, + IssuerOrg: meta.IssuerOrg, + KeyType: meta.KeyType, + ExpiresAt: ¬After, + NotBefore: ¬Before, + KeyVersion: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } - // Handle SANs if present - if len(cert.DNSNames) > 0 { - sslCert.Domains = strings.Join(cert.DNSNames, ",") + // Encrypt private key at rest + if parsed.KeyPEM != "" && s.encSvc != nil { + encrypted, err := s.encSvc.Encrypt([]byte(parsed.KeyPEM)) + if err != nil { + return nil, fmt.Errorf("failed to encrypt private key: %w", err) + } + sslCert.PrivateKeyEncrypted = encrypted } if err := s.db.Create(sslCert).Error; err != nil { - return nil, err + return nil, fmt.Errorf("failed to save certificate: %w", err) } - // Invalidate cache so the new cert appears immediately s.InvalidateCache() - return sslCert, nil + chainDepth := len(parsed.Intermediates) + + info := &CertificateInfo{ + UUID: sslCert.UUID, + Name: sslCert.Name, + CommonName: sslCert.CommonName, + Domains: sslCert.Domains, + Issuer: sslCert.Provider, + IssuerOrg: sslCert.IssuerOrg, + Fingerprint: sslCert.Fingerprint, + SerialNumber: sslCert.SerialNumber, + KeyType: sslCert.KeyType, + ExpiresAt: notAfter, + NotBefore: notBefore, + Status: certStatus(*sslCert), + Provider: sslCert.Provider, + ChainDepth: chainDepth, + HasKey: sslCert.PrivateKeyEncrypted != "", + InUse: false, + } + + return info, nil +} + +// GetCertificate returns full certificate detail by UUID. +func (s *CertificateService) GetCertificate(certUUID string) (*CertificateDetail, error) { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrCertNotFound + } + return nil, fmt.Errorf("failed to fetch certificate: %w", err) + } + + // Get assigned hosts + var hosts []models.ProxyHost + s.db.Where("certificate_id = ?", cert.ID).Find(&hosts) + assignedHosts := make([]AssignedHostInfo, 0, len(hosts)) + for _, h := range hosts { + assignedHosts = append(assignedHosts, AssignedHostInfo{ + UUID: h.UUID, + Name: h.Name, + DomainNames: h.DomainNames, + }) + } + + // Parse chain entries + chain := buildChainEntries(cert.Certificate, cert.CertificateChain) + + expires := time.Time{} + if cert.ExpiresAt != nil { + expires = *cert.ExpiresAt + } + notBefore := time.Time{} + if cert.NotBefore != nil { + notBefore = *cert.NotBefore + } + + detail := &CertificateDetail{ + UUID: cert.UUID, + Name: cert.Name, + CommonName: cert.CommonName, + Domains: cert.Domains, + Issuer: cert.Provider, + IssuerOrg: cert.IssuerOrg, + Fingerprint: cert.Fingerprint, + SerialNumber: cert.SerialNumber, + KeyType: cert.KeyType, + ExpiresAt: expires, + NotBefore: notBefore, + Status: certStatus(cert), + Provider: cert.Provider, + ChainDepth: len(chain), + HasKey: cert.PrivateKeyEncrypted != "", + InUse: len(hosts) > 0, + AssignedHosts: assignedHosts, + Chain: chain, + AutoRenew: cert.AutoRenew, + CreatedAt: cert.CreatedAt, + UpdatedAt: cert.UpdatedAt, + } + + return detail, nil +} + +// ValidateCertificate validates certificate data without storing. +func (s *CertificateService) ValidateCertificate(certPEM, keyPEM, chainPEM string) (*ValidationResult, error) { + result := &ValidationResult{ + Warnings: []string{}, + Errors: []string{}, + } + + parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "") + if err != nil { + result.Errors = append(result.Errors, err.Error()) + return result, nil + } + + meta := ExtractCertificateMetadata(parsed.Leaf) + result.CommonName = meta.CommonName + result.Domains = meta.Domains + result.IssuerOrg = meta.IssuerOrg + result.ExpiresAt = meta.NotAfter + result.ChainDepth = len(parsed.Intermediates) + + // Key match check + if parsed.PrivateKey != nil { + if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("key mismatch: %s", err.Error())) + } else { + result.KeyMatch = true + } + } + + // Chain validation (best-effort, warn on failure) + if len(parsed.Intermediates) > 0 { + if err := ValidateChain(parsed.Leaf, parsed.Intermediates); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("chain validation: %s", err.Error())) + } else { + result.ChainValid = true + } + } else { + // Try verifying with system roots + if err := ValidateChain(parsed.Leaf, nil); err != nil { + result.Warnings = append(result.Warnings, "certificate could not be verified against system roots") + } else { + result.ChainValid = true + } + } + + // Expiry warnings + daysUntilExpiry := time.Until(parsed.Leaf.NotAfter).Hours() / 24 + if daysUntilExpiry < 0 { + result.Warnings = append(result.Warnings, "Certificate has expired") + } else if daysUntilExpiry < 30 { + result.Warnings = append(result.Warnings, fmt.Sprintf("Certificate expires in %.0f days", daysUntilExpiry)) + } + + result.Valid = len(result.Errors) == 0 + return result, nil } // IsCertificateInUse checks if a certificate is referenced by any proxy host. @@ -392,10 +640,30 @@ func (s *CertificateService) IsCertificateInUse(id uint) (bool, error) { return count > 0, nil } -// DeleteCertificate removes a certificate. -func (s *CertificateService) DeleteCertificate(id uint) error { +// IsCertificateInUseByUUID checks if a certificate is referenced by any proxy host, looked up by UUID. +func (s *CertificateService) IsCertificateInUseByUUID(certUUID string) (bool, error) { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return false, ErrCertNotFound + } + return false, fmt.Errorf("failed to look up certificate: %w", err) + } + return s.IsCertificateInUse(cert.ID) +} + +// DeleteCertificate removes a certificate by UUID. +func (s *CertificateService) DeleteCertificate(certUUID string) error { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return ErrCertNotFound + } + return fmt.Errorf("failed to look up certificate: %w", err) + } + // Prevent deletion if the certificate is referenced by any proxy host - inUse, err := s.IsCertificateInUse(id) + inUse, err := s.IsCertificateInUse(cert.ID) if err != nil { return err } @@ -403,30 +671,22 @@ func (s *CertificateService) DeleteCertificate(id uint) error { return ErrCertInUse } - var cert models.SSLCertificate - if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil { - return err - } - - if cert.Provider == "letsencrypt" { + if cert.Provider == "letsencrypt" || cert.Provider == "letsencrypt-staging" { // Best-effort file deletion certRoot := filepath.Join(s.dataDir, "certificates") _ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error { if err == nil && !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") { if info.Name() == cert.Domains+".crt" { - // Found it logger.Log().WithField("path", path).Info("CertificateService: deleting ACME cert file") if err := os.Remove(path); err != nil { logger.Log().WithError(err).Error("CertificateService: failed to delete cert file") } - // Try to delete key as well keyPath := strings.TrimSuffix(path, ".crt") + ".key" if _, err := os.Stat(keyPath); err == nil { if err := os.Remove(keyPath); err != nil { logger.Log().WithError(err).Warn("Failed to remove key file") } } - // Also try to delete the json meta file jsonPath := strings.TrimSuffix(path, ".crt") + ".json" if _, err := os.Stat(jsonPath); err == nil { if err := os.Remove(jsonPath); err != nil { @@ -439,10 +699,348 @@ func (s *CertificateService) DeleteCertificate(id uint) error { }) } - if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error; err != nil { - return err + if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", cert.ID).Error; err != nil { + return fmt.Errorf("failed to delete certificate: %w", err) } - // Invalidate cache so the deleted cert disappears immediately s.InvalidateCache() return nil } + +// ExportCertificate exports a certificate in the requested format. +// Returns the file data, suggested filename, and any error. +func (s *CertificateService) ExportCertificate(certUUID string, format string, includeKey bool) ([]byte, string, error) { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, "", ErrCertNotFound + } + return nil, "", fmt.Errorf("failed to fetch certificate: %w", err) + } + + baseName := cert.Name + if baseName == "" { + baseName = "certificate" + } + + switch strings.ToLower(format) { + case "pem": + var buf strings.Builder + buf.WriteString(cert.Certificate) + if cert.CertificateChain != "" { + buf.WriteString("\n") + buf.WriteString(cert.CertificateChain) + } + if includeKey { + keyPEM, err := s.GetDecryptedPrivateKey(&cert) + if err != nil { + return nil, "", fmt.Errorf("failed to decrypt private key: %w", err) + } + buf.WriteString("\n") + buf.WriteString(keyPEM) + } + return []byte(buf.String()), baseName + ".pem", nil + + case "der": + derData, err := ConvertPEMToDER(cert.Certificate) + if err != nil { + return nil, "", fmt.Errorf("failed to convert to DER: %w", err) + } + return derData, baseName + ".der", nil + + case "pfx", "p12": + keyPEM, err := s.GetDecryptedPrivateKey(&cert) + if err != nil { + return nil, "", fmt.Errorf("failed to decrypt private key for PFX: %w", err) + } + pfxData, err := ConvertPEMToPFX(cert.Certificate, keyPEM, cert.CertificateChain, "") + if err != nil { + return nil, "", fmt.Errorf("failed to create PFX: %w", err) + } + return pfxData, baseName + ".pfx", nil + + default: + return nil, "", fmt.Errorf("unsupported export format: %s", format) + } +} + +// GetDecryptedPrivateKey decrypts and returns the private key PEM for internal use. +func (s *CertificateService) GetDecryptedPrivateKey(cert *models.SSLCertificate) (string, error) { + if cert.PrivateKeyEncrypted == "" { + return "", fmt.Errorf("no encrypted private key stored") + } + if s.encSvc == nil { + return "", fmt.Errorf("encryption service not configured") + } + + decrypted, err := s.encSvc.Decrypt(cert.PrivateKeyEncrypted) + if err != nil { + return "", fmt.Errorf("failed to decrypt private key: %w", err) + } + + return string(decrypted), nil +} + +// MigratePrivateKeys encrypts existing plaintext private keys. +// Idempotent — skips already-migrated rows. +func (s *CertificateService) MigratePrivateKeys() error { + if s.encSvc == nil { + logger.Log().Warn("CertificateService: encryption service not configured, skipping key migration") + return nil + } + + // Use raw SQL because PrivateKey has gorm:"-" tag + type rawCert struct { + ID uint + PrivateKey string + PrivateKeyEnc string `gorm:"column:private_key_enc"` + } + + var certs []rawCert + if err := s.db.Raw("SELECT id, private_key, private_key_enc FROM ssl_certificates WHERE private_key != '' AND (private_key_enc = '' OR private_key_enc IS NULL)").Scan(&certs).Error; err != nil { + return fmt.Errorf("failed to query certificates for migration: %w", err) + } + + if len(certs) == 0 { + logger.Log().Info("CertificateService: no private keys to migrate") + return nil + } + + logger.Log().WithField("count", len(certs)).Info("CertificateService: migrating plaintext private keys") + + for _, c := range certs { + encrypted, err := s.encSvc.Encrypt([]byte(c.PrivateKey)) + if err != nil { + logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to encrypt key during migration") + continue + } + + if err := s.db.Exec("UPDATE ssl_certificates SET private_key_enc = ?, key_version = 1, private_key = '' WHERE id = ?", encrypted, c.ID).Error; err != nil { + logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to update migrated key") + continue + } + + logger.Log().WithField("cert_id", c.ID).Info("CertificateService: migrated private key") + } + + return nil +} + +// DeleteCertificateByID removes a certificate by numeric ID (legacy compatibility). +func (s *CertificateService) DeleteCertificateByID(id uint) error { + var cert models.SSLCertificate + if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil { + return fmt.Errorf("failed to look up certificate: %w", err) + } + return s.DeleteCertificate(cert.UUID) +} + +// UpdateCertificate updates certificate metadata (name only) by UUID. +func (s *CertificateService) UpdateCertificate(certUUID string, name string) (*CertificateInfo, error) { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrCertNotFound + } + return nil, fmt.Errorf("failed to fetch certificate: %w", err) + } + + cert.Name = name + if err := s.db.Save(&cert).Error; err != nil { + return nil, fmt.Errorf("failed to update certificate: %w", err) + } + + s.InvalidateCache() + + expires := time.Time{} + if cert.ExpiresAt != nil { + expires = *cert.ExpiresAt + } + notBefore := time.Time{} + if cert.NotBefore != nil { + notBefore = *cert.NotBefore + } + + var chainDepth int + if cert.CertificateChain != "" { + certs, _ := parsePEMCertificates([]byte(cert.CertificateChain)) + chainDepth = len(certs) + } + + inUse, _ := s.IsCertificateInUse(cert.ID) + + return &CertificateInfo{ + UUID: cert.UUID, + Name: cert.Name, + CommonName: cert.CommonName, + Domains: cert.Domains, + Issuer: cert.Provider, + IssuerOrg: cert.IssuerOrg, + Fingerprint: cert.Fingerprint, + SerialNumber: cert.SerialNumber, + KeyType: cert.KeyType, + ExpiresAt: expires, + NotBefore: notBefore, + Status: certStatus(cert), + Provider: cert.Provider, + ChainDepth: chainDepth, + HasKey: cert.PrivateKeyEncrypted != "", + InUse: inUse, + }, nil +} + +// CheckExpiringCertificates returns certificates that are expiring within the given number of days. +func (s *CertificateService) CheckExpiringCertificates(warningDays int) ([]CertificateInfo, error) { + var certs []models.SSLCertificate + threshold := time.Now().Add(time.Duration(warningDays) * 24 * time.Hour) + + if err := s.db.Where("provider = ? AND expires_at IS NOT NULL AND expires_at <= ?", "custom", threshold).Find(&certs).Error; err != nil { + return nil, fmt.Errorf("failed to query expiring certificates: %w", err) + } + + result := make([]CertificateInfo, 0, len(certs)) + for _, cert := range certs { + expires := time.Time{} + if cert.ExpiresAt != nil { + expires = *cert.ExpiresAt + } + notBefore := time.Time{} + if cert.NotBefore != nil { + notBefore = *cert.NotBefore + } + + result = append(result, CertificateInfo{ + UUID: cert.UUID, + Name: cert.Name, + CommonName: cert.CommonName, + Domains: cert.Domains, + Issuer: cert.Provider, + IssuerOrg: cert.IssuerOrg, + Fingerprint: cert.Fingerprint, + SerialNumber: cert.SerialNumber, + KeyType: cert.KeyType, + ExpiresAt: expires, + NotBefore: notBefore, + Status: certStatus(cert), + Provider: cert.Provider, + HasKey: cert.PrivateKeyEncrypted != "", + }) + } + + return result, nil +} + +// StartExpiryChecker runs a background goroutine that periodically checks for expiring certificates. +func (s *CertificateService) StartExpiryChecker(ctx context.Context, notificationSvc *NotificationService, warningDays int) { + // Startup delay: avoid notification bursts during frequent restarts + startupDelay := 5 * time.Minute + select { + case <-ctx.Done(): + return + case <-time.After(startupDelay): + } + + // Add random jitter (0-60 minutes) using crypto/rand + maxJitter := int64(60 * time.Minute) + n, errRand := crand.Int(crand.Reader, big.NewInt(maxJitter)) + if errRand != nil { + n = big.NewInt(maxJitter / 2) + } + jitter := time.Duration(n.Int64()) + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + s.checkExpiry(ctx, notificationSvc, warningDays) + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.checkExpiry(ctx, notificationSvc, warningDays) + } + } +} + +func (s *CertificateService) checkExpiry(ctx context.Context, notificationSvc *NotificationService, warningDays int) { + if notificationSvc == nil { + return + } + + certs, err := s.CheckExpiringCertificates(warningDays) + if err != nil { + logger.Log().WithError(err).Error("CertificateService: failed to check expiring certificates") + return + } + + for _, cert := range certs { + daysLeft := time.Until(cert.ExpiresAt).Hours() / 24 + + if daysLeft < 0 { + // Expired + if _, err := notificationSvc.Create( + models.NotificationTypeError, + "Certificate Expired", + fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains), + ); err != nil { + logger.Log().WithError(err).Error("CertificateService: failed to create expiry notification") + } + notificationSvc.SendExternal(ctx, + "cert_expiry", + "Certificate Expired", + fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains), + map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "status": "expired"}, + ) + } else { + // Expiring soon + if _, err := notificationSvc.Create( + models.NotificationTypeWarning, + "Certificate Expiring Soon", + fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft), + ); err != nil { + logger.Log().WithError(err).Error("CertificateService: failed to create expiry warning notification") + } + notificationSvc.SendExternal(ctx, + "cert_expiry", + "Certificate Expiring Soon", + fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft), + map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "days_left": int(daysLeft)}, + ) + } + } +} + +func buildChainEntries(certPEM, chainPEM string) []ChainEntry { + var entries []ChainEntry + + // Parse leaf + if certPEM != "" { + certs, _ := parsePEMCertificates([]byte(certPEM)) + for _, c := range certs { + entries = append(entries, ChainEntry{ + Subject: c.Subject.CommonName, + Issuer: c.Issuer.CommonName, + ExpiresAt: c.NotAfter, + }) + } + } + + // Parse chain + if chainPEM != "" { + certs, _ := parsePEMCertificates([]byte(chainPEM)) + for _, c := range certs { + entries = append(entries, ChainEntry{ + Subject: c.Subject.CommonName, + Issuer: c.Issuer.CommonName, + ExpiresAt: c.NotAfter, + }) + } + } + + return entries +} diff --git a/backend/internal/services/certificate_service_test.go b/backend/internal/services/certificate_service_test.go index c0336b92..d45b4291 100644 --- a/backend/internal/services/certificate_service_test.go +++ b/backend/internal/services/certificate_service_test.go @@ -31,6 +31,14 @@ func newTestCertificateService(dataDir string, db *gorm.DB) *CertificateService } } +// certDBID looks up the numeric DB primary key for a certificate by UUID. +func certDBID(t *testing.T, db *gorm.DB, uuid string) uint { + t.Helper() + var cert models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", uuid).First(&cert).Error) + return cert.ID +} + func TestNewCertificateService(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) @@ -43,7 +51,7 @@ func TestNewCertificateService(t *testing.T) { require.NoError(t, os.MkdirAll(certDir, 0o750)) // #nosec G301 -- test directory // Test service creation - svc := NewCertificateService(tmpDir, db) + svc := NewCertificateService(tmpDir, db, nil) assert.NotNil(t, svc) assert.Equal(t, tmpDir, svc.dataDir) assert.Equal(t, db, svc.db) @@ -54,6 +62,11 @@ func TestNewCertificateService(t *testing.T) { } func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte { + certPEM, _ := generateTestCertAndKey(t, domain, expiry) + return certPEM +} + +func generateTestCertAndKey(t *testing.T, domain string, expiry time.Time) ([]byte, []byte) { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate private key: %v", err) @@ -77,7 +90,9 @@ func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte { t.Fatalf("Failed to create certificate: %v", err) } - return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + return certPEM, keyPEM } func TestCertificateService_GetCertificateInfo(t *testing.T) { @@ -123,7 +138,7 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) { assert.NoError(t, err) assert.Len(t, certs, 1) if len(certs) > 0 { - assert.Equal(t, domain, certs[0].Domain) + assert.Equal(t, domain, certs[0].Domains) assert.Equal(t, "valid", certs[0].Status) // Check expiry within a margin assert.WithinDuration(t, expiry, certs[0].ExpiresAt, time.Second) @@ -153,7 +168,7 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) { // Find the expired one var foundExpired bool for _, c := range certs { - if c.Domain == expiredDomain { + if c.Domains == expiredDomain { assert.Equal(t, "expired", c.Status) foundExpired = true } @@ -174,11 +189,10 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { // Generate Cert domain := "custom.example.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) // Test Upload - cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM), "") require.NoError(t, err) assert.NotNil(t, cert) assert.Equal(t, "My Custom Cert", cert.Name) @@ -190,7 +204,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { require.NoError(t, err) var found bool for _, c := range certs { - if c.ID == cert.ID { + if c.UUID == cert.UUID { found = true assert.Equal(t, "custom", c.Provider) break @@ -199,7 +213,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { assert.True(t, found) // Test Delete - err = cs.DeleteCertificate(cert.ID) + err = cs.DeleteCertificate(cert.UUID) require.NoError(t, err) // Verify it's gone @@ -207,7 +221,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { require.NoError(t, err) found = false for _, c := range certs { - if c.ID == cert.ID { + if c.UUID == cert.UUID { found = true break } @@ -248,7 +262,7 @@ func TestCertificateService_Persistence(t *testing.T) { // Verify it's in the returned list var foundInList bool for _, c := range certs { - if c.Domain == domain { + if c.Domains == domain { foundInList = true assert.Equal(t, "letsencrypt", c.Provider) break @@ -264,7 +278,7 @@ func TestCertificateService_Persistence(t *testing.T) { assert.Equal(t, string(certPEM), dbCert.Certificate) // 4. Delete the certificate via Service (which should delete the file) - err = cs.DeleteCertificate(dbCert.ID) + err = cs.DeleteCertificate(dbCert.UUID) require.NoError(t, err) // Verify file is gone @@ -278,7 +292,7 @@ func TestCertificateService_Persistence(t *testing.T) { // Verify it's NOT in the returned list foundInList = false for _, c := range certs { - if c.Domain == domain { + if c.Domains == domain { foundInList = true break } @@ -301,14 +315,14 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) { cs := newTestCertificateService(tmpDir, db) t.Run("invalid PEM format", func(t *testing.T) { - cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid") + cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid", "") assert.Error(t, err) assert.Nil(t, cert) - assert.Contains(t, err.Error(), "invalid certificate PEM") + assert.Contains(t, err.Error(), "unrecognized certificate format") }) t.Run("empty certificate", func(t *testing.T) { - cert, err := cs.UploadCertificate("Empty", "", "some-key") + cert, err := cs.UploadCertificate("Empty", "", "some-key", "") assert.Error(t, err) assert.Nil(t, cert) }) @@ -318,19 +332,18 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) { expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("No Key", string(certPEM), "") + cert, err := cs.UploadCertificate("No Key", string(certPEM), "", "") assert.NoError(t, err) // Uploading without key is allowed assert.NotNil(t, cert) - assert.Equal(t, "", cert.PrivateKey) + assert.False(t, cert.HasKey) }) t.Run("valid certificate with name", func(t *testing.T) { domain := "valid.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) - cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM), "") assert.NoError(t, err) assert.NotNil(t, cert) assert.Equal(t, "Valid Cert", cert.Name) @@ -341,10 +354,9 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) { t.Run("expired certificate can be uploaded", func(t *testing.T) { domain := "expired-upload.com" expiry := time.Now().Add(-24 * time.Hour) // Already expired - certPEM := generateTestCert(t, domain, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) - cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM), "") // Should still upload successfully, but status will be expired assert.NoError(t, err) assert.NotNil(t, cert) @@ -430,7 +442,7 @@ func TestCertificateService_ListCertificates_EdgeCases(t *testing.T) { domain2 := "custom.example.com" expiry2 := time.Now().Add(48 * time.Hour) certPEM2 := generateTestCert(t, domain2, expiry2) - _, err = cs.UploadCertificate("Custom", string(certPEM2), "FAKE KEY") + _, err = cs.UploadCertificate("Custom", string(certPEM2), "", "") require.NoError(t, err) certs, err := cs.ListCertificates() @@ -457,20 +469,22 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) { cs := newTestCertificateService(tmpDir, db) t.Run("delete non-existent certificate", func(t *testing.T) { - // IsCertificateInUse will succeed (not in use), then First will fail - err := cs.DeleteCertificate(99999) + // DeleteCertificate takes UUID string; non-existent UUID returns error + err := cs.DeleteCertificate("non-existent-uuid") assert.Error(t, err) - assert.Equal(t, gorm.ErrRecordNotFound, err) }) t.Run("delete certificate in use returns ErrCertInUse", func(t *testing.T) { // Create certificate domain := "in-use.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("In Use", string(certPEM), "FAKE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) + cert, err := cs.UploadCertificate("In Use", string(certPEM), string(keyPEM), "") require.NoError(t, err) + // Look up numeric ID for FK + dbID := certDBID(t, db, cert.UUID) + // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "test-ph", @@ -478,18 +492,18 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) { DomainNames: "in-use.com", ForwardHost: "localhost", ForwardPort: 8080, - CertificateID: &cert.ID, + CertificateID: &dbID, } require.NoError(t, db.Create(&ph).Error) // Attempt to delete certificate - should fail with ErrCertInUse - err = cs.DeleteCertificate(cert.ID) + err = cs.DeleteCertificate(cert.UUID) assert.Error(t, err) assert.Equal(t, ErrCertInUse, err) // Verify certificate still exists var dbCert models.SSLCertificate - err = db.First(&dbCert, "id = ?", cert.ID).Error + err = db.First(&dbCert, "id = ?", dbID).Error assert.NoError(t, err) }) @@ -497,21 +511,24 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) { // Create and upload cert domain := "to-delete.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("To Delete", string(certPEM), "FAKE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) + cert, err := cs.UploadCertificate("To Delete", string(certPEM), string(keyPEM), "") require.NoError(t, err) + // Look up numeric ID for verification + dbID := certDBID(t, db, cert.UUID) + // Manually remove the file (custom certs stored by numeric ID) certPath := filepath.Join(tmpDir, "certificates", "custom", "cert.crt") _ = os.Remove(certPath) // Delete should still work (DB cleanup) - err = cs.DeleteCertificate(cert.ID) + err = cs.DeleteCertificate(cert.UUID) assert.NoError(t, err) // Verify DB record is gone var dbCert models.SSLCertificate - err = db.First(&dbCert, "id = ?", cert.ID).Error + err = db.First(&dbCert, "id = ?", dbID).Error assert.Error(t, err) }) } @@ -781,9 +798,8 @@ func TestCertificateService_CertificateWithSANs(t *testing.T) { domain := "san.example.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCertWithSANs(t, domain, []string{"san.example.com", "www.san.example.com", "api.san.example.com"}, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") - cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), "", "") require.NoError(t, err) assert.NotNil(t, cert) // Should have joined SANs @@ -807,10 +823,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { domain := "unused.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("Unused", string(certPEM), "FAKE KEY") + cert, err := cs.UploadCertificate("Unused", string(certPEM), "", "") require.NoError(t, err) - inUse, err := cs.IsCertificateInUse(cert.ID) + dbID := certDBID(t, db, cert.UUID) + inUse, err := cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.False(t, inUse) }) @@ -820,9 +837,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { domain := "used.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("Used", string(certPEM), "FAKE KEY") + cert, err := cs.UploadCertificate("Used", string(certPEM), "", "") require.NoError(t, err) + dbID := certDBID(t, db, cert.UUID) + // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "ph-1", @@ -830,11 +849,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { DomainNames: "used.com", ForwardHost: "localhost", ForwardPort: 8080, - CertificateID: &cert.ID, + CertificateID: &dbID, } require.NoError(t, db.Create(&ph).Error) - inUse, err := cs.IsCertificateInUse(cert.ID) + inUse, err := cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.True(t, inUse) }) @@ -844,9 +863,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { domain := "shared.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("Shared", string(certPEM), "FAKE KEY") + cert, err := cs.UploadCertificate("Shared", string(certPEM), "", "") require.NoError(t, err) + dbID := certDBID(t, db, cert.UUID) + // Create multiple proxy hosts using this certificate for i := 1; i <= 3; i++ { ph := models.ProxyHost{ @@ -855,12 +876,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { DomainNames: fmt.Sprintf("host%d.shared.com", i), ForwardHost: "localhost", ForwardPort: 8080 + i, - CertificateID: &cert.ID, + CertificateID: &dbID, } require.NoError(t, db.Create(&ph).Error) } - inUse, err := cs.IsCertificateInUse(cert.ID) + inUse, err := cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.True(t, inUse) }) @@ -876,9 +897,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { domain := "freed.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("Freed", string(certPEM), "FAKE KEY") + cert, err := cs.UploadCertificate("Freed", string(certPEM), "", "") require.NoError(t, err) + dbID := certDBID(t, db, cert.UUID) + // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "ph-freed", @@ -886,12 +909,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { DomainNames: "freed.com", ForwardHost: "localhost", ForwardPort: 8080, - CertificateID: &cert.ID, + CertificateID: &dbID, } require.NoError(t, db.Create(&ph).Error) // Verify in use - inUse, err := cs.IsCertificateInUse(cert.ID) + inUse, err := cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.True(t, inUse) @@ -899,12 +922,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { require.NoError(t, db.Delete(&ph).Error) // Verify no longer in use - inUse, err = cs.IsCertificateInUse(cert.ID) + inUse, err = cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.False(t, inUse) // Now deletion should succeed - err = cs.DeleteCertificate(cert.ID) + err = cs.DeleteCertificate(cert.UUID) assert.NoError(t, err) }) } @@ -922,10 +945,9 @@ func TestCertificateService_CacheBehavior(t *testing.T) { // Create a cert domain := "cache.example.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) - cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM), "") require.NoError(t, err) require.NotNil(t, cert) @@ -940,7 +962,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) { require.Len(t, certs2, 1) // Both should return the same cert - assert.Equal(t, certs1[0].ID, certs2[0].ID) + assert.Equal(t, certs1[0].UUID, certs2[0].UUID) }) t.Run("invalidate cache forces resync", func(t *testing.T) { @@ -954,7 +976,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) { // Create a cert via upload (auto-invalidates) certPEM := generateTestCert(t, "invalidate.example.com", time.Now().Add(24*time.Hour)) - _, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "") + _, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "", "") require.NoError(t, err) // Get list (should have 1) @@ -1012,7 +1034,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) { certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) - assert.Equal(t, "db.example.com", certs[0].Domain) + assert.Equal(t, "db.example.com", certs[0].Domains) }) } @@ -1032,7 +1054,7 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -----END CERTIFICATE-----` - cert, err := cs.UploadCertificate("Corrupted", corruptedPEM, "") + cert, err := cs.UploadCertificate("Corrupted", corruptedPEM, "", "") assert.Error(t, err) assert.Nil(t, cert) assert.Contains(t, err.Error(), "failed to parse certificate") @@ -1047,7 +1069,7 @@ A7qVvdqxevEuUkW4K+2KdMXmnQbG9Aa7k7eBjK1S+0LYmVjPKlJGNXHDGuy5Fw/d hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtLze8R+KrZdHj0hLjZEPnl -----END PRIVATE KEY-----` - cert, err := cs.UploadCertificate("Wrong Type", wrongTypePEM, "") + cert, err := cs.UploadCertificate("Wrong Type", wrongTypePEM, "", "") assert.Error(t, err) assert.Nil(t, cert) assert.Contains(t, err.Error(), "failed to parse certificate") @@ -1070,7 +1092,7 @@ hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtLze8R+KrZdHj0hLjZEPnl require.NoError(t, err) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - cert, err := cs.UploadCertificate("Empty Subject", string(certPEM), "") + cert, err := cs.UploadCertificate("Empty Subject", string(certPEM), "", "") assert.NoError(t, err) // Upload succeeds assert.NotNil(t, cert) assert.Equal(t, "", cert.Domains) // Empty domains field @@ -1165,7 +1187,7 @@ func TestCertificateService_SyncFromDisk_ErrorHandling(t *testing.T) { certs, err := cs.ListCertificates() assert.NoError(t, err) assert.Len(t, certs, 1) - assert.Equal(t, validDomain, certs[0].Domain) + assert.Equal(t, validDomain, certs[0].Domains) }) } @@ -1233,7 +1255,7 @@ func TestCertificateService_RefreshCacheFromDB_EdgeCases(t *testing.T) { require.Len(t, certs, 1) // Should use proxy host name assert.Equal(t, "Matched Proxy", certs[0].Name) - assert.Contains(t, certs[0].Domain, "www.example.com") + assert.Contains(t, certs[0].Domains, "www.example.com") }) } diff --git a/backend/internal/services/certificate_validator.go b/backend/internal/services/certificate_validator.go new file mode 100644 index 00000000..4557e4d9 --- /dev/null +++ b/backend/internal/services/certificate_validator.go @@ -0,0 +1,521 @@ +package services + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "strings" + "time" + + "software.sslmate.com/src/go-pkcs12" +) + +// CertFormat represents a certificate file format. +type CertFormat string + +const ( + FormatPEM CertFormat = "pem" + FormatDER CertFormat = "der" + FormatPFX CertFormat = "pfx" + FormatUnknown CertFormat = "unknown" +) + +// ParsedCertificate contains the parsed result of certificate input. +type ParsedCertificate struct { + Leaf *x509.Certificate + Intermediates []*x509.Certificate + PrivateKey crypto.PrivateKey + CertPEM string + KeyPEM string + ChainPEM string + Format CertFormat +} + +// CertificateMetadata contains extracted metadata from an x509 certificate. +type CertificateMetadata struct { + CommonName string + Domains []string + Fingerprint string + SerialNumber string + IssuerOrg string + KeyType string + NotBefore time.Time + NotAfter time.Time +} + +// ValidationResult contains the result of a certificate validation. +type ValidationResult struct { + Valid bool `json:"valid"` + CommonName string `json:"common_name"` + Domains []string `json:"domains"` + IssuerOrg string `json:"issuer_org"` + ExpiresAt time.Time `json:"expires_at"` + KeyMatch bool `json:"key_match"` + ChainValid bool `json:"chain_valid"` + ChainDepth int `json:"chain_depth"` + Warnings []string `json:"warnings"` + Errors []string `json:"errors"` +} + +// DetectFormat determines the certificate format from raw file content. +// Uses trial-parse strategy: PEM → PFX → DER. +func DetectFormat(data []byte) CertFormat { + block, _ := pem.Decode(data) + if block != nil { + return FormatPEM + } + + if _, _, _, err := pkcs12.DecodeChain(data, ""); err == nil { + return FormatPFX + } + // PFX with empty password failed, but it could be password-protected + // If data starts with PKCS12 magic bytes (ASN.1 SEQUENCE), treat as PFX candidate + if len(data) > 2 && data[0] == 0x30 { + // Could be DER or PFX; try DER parse + if _, err := x509.ParseCertificate(data); err == nil { + return FormatDER + } + // If DER parse fails, it's likely PFX + return FormatPFX + } + + if _, err := x509.ParseCertificate(data); err == nil { + return FormatDER + } + + return FormatUnknown +} + +// ParseCertificateInput handles PEM, PFX, and DER input parsing. +func ParseCertificateInput(certData []byte, keyData []byte, chainData []byte, pfxPassword string) (*ParsedCertificate, error) { + if len(certData) == 0 { + return nil, fmt.Errorf("certificate data is empty") + } + + format := DetectFormat(certData) + + switch format { + case FormatPEM: + return parsePEMInput(certData, keyData, chainData) + case FormatPFX: + return parsePFXInput(certData, pfxPassword) + case FormatDER: + return parseDERInput(certData, keyData) + default: + return nil, fmt.Errorf("unrecognized certificate format") + } +} + +func parsePEMInput(certData []byte, keyData []byte, chainData []byte) (*ParsedCertificate, error) { + result := &ParsedCertificate{Format: FormatPEM} + + // Parse leaf certificate + certs, err := parsePEMCertificates(certData) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate PEM: %w", err) + } + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates found in PEM data") + } + + result.Leaf = certs[0] + result.CertPEM = string(certData) + + // If certData contains multiple certs, treat extras as intermediates + if len(certs) > 1 { + result.Intermediates = certs[1:] + } + + // Parse chain file if provided + if len(chainData) > 0 { + chainCerts, err := parsePEMCertificates(chainData) + if err != nil { + return nil, fmt.Errorf("failed to parse chain PEM: %w", err) + } + result.Intermediates = append(result.Intermediates, chainCerts...) + result.ChainPEM = string(chainData) + } + + // Build chain PEM from intermediates if not set from chain file + if result.ChainPEM == "" && len(result.Intermediates) > 0 { + var chainBuilder strings.Builder + for _, ic := range result.Intermediates { + if err := pem.Encode(&chainBuilder, &pem.Block{Type: "CERTIFICATE", Bytes: ic.Raw}); err != nil { + return nil, fmt.Errorf("failed to encode intermediate certificate: %w", err) + } + } + result.ChainPEM = chainBuilder.String() + } + + // Parse private key + if len(keyData) > 0 { + key, err := parsePEMPrivateKey(keyData) + if err != nil { + return nil, fmt.Errorf("failed to parse private key PEM: %w", err) + } + result.PrivateKey = key + result.KeyPEM = string(keyData) + } + + return result, nil +} + +func parsePFXInput(pfxData []byte, password string) (*ParsedCertificate, error) { + privateKey, leaf, caCerts, err := pkcs12.DecodeChain(pfxData, password) + if err != nil { + return nil, fmt.Errorf("failed to decode PFX/PKCS12: %w", err) + } + + result := &ParsedCertificate{ + Format: FormatPFX, + Leaf: leaf, + Intermediates: caCerts, + PrivateKey: privateKey, + } + + // Convert to PEM for storage + result.CertPEM = encodeCertToPEM(leaf) + + if len(caCerts) > 0 { + var chainBuilder strings.Builder + for _, ca := range caCerts { + chainBuilder.WriteString(encodeCertToPEM(ca)) + } + result.ChainPEM = chainBuilder.String() + } + + keyPEM, err := encodeKeyToPEM(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to encode private key to PEM: %w", err) + } + result.KeyPEM = keyPEM + + return result, nil +} + +func parseDERInput(certData []byte, keyData []byte) (*ParsedCertificate, error) { + cert, err := x509.ParseCertificate(certData) + if err != nil { + return nil, fmt.Errorf("failed to parse DER certificate: %w", err) + } + + result := &ParsedCertificate{ + Format: FormatDER, + Leaf: cert, + CertPEM: encodeCertToPEM(cert), + } + + if len(keyData) > 0 { + key, err := parsePEMPrivateKey(keyData) + if err != nil { + // Try DER key + key, err = x509.ParsePKCS8PrivateKey(keyData) + if err != nil { + key2, err2 := x509.ParseECPrivateKey(keyData) + if err2 != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + key = key2 + } + } + result.PrivateKey = key + keyPEM, err := encodeKeyToPEM(key) + if err != nil { + return nil, fmt.Errorf("failed to encode private key to PEM: %w", err) + } + result.KeyPEM = keyPEM + } + + return result, nil +} + +// ValidateKeyMatch checks that the private key matches the certificate public key. +func ValidateKeyMatch(cert *x509.Certificate, key crypto.PrivateKey) error { + if cert == nil { + return fmt.Errorf("certificate is nil") + } + if key == nil { + return fmt.Errorf("private key is nil") + } + + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + privKey, ok := key.(*rsa.PrivateKey) + if !ok { + return fmt.Errorf("key type mismatch: certificate has RSA public key but private key is not RSA") + } + if pub.N.Cmp(privKey.N) != 0 { + return fmt.Errorf("RSA key mismatch: certificate and private key modulus differ") + } + case *ecdsa.PublicKey: + privKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return fmt.Errorf("key type mismatch: certificate has ECDSA public key but private key is not ECDSA") + } + if pub.X.Cmp(privKey.X) != 0 || pub.Y.Cmp(privKey.Y) != 0 { + return fmt.Errorf("ECDSA key mismatch: certificate and private key points differ") + } + case ed25519.PublicKey: + privKey, ok := key.(ed25519.PrivateKey) + if !ok { + return fmt.Errorf("key type mismatch: certificate has Ed25519 public key but private key is not Ed25519") + } + pubFromPriv := privKey.Public().(ed25519.PublicKey) + if !pub.Equal(pubFromPriv) { + return fmt.Errorf("Ed25519 key mismatch: certificate and private key differ") + } + default: + return fmt.Errorf("unsupported public key type: %T", cert.PublicKey) + } + + return nil +} + +// ValidateChain verifies the certificate chain from leaf to root. +func ValidateChain(leaf *x509.Certificate, intermediates []*x509.Certificate) error { + if leaf == nil { + return fmt.Errorf("leaf certificate is nil") + } + + pool := x509.NewCertPool() + for _, ic := range intermediates { + pool.AddCert(ic) + } + + opts := x509.VerifyOptions{ + Intermediates: pool, + CurrentTime: time.Now(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + + if _, err := leaf.Verify(opts); err != nil { + return fmt.Errorf("chain verification failed: %w", err) + } + + return nil +} + +// ConvertDERToPEM converts DER-encoded certificate to PEM. +func ConvertDERToPEM(derData []byte) (string, error) { + cert, err := x509.ParseCertificate(derData) + if err != nil { + return "", fmt.Errorf("invalid DER data: %w", err) + } + return encodeCertToPEM(cert), nil +} + +// ConvertPFXToPEM extracts cert, key, and chain from PFX/PKCS12. +func ConvertPFXToPEM(pfxData []byte, password string) (certPEM string, keyPEM string, chainPEM string, err error) { + privateKey, leaf, caCerts, err := pkcs12.DecodeChain(pfxData, password) + if err != nil { + return "", "", "", fmt.Errorf("failed to decode PFX: %w", err) + } + + certPEM = encodeCertToPEM(leaf) + + keyPEM, err = encodeKeyToPEM(privateKey) + if err != nil { + return "", "", "", fmt.Errorf("failed to encode key: %w", err) + } + + if len(caCerts) > 0 { + var builder strings.Builder + for _, ca := range caCerts { + builder.WriteString(encodeCertToPEM(ca)) + } + chainPEM = builder.String() + } + + return certPEM, keyPEM, chainPEM, nil +} + +// ConvertPEMToPFX bundles cert, key, chain into PFX. +func ConvertPEMToPFX(certPEM string, keyPEM string, chainPEM string, password string) ([]byte, error) { + certs, err := parsePEMCertificates([]byte(certPEM)) + if err != nil || len(certs) == 0 { + return nil, fmt.Errorf("failed to parse cert PEM: %w", err) + } + + key, err := parsePEMPrivateKey([]byte(keyPEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse key PEM: %w", err) + } + + var caCerts []*x509.Certificate + if chainPEM != "" { + caCerts, err = parsePEMCertificates([]byte(chainPEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse chain PEM: %w", err) + } + } + + pfxData, err := pkcs12.Modern.Encode(key, certs[0], caCerts, password) + if err != nil { + return nil, fmt.Errorf("failed to encode PFX: %w", err) + } + + return pfxData, nil +} + +// ConvertPEMToDER converts PEM certificate to DER. +func ConvertPEMToDER(certPEM string) ([]byte, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM") + } + // Verify it's a valid certificate + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + return nil, fmt.Errorf("invalid certificate PEM: %w", err) + } + return block.Bytes, nil +} + +// ExtractCertificateMetadata extracts fingerprint, serial, issuer, key type, etc. +func ExtractCertificateMetadata(cert *x509.Certificate) *CertificateMetadata { + if cert == nil { + return nil + } + + fingerprint := sha256.Sum256(cert.Raw) + fpHex := formatFingerprint(hex.EncodeToString(fingerprint[:])) + + serial := formatSerial(cert.SerialNumber) + + issuerOrg := "" + if len(cert.Issuer.Organization) > 0 { + issuerOrg = cert.Issuer.Organization[0] + } + + domains := make([]string, 0, len(cert.DNSNames)+1) + if cert.Subject.CommonName != "" { + domains = append(domains, cert.Subject.CommonName) + } + for _, san := range cert.DNSNames { + if san != cert.Subject.CommonName { + domains = append(domains, san) + } + } + + return &CertificateMetadata{ + CommonName: cert.Subject.CommonName, + Domains: domains, + Fingerprint: fpHex, + SerialNumber: serial, + IssuerOrg: issuerOrg, + KeyType: detectKeyType(cert), + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + } +} + +// --- helpers --- + +func parsePEMCertificates(data []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + rest := data + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + certs = append(certs, cert) + } + return certs, nil +} + +func parsePEMPrivateKey(data []byte) (crypto.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("no PEM data found") + } + + // Try PKCS8 first (handles RSA, ECDSA, Ed25519) + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + return key, nil + } + + // Try PKCS1 RSA + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + + // Try EC + if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return key, nil + } + + return nil, fmt.Errorf("unsupported private key format") +} + +func encodeCertToPEM(cert *x509.Certificate) string { + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})) +} + +func encodeKeyToPEM(key crypto.PrivateKey) (string, error) { + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", fmt.Errorf("failed to marshal private key: %w", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), nil +} + +func formatFingerprint(hex string) string { + var parts []string + for i := 0; i < len(hex); i += 2 { + end := i + 2 + if end > len(hex) { + end = len(hex) + } + parts = append(parts, strings.ToUpper(hex[i:end])) + } + return strings.Join(parts, ":") +} + +func formatSerial(n *big.Int) string { + if n == nil { + return "" + } + b := n.Bytes() + parts := make([]string, len(b)) + for i, v := range b { + parts[i] = fmt.Sprintf("%02X", v) + } + return strings.Join(parts, ":") +} + +func detectKeyType(cert *x509.Certificate) string { + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + bits := pub.N.BitLen() + return fmt.Sprintf("RSA-%d", bits) + case *ecdsa.PublicKey: + switch pub.Curve { + case elliptic.P256(): + return "ECDSA-P256" + case elliptic.P384(): + return "ECDSA-P384" + default: + return "ECDSA" + } + case ed25519.PublicKey: + return "Ed25519" + default: + return "Unknown" + } +} diff --git a/backend/internal/services/certificate_validator_test.go b/backend/internal/services/certificate_validator_test.go new file mode 100644 index 00000000..16de35b3 --- /dev/null +++ b/backend/internal/services/certificate_validator_test.go @@ -0,0 +1,388 @@ +package services + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- helpers --- + +func makeRSACertAndKey(t *testing.T, cn string, expiry time.Time) (*x509.Certificate, *rsa.PrivateKey, []byte, []byte) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: expiry, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + return cert, priv, certPEM, keyPEM +} + +func makeECDSACertAndKey(t *testing.T, cn string) (*x509.Certificate, *ecdsa.PrivateKey, []byte, []byte) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return cert, priv, certPEM, keyPEM +} + +func makeEd25519CertAndKey(t *testing.T, cn string) (*x509.Certificate, ed25519.PrivateKey, []byte, []byte) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalPKCS8PrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + return cert, priv, certPEM, keyPEM +} + +// --- DetectFormat --- + +func TestDetectFormat(t *testing.T) { + cert, _, certPEM, _ := makeRSACertAndKey(t, "test.com", time.Now().Add(time.Hour)) + + t.Run("PEM format", func(t *testing.T) { + assert.Equal(t, FormatPEM, DetectFormat(certPEM)) + }) + + t.Run("DER format", func(t *testing.T) { + assert.Equal(t, FormatDER, DetectFormat(cert.Raw)) + }) + + t.Run("unknown format", func(t *testing.T) { + assert.Equal(t, FormatUnknown, DetectFormat([]byte("not a cert"))) + }) + + t.Run("empty data", func(t *testing.T) { + assert.Equal(t, FormatUnknown, DetectFormat([]byte{})) + }) +} + +// --- ParseCertificateInput --- + +func TestParseCertificateInput(t *testing.T) { + t.Run("PEM cert only", func(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "pem.test", time.Now().Add(time.Hour)) + parsed, err := ParseCertificateInput(certPEM, nil, nil, "") + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.Equal(t, FormatPEM, parsed.Format) + assert.Nil(t, parsed.PrivateKey) + }) + + t.Run("PEM cert with key", func(t *testing.T) { + _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pem-key.test", time.Now().Add(time.Hour)) + parsed, err := ParseCertificateInput(certPEM, keyPEM, nil, "") + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.NotNil(t, parsed.PrivateKey) + assert.Equal(t, FormatPEM, parsed.Format) + }) + + t.Run("DER cert", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour)) + parsed, err := ParseCertificateInput(cert.Raw, nil, nil, "") + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.Equal(t, FormatDER, parsed.Format) + }) + + t.Run("empty data returns error", func(t *testing.T) { + _, err := ParseCertificateInput(nil, nil, nil, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty") + }) + + t.Run("unrecognized format returns error", func(t *testing.T) { + _, err := ParseCertificateInput([]byte("garbage"), nil, nil, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized") + }) + + t.Run("invalid key PEM returns error", func(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "badkey.test", time.Now().Add(time.Hour)) + _, err := ParseCertificateInput(certPEM, []byte("not-key"), nil, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "private key") + }) +} + +// --- ValidateKeyMatch --- + +func TestValidateKeyMatch(t *testing.T) { + t.Run("RSA matching", func(t *testing.T) { + cert, priv, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + assert.NoError(t, ValidateKeyMatch(cert, priv)) + }) + + t.Run("RSA mismatched", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa1.test", time.Now().Add(time.Hour)) + _, otherPriv, _, _ := makeRSACertAndKey(t, "rsa2.test", time.Now().Add(time.Hour)) + err := ValidateKeyMatch(cert, otherPriv) + assert.Error(t, err) + assert.Contains(t, err.Error(), "mismatch") + }) + + t.Run("ECDSA matching", func(t *testing.T) { + cert, priv, _, _ := makeECDSACertAndKey(t, "ecdsa.test") + assert.NoError(t, ValidateKeyMatch(cert, priv)) + }) + + t.Run("ECDSA mismatched", func(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "ec1.test") + _, other, _, _ := makeECDSACertAndKey(t, "ec2.test") + assert.Error(t, ValidateKeyMatch(cert, other)) + }) + + t.Run("Ed25519 matching", func(t *testing.T) { + cert, priv, _, _ := makeEd25519CertAndKey(t, "ed.test") + assert.NoError(t, ValidateKeyMatch(cert, priv)) + }) + + t.Run("Ed25519 mismatched", func(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed1.test") + _, other, _, _ := makeEd25519CertAndKey(t, "ed2.test") + assert.Error(t, ValidateKeyMatch(cert, other)) + }) + + t.Run("type mismatch RSA cert with ECDSA key", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + _, ecKey, _, _ := makeECDSACertAndKey(t, "ec.test") + err := ValidateKeyMatch(cert, ecKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "type mismatch") + }) + + t.Run("nil certificate", func(t *testing.T) { + _, priv, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + assert.Error(t, ValidateKeyMatch(nil, priv)) + }) + + t.Run("nil key", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + assert.Error(t, ValidateKeyMatch(cert, nil)) + }) +} + +// --- ValidateChain --- + +func TestValidateChain(t *testing.T) { + t.Run("nil leaf returns error", func(t *testing.T) { + err := ValidateChain(nil, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "nil") + }) + + t.Run("self-signed cert validates", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "self.test", time.Now().Add(time.Hour)) + // Self-signed won't pass chain validation without being a CA + err := ValidateChain(cert, nil) + assert.Error(t, err) + }) +} + +// --- ConvertDERToPEM --- + +func TestConvertDERToPEM(t *testing.T) { + t.Run("valid DER", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour)) + pemStr, err := ConvertDERToPEM(cert.Raw) + require.NoError(t, err) + assert.Contains(t, pemStr, "BEGIN CERTIFICATE") + }) + + t.Run("invalid DER", func(t *testing.T) { + _, err := ConvertDERToPEM([]byte("not-der")) + assert.Error(t, err) + }) +} + +// --- ConvertPEMToDER --- + +func TestConvertPEMToDER(t *testing.T) { + t.Run("valid PEM", func(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "p2d.test", time.Now().Add(time.Hour)) + der, err := ConvertPEMToDER(string(certPEM)) + require.NoError(t, err) + assert.NotEmpty(t, der) + // Round-trip + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + assert.Equal(t, "p2d.test", cert.Subject.CommonName) + }) + + t.Run("invalid PEM", func(t *testing.T) { + _, err := ConvertPEMToDER("not-pem") + assert.Error(t, err) + }) +} + +// --- ExtractCertificateMetadata --- + +func TestExtractCertificateMetadata(t *testing.T) { + t.Run("nil cert returns nil", func(t *testing.T) { + assert.Nil(t, ExtractCertificateMetadata(nil)) + }) + + t.Run("RSA cert metadata", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "meta.test", time.Now().Add(time.Hour)) + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + assert.Equal(t, "meta.test", m.CommonName) + assert.Contains(t, m.KeyType, "RSA") + assert.NotEmpty(t, m.Fingerprint) + assert.NotEmpty(t, m.SerialNumber) + assert.Contains(t, m.Domains, "meta.test") + }) + + t.Run("ECDSA cert metadata", func(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "ec-meta.test") + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + assert.Contains(t, m.KeyType, "ECDSA") + }) + + t.Run("Ed25519 cert metadata", func(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed-meta.test") + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + assert.Equal(t, "Ed25519", m.KeyType) + }) + + t.Run("cert with SANs", func(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(10), + Subject: pkix.Name{CommonName: "main.test"}, + DNSNames: []string{"main.test", "alt1.test", "alt2.test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, _ := x509.ParseCertificate(der) + + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + assert.Contains(t, m.Domains, "main.test") + assert.Contains(t, m.Domains, "alt1.test") + assert.Contains(t, m.Domains, "alt2.test") + // CN should not be duplicated when it matches a SAN + count := 0 + for _, d := range m.Domains { + if d == "main.test" { + count++ + } + } + assert.Equal(t, 1, count, "CN should not be duplicated in domains list") + }) + + t.Run("cert with issuer org", func(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(11), + Subject: pkix.Name{CommonName: "org.test"}, + Issuer: pkix.Name{Organization: []string{"Test Org Inc"}}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, _ := x509.ParseCertificate(der) + + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + // Self-signed cert's issuer org may differ from template + assert.NotEmpty(t, m.Fingerprint) + }) +} + +// --- Helpers --- + +func TestFormatFingerprint(t *testing.T) { + assert.Equal(t, "AB:CD:EF", formatFingerprint("abcdef")) + assert.Equal(t, "01:23", formatFingerprint("0123")) + assert.Equal(t, "", formatFingerprint("")) +} + +func TestFormatSerial(t *testing.T) { + assert.Equal(t, "01", formatSerial(big.NewInt(1))) + assert.Equal(t, "FF", formatSerial(big.NewInt(255))) + assert.Equal(t, "", formatSerial(nil)) +} + +func TestDetectKeyType(t *testing.T) { + t.Run("RSA key type", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + kt := detectKeyType(cert) + assert.Contains(t, kt, "RSA-2048") + }) + + t.Run("ECDSA-P256 key type", func(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "ec.test") + kt := detectKeyType(cert) + assert.Equal(t, "ECDSA-P256", kt) + }) + + t.Run("Ed25519 key type", func(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed.test") + kt := detectKeyType(cert) + assert.Equal(t, "Ed25519", kt) + }) +} diff --git a/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go b/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go index 2c42b598..7b44bf73 100644 --- a/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go +++ b/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go @@ -12,6 +12,7 @@ func TestNewRFC2136Provider(t *testing.T) { if provider == nil { t.Fatal("NewRFC2136Provider() returned nil") + return } if provider.propagationTimeout != RFC2136DefaultPropagationTimeout { diff --git a/docs/plans/archive/nightly-vuln-remediation-spec.md b/docs/plans/archive/nightly-vuln-remediation-spec.md new file mode 100644 index 00000000..0e956d1d --- /dev/null +++ b/docs/plans/archive/nightly-vuln-remediation-spec.md @@ -0,0 +1,432 @@ +# Nightly Build Vulnerability Remediation Plan + +**Date**: 2026-04-09 +**Status**: Draft — Awaiting Approval +**Scope**: Dependency security patches for 5 HIGH + 3 MEDIUM vulnerability groups +**Target**: Single PR — all changes ship together +**Archived**: Previous plan (CrowdSec Hub Bootstrapping) → `docs/plans/archive/crowdsec-hub-bootstrap-spec.md` + +--- + +## 1. Problem Statement + +The Charon nightly build is failing container image vulnerability scans with **5 HIGH-severity** and **multiple MEDIUM-severity** findings. These vulnerabilities exist across three compiled binaries embedded in the container image: + +1. **Charon backend** (`/app/charon`) — Go binary built from `backend/go.mod` +2. **Caddy** (`/usr/bin/caddy`) — Built via xcaddy in the Dockerfile Caddy builder stage +3. **CrowdSec** (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) — Built from source in the Dockerfile CrowdSec builder stage + +Additionally, the **nightly branch** was synced from development before the Go 1.26.2 bump landed, so the nightly image was compiled with Go 1.26.1 (confirmed in `ci_failure.log` line 55: `GO_VERSION: 1.26.1`). + +--- + +## 2. Research Findings + +### 2.1 Go Version Audit + +All files on `development` / `main` already reference **Go 1.26.2**: + +| File | Current Value | Status | +|------|---------------|--------| +| `backend/go.mod` | `go 1.26.2` | ✅ Current | +| `go.work` | `go 1.26.2` | ✅ Current | +| `Dockerfile` (`ARG GO_VERSION`) | `1.26.2` | ✅ Current | +| `.github/workflows/nightly-build.yml` | `'1.26.2'` | ✅ Current | +| `.github/workflows/codecov-upload.yml` | `'1.26.2'` | ✅ Current | +| `.github/workflows/quality-checks.yml` | `'1.26.2'` | ✅ Current | +| `.github/workflows/codeql.yml` | `'1.26.2'` | ✅ Current | +| `.github/workflows/benchmark.yml` | `'1.26.2'` | ✅ Current | +| `.github/workflows/release-goreleaser.yml` | `'1.26.2'` | ✅ Current | +| `.github/workflows/e2e-tests-split.yml` | `'1.26.2'` | ✅ Current | +| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | `'1.26.1'` | ❌ **Stale** | +| `scripts/install-go-1.26.0.sh` | `1.26.0` | ⚠️ Old install script (not used in CI/Docker builds) | + +**Root Cause of Go stdlib CVEs**: The nightly branch's last sync predated the 1.26.2 bump. The next nightly sync from development will propagate 1.26.2 automatically. The only file requiring a fix is the example workflow. + +### 2.2 Vulnerability Inventory + +#### HIGH Severity (must fix — merge-blocking) + +| # | CVE / GHSA | Package | Current | Fix | Binary | Dep Type | +|---|-----------|---------|---------|-----|--------|----------| +| 1 | CVE-2026-39883 | `go.opentelemetry.io/otel/sdk` | v1.40.0 | v1.43.0 | Caddy | Transitive (Caddy plugins → otelhttp → otel/sdk) | +| 2 | CVE-2026-34986 | `github.com/go-jose/go-jose/v3` | v3.0.4 | **v3.0.5** | Caddy | Transitive (caddy-security → JWT/JOSE stack) | +| 3 | CVE-2026-34986 | `github.com/go-jose/go-jose/v4` | v4.1.3 | **v4.1.4** | Caddy | Transitive (grpc v1.79.3 → go-jose/v4) | +| 4 | CVE-2026-32286 | `github.com/jackc/pgproto3/v2` | v2.3.3 | pgx/v4 v4.18.3 ¹ | CrowdSec | Transitive (CrowdSec → pgx/v4 v4.18.2 → pgproto3/v2) | + +¹ pgproto3/v2 has **no patched release**. Fix requires upstream migration to pgx/v5 (uses pgproto3/v3). See §5 Risk Assessment. + +#### MEDIUM Severity (fix in same pass) + +| # | CVE / GHSA | Package(s) | Current | Fix | Binary | Dep Type | +|---|-----------|------------|---------|-----|--------|----------| +| 5 | GHSA-xmrv-pmrh-hhx2 | AWS SDK v2: `eventstream` v1.7.1, `cloudwatchlogs` v1.57.2, `kinesis` v1.40.1, `s3` v1.87.3 | See left | Bump all | CrowdSec | Direct deps of CrowdSec v1.7.7 | +| 6 | CVE-2026-32281, -32288, -32289 | Go stdlib | 1.26.1 | **1.26.2** | All (nightly image) | Toolchain | +| 7 | CVE-2026-39882 | OTel HTTP exporters: `otlploghttp` v0.16.0, `otlpmetrichttp` v1.40.0, `otlptracehttp` v1.40.0 | See left | Bump all | Caddy | Transitive (Caddy plugins → OTel exporters) | + +### 2.3 Dependency Chain Analysis + +#### Backend (`backend/go.mod`) + +``` +charon/backend (direct) + └─ docker/docker v28.5.2+incompatible (direct) + └─ otelhttp v0.68.0 (indirect) + └─ otel/sdk v1.43.0 (indirect) — already at latest + └─ grpc v1.79.3 (indirect) + └─ otlptracehttp v1.42.0 (indirect) ── CVE-2026-39882 +``` + +Backend resolved versions (verified via `go list -m -json`): + +| Package | Version | Type | +|---------|---------|------| +| `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` | v1.42.0 | indirect | +| `google.golang.org/grpc` | v1.79.3 | indirect | +| `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` | v0.68.0 | indirect | + +**Not present in backend**: go-jose/v3, go-jose/v4, otel/sdk, pgproto3/v2, AWS SDK, otlploghttp, otlpmetrichttp. + +#### CrowdSec Binary (Dockerfile `crowdsec-builder` stage) + +Source: CrowdSec v1.7.7 `go.mod` (verified via `git clone --depth 1 --branch v1.7.7`): + +``` +crowdsec v1.7.7 + └─ pgx/v4 v4.18.2 (direct) → pgproto3/v2 v2.3.3 (indirect) ── CVE-2026-32286 + └─ aws-sdk-go-v2/service/s3 v1.87.3 (direct) ── GHSA-xmrv-pmrh-hhx2 + └─ aws-sdk-go-v2/service/cloudwatchlogs v1.57.2 (direct) ── GHSA-xmrv-pmrh-hhx2 + └─ aws-sdk-go-v2/service/kinesis v1.40.1 (direct) ── GHSA-xmrv-pmrh-hhx2 + └─ aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 (indirect) ── GHSA-xmrv-pmrh-hhx2 + └─ otel v1.39.0, otel/metric v1.39.0, otel/trace v1.39.0 (indirect) +``` + +Confirmed by Trivy image scan (`trivy-image-report.json`): pgproto3/v2 v2.3.3 flagged in `usr/local/bin/crowdsec` and `usr/local/bin/cscli`. + +#### Caddy Binary (Dockerfile `caddy-builder` stage) + +Built via xcaddy with plugins. go.mod is generated at build time. Vulnerable packages enter via: + +``` +xcaddy build (Caddy v2.11.2 + plugins) + └─ caddy-security v1.1.61 → go-jose/v3 (JWT auth stack) ── CVE-2026-34986 + └─ grpc (patched to v1.79.3 in Dockerfile) → go-jose/v4 v4.1.3 ── CVE-2026-34986 + └─ Caddy/plugins → otel/sdk v1.40.0 ── CVE-2026-39883 + └─ Caddy/plugins → otlploghttp v0.16.0, otlpmetrichttp v1.40.0, otlptracehttp v1.40.0 ── CVE-2026-39882 +``` + +--- + +## 3. Technical Specifications + +### 3.1 Backend go.mod Changes + +**File**: `backend/go.mod` (+ `backend/go.sum` auto-generated) + +```bash +cd backend + +# Upgrade grpc to v1.80.0 (security patches for transitive deps) +go get google.golang.org/grpc@v1.80.0 + +# CVE-2026-39882: OTel HTTP exporter (backend only has otlptracehttp) +go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0 + +go mod tidy +``` + +Expected `go.mod` diff: +- `google.golang.org/grpc` v1.79.3 → v1.80.0 +- `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` v1.42.0 → v1.43.0 + +### 3.2 Dockerfile — Caddy Builder Stage Patches + +**File**: `Dockerfile`, within the caddy-builder `RUN bash -c '...'` block, in the **Stage 2: Apply security patches** section. + +Add after the existing `go get golang.org/x/net@v${XNET_VERSION};` line: + +```bash +# CVE-2026-34986: go-jose JOSE/JWT validation bypass +# Fix in v3.0.5 and v4.1.4. Pin here until caddy-security ships fix. +# renovate: datasource=go depName=github.com/go-jose/go-jose/v3 +go get github.com/go-jose/go-jose/v3@v3.0.5; \ +# renovate: datasource=go depName=github.com/go-jose/go-jose/v4 +go get github.com/go-jose/go-jose/v4@v4.1.4; \ +# CVE-2026-39883: OTel SDK resource leak +# Fix in v1.43.0. Pin here until Caddy ships with updated OTel. +# renovate: datasource=go depName=go.opentelemetry.io/otel/sdk +go get go.opentelemetry.io/otel/sdk@v1.43.0; \ +# CVE-2026-39882: OTel HTTP exporter request smuggling +# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp +go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp@v0.19.0; \ +# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp +go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0; \ +# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp +go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0; \ +``` + +Update existing grpc patch line from `v1.79.3` → `v1.80.0`: + +```bash +# Before: +go get google.golang.org/grpc@v1.79.3; \ +# After: +# CVE-2026-33186: gRPC-Go auth bypass (fixed in v1.79.3) +# CVE-2026-34986: go-jose/v4 transitive fix (requires grpc >= v1.80.0) +# renovate: datasource=go depName=google.golang.org/grpc +go get google.golang.org/grpc@v1.80.0; \ +``` + +### 3.3 Dockerfile — CrowdSec Builder Stage Patches + +**File**: `Dockerfile`, within the crowdsec-builder `RUN` block that patches dependencies. + +Add after the existing `go get golang.org/x/net@v${XNET_VERSION}` line: + +```bash +# CVE-2026-32286: pgproto3/v2 buffer overflow (no v2 fix exists; bump pgx/v4 to latest patch) +# renovate: datasource=go depName=github.com/jackc/pgx/v4 +go get github.com/jackc/pgx/v4@v4.18.3 && \ +# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection +# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream +go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.8 && \ +# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs +go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.68.0 && \ +# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/kinesis +go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.5 && \ +# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3 +go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.0 && \ +``` + +CrowdSec grpc already at v1.80.0 — no change needed. + +### 3.4 Example Workflow Fix + +**File**: `.github/skills/examples/gorm-scanner-ci-workflow.yml` (line 28) + +```yaml +# Before: + go-version: "1.26.1" +# After: + go-version: "1.26.2" +``` + +### 3.5 Go Stdlib CVEs (nightly branch — no code change needed) + +The nightly workflow syncs `development → nightly` via `git merge --ff-only`. Since `development` already has Go 1.26.2 everywhere: +- Dockerfile `ARG GO_VERSION=1.26.2` ✓ +- All CI workflows `GO_VERSION: '1.26.2'` ✓ +- `backend/go.mod` `go 1.26.2` ✓ + +The next nightly run at 09:00 UTC will automatically propagate Go 1.26.2 to the nightly branch and rebuild the image. + +--- + +## 4. Implementation Plan + +### Phase 1: Playwright Tests (N/A) + +No UI/UX changes — this is a dependency-only update. Existing E2E tests validate runtime behavior. + +### Phase 2: Backend Implementation + +| Task | File(s) | Action | +|------|---------|--------| +| 2.1 | `backend/go.mod`, `backend/go.sum` | Run `go get` commands from §3.1 | +| 2.2 | Verify build | `cd backend && go build ./cmd/api` | +| 2.3 | Verify vet | `cd backend && go vet ./...` | +| 2.4 | Verify tests | `cd backend && go test ./...` | +| 2.5 | Verify vulns | `cd backend && govulncheck ./...` | + +### Phase 3: Dockerfile Implementation + +| Task | File(s) | Action | +|------|---------|--------| +| 3.1 | `Dockerfile` (caddy-builder, ~L258-280) | Add go-jose v3/v4, OTel SDK, OTel exporter patches per §3.2 | +| 3.2 | `Dockerfile` (caddy-builder, ~L270) | Update grpc patch v1.79.3 → v1.80.0 | +| 3.3 | `Dockerfile` (crowdsec-builder, ~L360-370) | Add pgx, AWS SDK patches per §3.3 | +| 3.3a | CrowdSec binaries | After patching deps, run `go build` on CrowdSec binaries before full Docker build for faster compilation feedback | +| 3.4 | `Dockerfile` | Verify `docker build .` completes successfully (amd64) | + +### Phase 4: CI / Misc Fixes + +| Task | File(s) | Action | +|------|---------|--------| +| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.2 → 1.26.2 | + +### Phase 5: Validation + +| Task | Validation | +|------|------------| +| 5.1 | `cd backend && go build ./cmd/api` — compiles cleanly | +| 5.2 | `cd backend && go test ./...` — all tests pass | +| 5.3 | `cd backend && go vet ./...` — no issues | +| 5.4 | `cd backend && govulncheck ./...` — 0 findings | +| 5.5 | `docker build -t charon:vuln-fix .` — image builds for amd64 | +| 5.6 | Trivy scan on built image: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity CRITICAL,HIGH charon:vuln-fix` — 0 HIGH (pgproto3/v2 excepted) | +| 5.7 | Container health: `docker run -d -p 8080:8080 charon:vuln-fix && curl -f http://localhost:8080/health` | +| 5.8 | E2E Playwright tests pass against rebuilt container | + +--- + +## 5. Risk Assessment + +### Low Risk + +| Change | Risk | Rationale | +|--------|------|-----------| +| `go-jose/v3` v3.0.4 → v3.0.5 | Low | Security patch release only | +| `go-jose/v4` v4.1.3 → v4.1.4 | Low | Security patch release only | +| `otel/sdk` v1.40.0 → v1.43.0 (Caddy) | Low | Minor bumps, backwards compatible | +| `otlptracehttp` v1.42.0 → v1.43.0 (backend) | Low | Minor bump | +| OTel exporters (Caddy) | Low | Minor/patch bumps | +| Go version example fix | None | Non-runtime file | + +### Medium Risk + +| Change | Risk | Mitigation | +|--------|------|------------| +| `grpc` v1.79.3 → v1.80.0 | Medium | Minor version bump. gRPC is indirect — Charon doesn't use gRPC directly. Run full test suite. Verify Caddy and CrowdSec still compile. | +| AWS SDK major bumps (s3 v1.87→v1.99, cloudwatchlogs v1.57→v1.68, kinesis v1.40→v1.43) | Medium | CrowdSec build may fail if internal APIs changed between versions. Mitigate: run `go mod tidy` after patches and verify CrowdSec binaries compile. **Note:** AWS SDK Go v2 packages use independent semver within the `v1.x.x` line — these are minor version bumps, not major API breaks. | +| `pgx/v4` v4.18.2 → v4.18.3 | Medium | Patch release should be safe. May not fully resolve pgproto3/v2 since no patched v2 exists. | + +### Known Limitation: pgproto3/v2 (CVE-2026-32286) + +The `pgproto3/v2` module has **no patched release** — the fix exists only in `pgproto3/v3` (used by `pgx/v5`). CrowdSec v1.7.7 uses `pgx/v4` which depends on `pgproto3/v2`. Remediation: + +1. Bump `pgx/v4` to v4.18.3 (latest v4 patch) — may transitively resolve the issue +2. If scanner still flags pgproto3/v2 after the bump: document as **accepted risk with upstream tracking** +3. Monitor CrowdSec releases for `pgx/v5` migration +4. Consider upgrading `CROWDSEC_VERSION` ARG if a newer CrowdSec release ships with pgx/v5 + +--- + +## 6. Acceptance Criteria + +- [ ] `cd backend && go build ./cmd/api` succeeds with zero warnings +- [ ] `cd backend && go test ./...` passes with zero failures +- [ ] `cd backend && go vet ./...` reports zero issues +- [ ] `cd backend && govulncheck ./...` reports zero findings +- [ ] Docker image builds successfully for amd64 +- [ ] Trivy/Grype scan of built image shows 0 new HIGH findings (pgproto3/v2 excepted if upstream unpatched) +- [ ] Container starts, health check passes on port 8080 +- [ ] Existing E2E Playwright tests pass against rebuilt container +- [ ] No new compile errors in Caddy or CrowdSec builder stages +- [ ] `backend/go.mod` shows updated versions for grpc, otlptracehttp + +--- + +## 7. Commit Slicing Strategy + +### Decision: Single PR + +**Rationale**: All changes are dependency version bumps with no feature or behavioral changes. They address a single concern (security vulnerability remediation) and should be reviewed and merged atomically to avoid partial-fix states. + +**Trigger reasons for single PR**: +- All changes are security patches — cannot ship partial fixes +- Changes span backend + Dockerfile + CI config — logically coupled +- No risk of one slice breaking another +- Total diff is small (go.mod/go.sum + Dockerfile patch lines + 1 YAML fix) + +### PR-1: Nightly Build Vulnerability Remediation + +**Scope**: All changes in §3.1–§3.4 + +**Files modified**: + +| File | Change Type | +|------|-------------| +| `backend/go.mod` | Dependency version bumps (grpc, otlptracehttp) | +| `backend/go.sum` | Auto-generated checksum updates | +| `Dockerfile` | Add `go get` patches in caddy-builder and crowdsec-builder stages | +| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.2 → 1.26.2 | + +**Dependencies**: None (standalone) + +**Validation gates**: +1. `go build` / `go test` / `go vet` / `govulncheck` pass +2. Docker image builds for amd64 +3. Trivy/Grype scan passes (0 new HIGH) +4. E2E tests pass + +**Rollback**: Revert PR. All changes are version pins — reverting restores previous state with no data migration needed. + +### Post-merge Actions + +1. Nightly build will automatically sync development → nightly and rebuild the image with all patches +2. Monitor next nightly scan for zero HIGH findings +3. If pgproto3/v2 still flagged: open tracking issue for CrowdSec pgx/v5 upstream migration +4. If any AWS SDK bump breaks CrowdSec compilation: pin to intermediate version and document + +--- + +## 8. CI Failure Amendment: pgx/v4 Module Path Mismatch + +**Date**: 2026-04-09 +**Failure**: PR #921 `build-and-push` job, step `crowdsec-builder 7/11` +**Error**: `go: github.com/jackc/pgx/v4@v5.9.1: invalid version: go.mod has non-.../v4 module path "github.com/jackc/pgx/v5" (and .../v4/go.mod does not exist) at revision v5.9.1` + +### Root Cause + +Dockerfile line 386 specifies `go get github.com/jackc/pgx/v4@v5.9.1`. This mixes the v4 module path with a v5 version tag. Go's semantic import versioning rejects this because tag `v5.9.1` declares module path `github.com/jackc/pgx/v5` in its go.mod. + +### Fix + +**Dockerfile line 386** — change: +```dockerfile +go get github.com/jackc/pgx/v4@v5.9.1 && \ +``` +to: +```dockerfile +go get github.com/jackc/pgx/v4@v4.18.3 && \ +``` + +No changes needed to the Renovate annotation (line 385) or the CVE comment (line 384) — both are already correct. + +### Why v4.18.3 + +- CrowdSec v1.7.7 uses `github.com/jackc/pgx/v4 v4.18.2` (direct dependency) +- v4.18.3 is the latest and likely final v4 release +- pgproto3/v2 is archived at v2.3.3 (July 2025) — no fix will be released in the v2 line +- The CVE (pgproto3/v2 buffer overflow) can only be fully resolved by CrowdSec migrating to pgx/v5 upstream +- Bumping pgx/v4 to v4.18.3 gets the latest v4 maintenance patch; the CVE remains an accepted risk per §5 + +### Validation + +The same `docker build` that previously failed at step 7/11 should now pass through the CrowdSec dependency patching stage and proceed to compilation (steps 8-11). + +--- + +## 9. Commands Reference + +```bash +# === Backend dependency upgrades === +cd /projects/Charon/backend + +go get google.golang.org/grpc@v1.80.0 +go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0 +go mod tidy + +# === Validate backend === +go build ./cmd/api +go test ./... +go vet ./... +govulncheck ./... + +# === Docker build (after Dockerfile edits) === +cd /projects/Charon +docker build -t charon:vuln-fix . + +# === Scan built image === +docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy:latest image \ + --severity CRITICAL,HIGH \ + charon:vuln-fix + +# === Quick container health check === +docker run -d --name charon-vuln-test -p 8080:8080 charon:vuln-fix +sleep 10 +curl -f http://localhost:8080/health +docker stop charon-vuln-test && docker rm charon-vuln-test +``` diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 0e956d1d..96e9eadc 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,432 +1,949 @@ -# Nightly Build Vulnerability Remediation Plan +# Custom Certificate Upload & Management -**Date**: 2026-04-09 +**Issue**: #22 — Custom Certificate Upload & Management +**Date**: 2026-04-10 **Status**: Draft — Awaiting Approval -**Scope**: Dependency security patches for 5 HIGH + 3 MEDIUM vulnerability groups -**Target**: Single PR — all changes ship together -**Archived**: Previous plan (CrowdSec Hub Bootstrapping) → `docs/plans/archive/crowdsec-hub-bootstrap-spec.md` +**Priority**: High +**Milestone**: Beta +**Labels**: high, beta, ssl +**Archived**: Previous plan (Nightly Build Vulnerability Remediation) → `docs/plans/archive/nightly-vuln-remediation-spec.md` --- -## 1. Problem Statement +## 1. Executive Summary -The Charon nightly build is failing container image vulnerability scans with **5 HIGH-severity** and **multiple MEDIUM-severity** findings. These vulnerabilities exist across three compiled binaries embedded in the container image: +Charon currently supports automatic certificate provisioning via Let's Encrypt/ZeroSSL (ACME) and has a rudimentary custom certificate upload flow (basic PEM upload with name). This plan enhances the certificate management system to support: -1. **Charon backend** (`/app/charon`) — Go binary built from `backend/go.mod` -2. **Caddy** (`/usr/bin/caddy`) — Built via xcaddy in the Dockerfile Caddy builder stage -3. **CrowdSec** (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) — Built from source in the Dockerfile CrowdSec builder stage +- **Full certificate validation pipeline** (format, chain, expiry, key matching) +- **Private key encryption at rest** using the existing `CHARON_ENCRYPTION_KEY` infrastructure +- **Multiple certificate formats** (PEM, PFX/PKCS12, DER) +- **Certificate chain/intermediate support** +- **Certificate assignment to proxy hosts** via the UI +- **Expiry warning notifications** using the existing notification infrastructure +- **Certificate export** with format conversion +- **Enhanced upload UI** with drag-and-drop, validation feedback, and chain preview -Additionally, the **nightly branch** was synced from development before the Go 1.26.2 bump landed, so the nightly image was compiled with Go 1.26.1 (confirmed in `ci_failure.log` line 55: `GO_VERSION: 1.26.1`). +### Why This Matters + +Users who bring their own certificates (enterprise CAs, internal PKI, wildcard certs from commercial providers) need a secure, validated workflow for importing, managing, and assigning certificates. Currently, private keys are stored in plaintext in the database, there is no format validation beyond basic PEM decoding, and the UI lacks chain support and export capabilities. --- -## 2. Research Findings +## 2. Current State Analysis -### 2.1 Go Version Audit +### 2.1 What Already Exists -All files on `development` / `main` already reference **Go 1.26.2**: +| Component | Status | Location | Notes | +|-----------|--------|----------|-------| +| **SSLCertificate model** | Exists | `backend/internal/models/ssl_certificate.go` | Has `Certificate`, `PrivateKey` (plaintext), `Domains`, `ExpiresAt`, `Provider` | +| **CertificateService** | Exists | `backend/internal/services/certificate_service.go` | `UploadCertificate()`, `ListCertificates()`, `DeleteCertificate()`, `IsCertificateInUse()`, disk sync for ACME | +| **CertificateHandler** | Exists | `backend/internal/api/handlers/certificate_handler.go` | `List`, `Upload`, `Delete` endpoints | +| **API routes** | Exists | `backend/internal/api/routes/routes.go:664-675` | `GET/POST/DELETE /api/v1/certificates` | +| **Frontend API client** | Exists | `frontend/src/api/certificates.ts` | `getCertificates()`, `uploadCertificate()`, `deleteCertificate()` | +| **Certificates page** | Exists | `frontend/src/pages/Certificates.tsx` | Upload dialog (name + 2 files), list view | +| **CertificateList** | Exists | `frontend/src/components/CertificateList.tsx` | Table with sort, bulk delete, status display | +| **useCertificates hook** | Exists | `frontend/src/hooks/useCertificates.ts` | React Query wrapper | +| **Caddy TLS loading** | Exists | `backend/internal/caddy/config.go:418-453` | Custom certs loaded via `LoadPEM` in TLS app | +| **Caddy types** | Exists | `backend/internal/caddy/types.go:239-266` | `TLSApp`, `CertificatesConfig`, `LoadPEMConfig` | +| **Encryption service** | Exists | `backend/internal/crypto/encryption.go` | AES-256-GCM encrypt/decrypt with `CHARON_ENCRYPTION_KEY` | +| **Key rotation** | Exists | `backend/internal/crypto/rotation_service.go` | Multi-version key rotation for DNS provider credentials | +| **Notification service** | Exists | `backend/internal/services/notification_service.go` | `SendExternal()` with event types, `Create()` for in-app | +| **ProxyHost.CertificateID** | Exists | `backend/internal/models/proxy_host.go` | FK to SSLCertificate, already used in update handler | +| **Delete E2E tests** | Exists | `tests/certificate-delete.spec.ts`, `tests/certificate-bulk-delete.spec.ts` | Delete flow E2E coverage | +| **Config tests** | Exists | `backend/internal/caddy/config_test.go:1480-1600` | Custom cert loading via Caddy tested | -| File | Current Value | Status | -|------|---------------|--------| -| `backend/go.mod` | `go 1.26.2` | ✅ Current | -| `go.work` | `go 1.26.2` | ✅ Current | -| `Dockerfile` (`ARG GO_VERSION`) | `1.26.2` | ✅ Current | -| `.github/workflows/nightly-build.yml` | `'1.26.2'` | ✅ Current | -| `.github/workflows/codecov-upload.yml` | `'1.26.2'` | ✅ Current | -| `.github/workflows/quality-checks.yml` | `'1.26.2'` | ✅ Current | -| `.github/workflows/codeql.yml` | `'1.26.2'` | ✅ Current | -| `.github/workflows/benchmark.yml` | `'1.26.2'` | ✅ Current | -| `.github/workflows/release-goreleaser.yml` | `'1.26.2'` | ✅ Current | -| `.github/workflows/e2e-tests-split.yml` | `'1.26.2'` | ✅ Current | -| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | `'1.26.1'` | ❌ **Stale** | -| `scripts/install-go-1.26.0.sh` | `1.26.0` | ⚠️ Old install script (not used in CI/Docker builds) | +### 2.2 Gaps to Address -**Root Cause of Go stdlib CVEs**: The nightly branch's last sync predated the 1.26.2 bump. The next nightly sync from development will propagate 1.26.2 automatically. The only file requiring a fix is the example workflow. - -### 2.2 Vulnerability Inventory - -#### HIGH Severity (must fix — merge-blocking) - -| # | CVE / GHSA | Package | Current | Fix | Binary | Dep Type | -|---|-----------|---------|---------|-----|--------|----------| -| 1 | CVE-2026-39883 | `go.opentelemetry.io/otel/sdk` | v1.40.0 | v1.43.0 | Caddy | Transitive (Caddy plugins → otelhttp → otel/sdk) | -| 2 | CVE-2026-34986 | `github.com/go-jose/go-jose/v3` | v3.0.4 | **v3.0.5** | Caddy | Transitive (caddy-security → JWT/JOSE stack) | -| 3 | CVE-2026-34986 | `github.com/go-jose/go-jose/v4` | v4.1.3 | **v4.1.4** | Caddy | Transitive (grpc v1.79.3 → go-jose/v4) | -| 4 | CVE-2026-32286 | `github.com/jackc/pgproto3/v2` | v2.3.3 | pgx/v4 v4.18.3 ¹ | CrowdSec | Transitive (CrowdSec → pgx/v4 v4.18.2 → pgproto3/v2) | - -¹ pgproto3/v2 has **no patched release**. Fix requires upstream migration to pgx/v5 (uses pgproto3/v3). See §5 Risk Assessment. - -#### MEDIUM Severity (fix in same pass) - -| # | CVE / GHSA | Package(s) | Current | Fix | Binary | Dep Type | -|---|-----------|------------|---------|-----|--------|----------| -| 5 | GHSA-xmrv-pmrh-hhx2 | AWS SDK v2: `eventstream` v1.7.1, `cloudwatchlogs` v1.57.2, `kinesis` v1.40.1, `s3` v1.87.3 | See left | Bump all | CrowdSec | Direct deps of CrowdSec v1.7.7 | -| 6 | CVE-2026-32281, -32288, -32289 | Go stdlib | 1.26.1 | **1.26.2** | All (nightly image) | Toolchain | -| 7 | CVE-2026-39882 | OTel HTTP exporters: `otlploghttp` v0.16.0, `otlpmetrichttp` v1.40.0, `otlptracehttp` v1.40.0 | See left | Bump all | Caddy | Transitive (Caddy plugins → OTel exporters) | - -### 2.3 Dependency Chain Analysis - -#### Backend (`backend/go.mod`) - -``` -charon/backend (direct) - └─ docker/docker v28.5.2+incompatible (direct) - └─ otelhttp v0.68.0 (indirect) - └─ otel/sdk v1.43.0 (indirect) — already at latest - └─ grpc v1.79.3 (indirect) - └─ otlptracehttp v1.42.0 (indirect) ── CVE-2026-39882 -``` - -Backend resolved versions (verified via `go list -m -json`): - -| Package | Version | Type | -|---------|---------|------| -| `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` | v1.42.0 | indirect | -| `google.golang.org/grpc` | v1.79.3 | indirect | -| `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` | v0.68.0 | indirect | - -**Not present in backend**: go-jose/v3, go-jose/v4, otel/sdk, pgproto3/v2, AWS SDK, otlploghttp, otlpmetrichttp. - -#### CrowdSec Binary (Dockerfile `crowdsec-builder` stage) - -Source: CrowdSec v1.7.7 `go.mod` (verified via `git clone --depth 1 --branch v1.7.7`): - -``` -crowdsec v1.7.7 - └─ pgx/v4 v4.18.2 (direct) → pgproto3/v2 v2.3.3 (indirect) ── CVE-2026-32286 - └─ aws-sdk-go-v2/service/s3 v1.87.3 (direct) ── GHSA-xmrv-pmrh-hhx2 - └─ aws-sdk-go-v2/service/cloudwatchlogs v1.57.2 (direct) ── GHSA-xmrv-pmrh-hhx2 - └─ aws-sdk-go-v2/service/kinesis v1.40.1 (direct) ── GHSA-xmrv-pmrh-hhx2 - └─ aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 (indirect) ── GHSA-xmrv-pmrh-hhx2 - └─ otel v1.39.0, otel/metric v1.39.0, otel/trace v1.39.0 (indirect) -``` - -Confirmed by Trivy image scan (`trivy-image-report.json`): pgproto3/v2 v2.3.3 flagged in `usr/local/bin/crowdsec` and `usr/local/bin/cscli`. - -#### Caddy Binary (Dockerfile `caddy-builder` stage) - -Built via xcaddy with plugins. go.mod is generated at build time. Vulnerable packages enter via: - -``` -xcaddy build (Caddy v2.11.2 + plugins) - └─ caddy-security v1.1.61 → go-jose/v3 (JWT auth stack) ── CVE-2026-34986 - └─ grpc (patched to v1.79.3 in Dockerfile) → go-jose/v4 v4.1.3 ── CVE-2026-34986 - └─ Caddy/plugins → otel/sdk v1.40.0 ── CVE-2026-39883 - └─ Caddy/plugins → otlploghttp v0.16.0, otlpmetrichttp v1.40.0, otlptracehttp v1.40.0 ── CVE-2026-39882 -``` +| Gap | Severity | Description | +|-----|----------|-------------| +| **Private keys stored in plaintext** | CRITICAL | `PrivateKey` field in `SSLCertificate` is stored as raw PEM. Must encrypt at rest. | +| **🔴 Active private key disclosure** | CRITICAL | The Upload handler (`certificate_handler.go:137`) returns the full `*SSLCertificate` struct via `c.JSON(http.StatusCreated, cert)`. Because the model has `json:"private_key"`, the raw PEM private key is sent to the client in every upload response. **This is an active vulnerability in production.** Commit 1 fixes this by changing the tag to `json:"-"`. | +| **Unsafe file read pattern** | HIGH | `certificate_handler.go:109` uses `certSrc.Read(certBytes)` which may return partial reads. Must use `io.ReadAll(io.LimitReader(src, 1<<20))` for safe, bounded reads. Commit 2 (task 2.1) fixes this. | +| **No certificate chain validation** | HIGH | Upload accepts any PEM without verifying chain or key-cert match. | +| **No format conversion** | HIGH | Only PEM is accepted. PFX/DER users cannot upload. | +| **No expiry warnings** | HIGH | No scheduled check or notification for upcoming certificate expiry. | +| **No certificate export** | MEDIUM | Users cannot download certs they uploaded (for migration, backup). | +| **No chain/intermediate storage** | MEDIUM | Model has single `Certificate` field; no dedicated chain field. | +| **No certificate detail view** | MEDIUM | Frontend shows only list; no detail/expand view showing SANs, issuer chain, fingerprint. | +| **`CertificateInfo` leaks numeric ID** | HIGH | `CertificateInfo.ID uint json:"id,omitempty"` in service — violates GORM security rules. | +| **Delete uses numeric ID in URL** | HIGH | `DELETE /certificates/:id` uses numeric ID; should use UUID. | --- -## 3. Technical Specifications +## 3. Requirements (EARS Notation) -### 3.1 Backend go.mod Changes +### 3.1 Certificate Upload -**File**: `backend/go.mod` (+ `backend/go.sum` auto-generated) +| ID | Requirement | +|----|-------------| +| R-UP-01 | WHEN a user submits a certificate upload form with valid PEM, PFX, or DER files, THE SYSTEM SHALL parse and validate the certificate, encrypt the private key at rest, store the certificate in the database, and return the certificate metadata. | +| R-UP-02 | WHEN a user uploads a PFX/PKCS12 file with a password, THE SYSTEM SHALL decrypt the PFX, extract the certificate chain and private key, convert to PEM, and store them. | +| R-UP-03 | WHEN a user uploads a DER-encoded certificate, THE SYSTEM SHALL convert it to PEM format before storage. | +| R-UP-04 | WHEN a certificate upload contains intermediate certificates, THE SYSTEM SHALL store the full chain in order (leaf then intermediate then root). | +| R-UP-05 | IF a user uploads a certificate whose private key does not match the certificate's public key, THEN THE SYSTEM SHALL reject the upload with a descriptive error. | +| R-UP-06 | IF a user uploads an expired certificate, THEN THE SYSTEM SHALL warn but still allow storage (with status "expired"). | +| R-UP-07 | THE SYSTEM SHALL enforce a maximum upload size of 1MB per file to prevent abuse. | +| R-UP-08 | IF a user uploads a file that is not a valid certificate or key format, THEN THE SYSTEM SHALL reject the upload with a descriptive error. | -```bash -cd backend +### 3.2 Certificate Validation -# Upgrade grpc to v1.80.0 (security patches for transitive deps) -go get google.golang.org/grpc@v1.80.0 +| ID | Requirement | +|----|-------------| +| R-VL-01 | WHEN a certificate is uploaded, THE SYSTEM SHALL verify the X.509 structure, extract the Common Name, SANs, issuer, serial number, and expiry date. | +| R-VL-02 | WHEN a certificate chain is provided, THE SYSTEM SHALL verify that each certificate in the chain is signed by the next certificate (leaf then intermediate then root). | +| R-VL-03 | WHEN a private key is uploaded, THE SYSTEM SHALL verify that the key matches the certificate's public key by comparing the public key modulus. | -# CVE-2026-39882: OTel HTTP exporter (backend only has otlptracehttp) -go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0 +### 3.3 Private Key Security -go mod tidy -``` +| ID | Requirement | +|----|-------------| +| R-PK-01 | THE SYSTEM SHALL encrypt all custom certificate private keys at rest using AES-256-GCM via the existing `CHARON_ENCRYPTION_KEY`. | +| R-PK-02 | THE SYSTEM SHALL decrypt private keys only when serving them to Caddy for TLS or when exporting. | +| R-PK-03 | THE SYSTEM SHALL never return private key content in API list/get responses. | +| R-PK-04 | WHEN `CHARON_ENCRYPTION_KEY` is rotated, THE SYSTEM SHALL re-encrypt all stored private keys during the rotation process. | -Expected `go.mod` diff: -- `google.golang.org/grpc` v1.79.3 → v1.80.0 -- `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` v1.42.0 → v1.43.0 +### 3.4 Certificate Assignment -### 3.2 Dockerfile — Caddy Builder Stage Patches +| ID | Requirement | +|----|-------------| +| R-AS-01 | WHEN a user assigns a custom certificate to a proxy host, THE SYSTEM SHALL update the proxy host's `CertificateID` and reload Caddy configuration. | +| R-AS-02 | WHEN a custom certificate is assigned to a proxy host, THE SYSTEM SHALL use `LoadPEM` in Caddy's TLS app to serve the certificate for that domain. | +| R-AS-03 | THE SYSTEM SHALL prevent deletion of certificates that are assigned to one or more proxy hosts. | -**File**: `Dockerfile`, within the caddy-builder `RUN bash -c '...'` block, in the **Stage 2: Apply security patches** section. +### 3.5 Expiry Warnings -Add after the existing `go get golang.org/x/net@v${XNET_VERSION};` line: +| ID | Requirement | +|----|-------------| +| R-EX-01 | THE SYSTEM SHALL check certificate expiry dates daily via a background scheduler. | +| R-EX-02 | WHEN a custom certificate will expire within 30 days, THE SYSTEM SHALL create an in-app warning notification. | +| R-EX-03 | WHEN a custom certificate will expire within 30 days AND external notification providers are configured, THE SYSTEM SHALL send an external notification (rate-limited to once per 24 hours per certificate). | +| R-EX-04 | WHEN a custom certificate has expired, THE SYSTEM SHALL update its status to "expired" and send a critical notification. | -```bash -# CVE-2026-34986: go-jose JOSE/JWT validation bypass -# Fix in v3.0.5 and v4.1.4. Pin here until caddy-security ships fix. -# renovate: datasource=go depName=github.com/go-jose/go-jose/v3 -go get github.com/go-jose/go-jose/v3@v3.0.5; \ -# renovate: datasource=go depName=github.com/go-jose/go-jose/v4 -go get github.com/go-jose/go-jose/v4@v4.1.4; \ -# CVE-2026-39883: OTel SDK resource leak -# Fix in v1.43.0. Pin here until Caddy ships with updated OTel. -# renovate: datasource=go depName=go.opentelemetry.io/otel/sdk -go get go.opentelemetry.io/otel/sdk@v1.43.0; \ -# CVE-2026-39882: OTel HTTP exporter request smuggling -# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp -go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp@v0.19.0; \ -# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp -go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0; \ -# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp -go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0; \ -``` +### 3.6 Certificate Export -Update existing grpc patch line from `v1.79.3` → `v1.80.0`: +| ID | Requirement | +|----|-------------| +| R-EXP-01 | WHEN a user requests a certificate export, THE SYSTEM SHALL provide the certificate and chain in the requested format (PEM, PFX, DER). | +| R-EXP-02 | WHEN exporting in PFX format, THE SYSTEM SHALL prompt for a password and encrypt the PFX bundle. | +| R-EXP-03 | THE SYSTEM SHALL require authentication for all export operations. | +| R-EXP-04 | THE SYSTEM SHALL never include the private key in export unless explicitly requested with re-authentication. | -```bash -# Before: -go get google.golang.org/grpc@v1.79.3; \ -# After: -# CVE-2026-33186: gRPC-Go auth bypass (fixed in v1.79.3) -# CVE-2026-34986: go-jose/v4 transitive fix (requires grpc >= v1.80.0) -# renovate: datasource=go depName=google.golang.org/grpc -go get google.golang.org/grpc@v1.80.0; \ -``` +### 3.7 UI/UX -### 3.3 Dockerfile — CrowdSec Builder Stage Patches - -**File**: `Dockerfile`, within the crowdsec-builder `RUN` block that patches dependencies. - -Add after the existing `go get golang.org/x/net@v${XNET_VERSION}` line: - -```bash -# CVE-2026-32286: pgproto3/v2 buffer overflow (no v2 fix exists; bump pgx/v4 to latest patch) -# renovate: datasource=go depName=github.com/jackc/pgx/v4 -go get github.com/jackc/pgx/v4@v4.18.3 && \ -# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection -# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream -go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.8 && \ -# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs -go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.68.0 && \ -# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/kinesis -go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.5 && \ -# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3 -go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.0 && \ -``` - -CrowdSec grpc already at v1.80.0 — no change needed. - -### 3.4 Example Workflow Fix - -**File**: `.github/skills/examples/gorm-scanner-ci-workflow.yml` (line 28) - -```yaml -# Before: - go-version: "1.26.1" -# After: - go-version: "1.26.2" -``` - -### 3.5 Go Stdlib CVEs (nightly branch — no code change needed) - -The nightly workflow syncs `development → nightly` via `git merge --ff-only`. Since `development` already has Go 1.26.2 everywhere: -- Dockerfile `ARG GO_VERSION=1.26.2` ✓ -- All CI workflows `GO_VERSION: '1.26.2'` ✓ -- `backend/go.mod` `go 1.26.2` ✓ - -The next nightly run at 09:00 UTC will automatically propagate Go 1.26.2 to the nightly branch and rebuild the image. +| ID | Requirement | +|----|-------------| +| R-UI-01 | THE SYSTEM SHALL support drag-and-drop file upload for certificate and key files. | +| R-UI-02 | WHEN a certificate is uploaded, THE SYSTEM SHALL display a preview showing: domains (CN + SANs), issuer, expiry date, chain depth, and key match status. | +| R-UI-03 | THE SYSTEM SHALL display an expiry warning badge on certificates expiring within 30 days. | +| R-UI-04 | THE SYSTEM SHALL provide a certificate detail view showing full metadata including fingerprint, serial number, issuer chain, and assigned hosts. | --- -## 4. Implementation Plan +## 4. Technical Architecture -### Phase 1: Playwright Tests (N/A) +### 4.1 Database Model Changes -No UI/UX changes — this is a dependency-only update. Existing E2E tests validate runtime behavior. +#### Modified: `SSLCertificate` (`backend/internal/models/ssl_certificate.go`) -### Phase 2: Backend Implementation - -| Task | File(s) | Action | -|------|---------|--------| -| 2.1 | `backend/go.mod`, `backend/go.sum` | Run `go get` commands from §3.1 | -| 2.2 | Verify build | `cd backend && go build ./cmd/api` | -| 2.3 | Verify vet | `cd backend && go vet ./...` | -| 2.4 | Verify tests | `cd backend && go test ./...` | -| 2.5 | Verify vulns | `cd backend && govulncheck ./...` | - -### Phase 3: Dockerfile Implementation - -| Task | File(s) | Action | -|------|---------|--------| -| 3.1 | `Dockerfile` (caddy-builder, ~L258-280) | Add go-jose v3/v4, OTel SDK, OTel exporter patches per §3.2 | -| 3.2 | `Dockerfile` (caddy-builder, ~L270) | Update grpc patch v1.79.3 → v1.80.0 | -| 3.3 | `Dockerfile` (crowdsec-builder, ~L360-370) | Add pgx, AWS SDK patches per §3.3 | -| 3.3a | CrowdSec binaries | After patching deps, run `go build` on CrowdSec binaries before full Docker build for faster compilation feedback | -| 3.4 | `Dockerfile` | Verify `docker build .` completes successfully (amd64) | - -### Phase 4: CI / Misc Fixes - -| Task | File(s) | Action | -|------|---------|--------| -| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.2 → 1.26.2 | - -### Phase 5: Validation - -| Task | Validation | -|------|------------| -| 5.1 | `cd backend && go build ./cmd/api` — compiles cleanly | -| 5.2 | `cd backend && go test ./...` — all tests pass | -| 5.3 | `cd backend && go vet ./...` — no issues | -| 5.4 | `cd backend && govulncheck ./...` — 0 findings | -| 5.5 | `docker build -t charon:vuln-fix .` — image builds for amd64 | -| 5.6 | Trivy scan on built image: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity CRITICAL,HIGH charon:vuln-fix` — 0 HIGH (pgproto3/v2 excepted) | -| 5.7 | Container health: `docker run -d -p 8080:8080 charon:vuln-fix && curl -f http://localhost:8080/health` | -| 5.8 | E2E Playwright tests pass against rebuilt container | - ---- - -## 5. Risk Assessment - -### Low Risk - -| Change | Risk | Rationale | -|--------|------|-----------| -| `go-jose/v3` v3.0.4 → v3.0.5 | Low | Security patch release only | -| `go-jose/v4` v4.1.3 → v4.1.4 | Low | Security patch release only | -| `otel/sdk` v1.40.0 → v1.43.0 (Caddy) | Low | Minor bumps, backwards compatible | -| `otlptracehttp` v1.42.0 → v1.43.0 (backend) | Low | Minor bump | -| OTel exporters (Caddy) | Low | Minor/patch bumps | -| Go version example fix | None | Non-runtime file | - -### Medium Risk - -| Change | Risk | Mitigation | -|--------|------|------------| -| `grpc` v1.79.3 → v1.80.0 | Medium | Minor version bump. gRPC is indirect — Charon doesn't use gRPC directly. Run full test suite. Verify Caddy and CrowdSec still compile. | -| AWS SDK major bumps (s3 v1.87→v1.99, cloudwatchlogs v1.57→v1.68, kinesis v1.40→v1.43) | Medium | CrowdSec build may fail if internal APIs changed between versions. Mitigate: run `go mod tidy` after patches and verify CrowdSec binaries compile. **Note:** AWS SDK Go v2 packages use independent semver within the `v1.x.x` line — these are minor version bumps, not major API breaks. | -| `pgx/v4` v4.18.2 → v4.18.3 | Medium | Patch release should be safe. May not fully resolve pgproto3/v2 since no patched v2 exists. | - -### Known Limitation: pgproto3/v2 (CVE-2026-32286) - -The `pgproto3/v2` module has **no patched release** — the fix exists only in `pgproto3/v3` (used by `pgx/v5`). CrowdSec v1.7.7 uses `pgx/v4` which depends on `pgproto3/v2`. Remediation: - -1. Bump `pgx/v4` to v4.18.3 (latest v4 patch) — may transitively resolve the issue -2. If scanner still flags pgproto3/v2 after the bump: document as **accepted risk with upstream tracking** -3. Monitor CrowdSec releases for `pgx/v5` migration -4. Consider upgrading `CROWDSEC_VERSION` ARG if a newer CrowdSec release ships with pgx/v5 - ---- - -## 6. Acceptance Criteria - -- [ ] `cd backend && go build ./cmd/api` succeeds with zero warnings -- [ ] `cd backend && go test ./...` passes with zero failures -- [ ] `cd backend && go vet ./...` reports zero issues -- [ ] `cd backend && govulncheck ./...` reports zero findings -- [ ] Docker image builds successfully for amd64 -- [ ] Trivy/Grype scan of built image shows 0 new HIGH findings (pgproto3/v2 excepted if upstream unpatched) -- [ ] Container starts, health check passes on port 8080 -- [ ] Existing E2E Playwright tests pass against rebuilt container -- [ ] No new compile errors in Caddy or CrowdSec builder stages -- [ ] `backend/go.mod` shows updated versions for grpc, otlptracehttp - ---- - -## 7. Commit Slicing Strategy - -### Decision: Single PR - -**Rationale**: All changes are dependency version bumps with no feature or behavioral changes. They address a single concern (security vulnerability remediation) and should be reviewed and merged atomically to avoid partial-fix states. - -**Trigger reasons for single PR**: -- All changes are security patches — cannot ship partial fixes -- Changes span backend + Dockerfile + CI config — logically coupled -- No risk of one slice breaking another -- Total diff is small (go.mod/go.sum + Dockerfile patch lines + 1 YAML fix) - -### PR-1: Nightly Build Vulnerability Remediation - -**Scope**: All changes in §3.1–§3.4 - -**Files modified**: - -| File | Change Type | -|------|-------------| -| `backend/go.mod` | Dependency version bumps (grpc, otlptracehttp) | -| `backend/go.sum` | Auto-generated checksum updates | -| `Dockerfile` | Add `go get` patches in caddy-builder and crowdsec-builder stages | -| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.2 → 1.26.2 | - -**Dependencies**: None (standalone) - -**Validation gates**: -1. `go build` / `go test` / `go vet` / `govulncheck` pass -2. Docker image builds for amd64 -3. Trivy/Grype scan passes (0 new HIGH) -4. E2E tests pass - -**Rollback**: Revert PR. All changes are version pins — reverting restores previous state with no data migration needed. - -### Post-merge Actions - -1. Nightly build will automatically sync development → nightly and rebuild the image with all patches -2. Monitor next nightly scan for zero HIGH findings -3. If pgproto3/v2 still flagged: open tracking issue for CrowdSec pgx/v5 upstream migration -4. If any AWS SDK bump breaks CrowdSec compilation: pin to intermediate version and document - ---- - -## 8. CI Failure Amendment: pgx/v4 Module Path Mismatch - -**Date**: 2026-04-09 -**Failure**: PR #921 `build-and-push` job, step `crowdsec-builder 7/11` -**Error**: `go: github.com/jackc/pgx/v4@v5.9.1: invalid version: go.mod has non-.../v4 module path "github.com/jackc/pgx/v5" (and .../v4/go.mod does not exist) at revision v5.9.1` - -### Root Cause - -Dockerfile line 386 specifies `go get github.com/jackc/pgx/v4@v5.9.1`. This mixes the v4 module path with a v5 version tag. Go's semantic import versioning rejects this because tag `v5.9.1` declares module path `github.com/jackc/pgx/v5` in its go.mod. - -### Fix - -**Dockerfile line 386** — change: -```dockerfile -go get github.com/jackc/pgx/v4@v5.9.1 && \ -``` -to: -```dockerfile -go get github.com/jackc/pgx/v4@v4.18.3 && \ +```go +type SSLCertificate struct { + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Name string `json:"name" gorm:"index"` + Provider string `json:"provider" gorm:"index"` + Domains string `json:"domains" gorm:"index"` + CommonName string `json:"common_name"` // NEW + Certificate string `json:"-" gorm:"type:text"` // CHANGED: hide from JSON + CertificateChain string `json:"-" gorm:"type:text"` // NEW + PrivateKeyEncrypted string `json:"-" gorm:"column:private_key_enc;type:text"` // NEW + PrivateKey string `json:"-" gorm:"-"` // CHANGED: json:"-" fixes active private key disclosure (was json:"private_key"), gorm:"-" excludes from queries (column kept but values cleared) + KeyVersion int `json:"-" gorm:"default:1"` // NEW + Fingerprint string `json:"fingerprint"` // NEW + SerialNumber string `json:"serial_number"` // NEW + IssuerOrg string `json:"issuer_org"` // NEW + KeyType string `json:"key_type"` // NEW — see KeyType values below + ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"` + NotBefore *time.Time `json:"not_before,omitempty"` // NEW + AutoRenew bool `json:"auto_renew" gorm:"default:false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} ``` -No changes needed to the Renovate annotation (line 385) or the CVE comment (line 384) — both are already correct. +**`KeyType` enum values**: `RSA-2048`, `RSA-4096`, `ECDSA-P256`, `ECDSA-P384`, `Ed25519`. Derived from the parsed private key at upload time. -### Why v4.18.3 +**Migration strategy**: Add new columns with defaults. Migrate existing plaintext `PrivateKey` data to `PrivateKeyEncrypted` via a dedicated migration step. After migration, clear `private_key` values (set to empty string) but **do NOT drop the column** — SQLite < 3.35.0 does not support `ALTER TABLE DROP COLUMN`, and GORM's `DropColumn` has varying support. Add `gorm:"-"` tag to the `PrivateKey` field so GORM ignores it in all queries. -- CrowdSec v1.7.7 uses `github.com/jackc/pgx/v4 v4.18.2` (direct dependency) -- v4.18.3 is the latest and likely final v4 release -- pgproto3/v2 is archived at v2.3.3 (July 2025) — no fix will be released in the v2 line -- The CVE (pgproto3/v2 buffer overflow) can only be fully resolved by CrowdSec migrating to pgx/v5 upstream -- Bumping pgx/v4 to v4.18.3 gets the latest v4 maintenance patch; the CVE remains an accepted risk per §5 +**Migration verification criteria**: No rows where `private_key != '' AND private_key_enc == ''`. -### Validation +**Follow-up task** (outside this feature's 4 PRs): Drop `private_key` column in a future release once all deployments are confirmed migrated and SQLite version requirements are established. -The same `docker build` that previously failed at step 7/11 should now pass through the CrowdSec dependency patching stage and proceed to compilation (steps 8-11). +#### Modified: `CertificateInfo` (`backend/internal/services/certificate_service.go`) + +```go +type CertificateInfo struct { + UUID string `json:"uuid"` + Name string `json:"name,omitempty"` + CommonName string `json:"common_name,omitempty"` + Domains string `json:"domains"` + Issuer string `json:"issuer"` + IssuerOrg string `json:"issuer_org,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + KeyType string `json:"key_type,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + NotBefore time.Time `json:"not_before,omitempty"` + Status string `json:"status"` + Provider string `json:"provider"` + ChainDepth int `json:"chain_depth,omitempty"` + HasKey bool `json:"has_key"` + InUse bool `json:"in_use"` +} +``` + +### 4.2 API Endpoints + +All endpoints under `/api/v1` require authentication (existing middleware). + +#### Existing (Modified) + +| Method | Path | Changes | +|--------|------|---------| +| `GET` | `/certificates` | Response uses `CertificateInfo` (UUID only, no numeric ID, new metadata fields) | +| `POST` | `/certificates` | Accept PEM, PFX, DER; encrypt private key; validate chain; return `CertificateInfo` | +| `DELETE` | `/certificates/:uuid` | CHANGED: Use UUID param instead of numeric ID | + +#### UUID-to-uint Resolution for Certificate Assignment + +The `ProxyHost.CertificateID` field is `*uint` — this **will not change** to UUID. It remains a numeric foreign key. When the certificate assignment endpoint receives a certificate UUID (from the UI/API), the handler **must resolve UUID → numeric ID** via a DB lookup (`SELECT id FROM ssl_certificates WHERE uuid = ?`) before setting `ProxyHost.CertificateID`. Implementers must NOT attempt to change the FK type to UUID. + +#### New Endpoints + +| Method | Path | Description | Request | Response | +|--------|------|-------------|---------|----------| +| `GET` | `/certificates/:uuid` | Get certificate detail | — | `CertificateDetail` (full metadata, chain info, assigned hosts) | +| `POST` | `/certificates/:uuid/export` | Export certificate | JSON body (format, include_key, pfx_password, password) | Binary file download | +| `PUT` | `/certificates/:uuid` | Update certificate metadata (name) | JSON body (name) | `CertificateInfo` | +| `POST` | `/certificates/validate` | Validate certificate without storing | Multipart (same as upload) | `ValidationResult` | + +#### Request/Response Schemas + +**Upload Request** (`POST /certificates`) — Multipart form: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Display name | +| `certificate_file` | file | Yes | Certificate file (.pem, .crt, .cer, .pfx, .p12, .der) | +| `key_file` | file | Conditional | Private key file (required for PEM/DER; not needed for PFX) | +| `chain_file` | file | No | Intermediate chain file (PEM) | +| `pfx_password` | string | Conditional | Password for PFX decryption | + +**Upload Response** (`201 Created`): + +```json +{ + "uuid": "a1b2c3d4-...", + "name": "My Wildcard Cert", + "common_name": "*.example.com", + "domains": "*.example.com,example.com", + "issuer": "custom", + "issuer_org": "DigiCert Inc", + "fingerprint": "AB:CD:EF:...", + "serial_number": "03:A1:...", + "key_type": "RSA-2048", + "expires_at": "2027-04-10T00:00:00Z", + "not_before": "2026-04-10T00:00:00Z", + "status": "valid", + "provider": "custom", + "chain_depth": 2, + "has_key": true, + "in_use": false +} +``` + +**Certificate Detail Response** (`GET /certificates/:uuid`): + +```json +{ + "uuid": "a1b2c3d4-...", + "name": "My Wildcard Cert", + "common_name": "*.example.com", + "domains": "*.example.com,example.com", + "issuer": "custom", + "issuer_org": "DigiCert Inc", + "fingerprint": "AB:CD:EF:...", + "serial_number": "03:A1:...", + "key_type": "RSA-2048", + "expires_at": "2027-04-10T00:00:00Z", + "not_before": "2026-04-10T00:00:00Z", + "status": "valid", + "provider": "custom", + "chain_depth": 2, + "has_key": true, + "in_use": true, + "assigned_hosts": [ + {"uuid": "host-uuid-1", "name": "My App", "domain_names": "app.example.com"} + ], + "chain": [ + {"subject": "*.example.com", "issuer": "DigiCert SHA2 Extended Validation Server CA", "expires_at": "2027-04-10T00:00:00Z"}, + {"subject": "DigiCert SHA2 Extended Validation Server CA", "issuer": "DigiCert Global Root CA", "expires_at": "2031-11-10T00:00:00Z"} + ], + "auto_renew": false, + "created_at": "2026-04-10T12:00:00Z", + "updated_at": "2026-04-10T12:00:00Z" +} +``` + +**Export Request** (`POST /certificates/:uuid/export`): + +```json +{ + "format": "pem", + "include_key": true, + "pfx_password": "optional-for-pfx", + "password": "current-user-password" +} +``` + +**R-EXP-04 Re-authentication Design**: When `include_key: true` is set, the request body **must** include the `password` field containing the current user's password. The export handler validates the password against the authenticated user's stored credentials before decrypting and returning the private key. If the password is missing or incorrect, the endpoint returns `403 Forbidden`. This prevents key exfiltration via stolen session tokens. + +```json +// Example: export without key (no password required) +{ + "format": "pem", + "include_key": false +} + +// Example: export with key (password confirmation required) +{ + "format": "pem", + "include_key": true, + "password": "MyCurrentPassword123" +} +``` + +**Validation Response** (`POST /certificates/validate`): + +```json +{ + "valid": true, + "common_name": "*.example.com", + "domains": ["*.example.com", "example.com"], + "issuer_org": "DigiCert Inc", + "expires_at": "2027-04-10T00:00:00Z", + "key_match": true, + "chain_valid": true, + "chain_depth": 2, + "warnings": ["Certificate expires in 365 days"], + "errors": [] +} +``` + +### 4.3 Service Layer Changes + +#### Modified: `CertificateService` (`backend/internal/services/certificate_service.go`) + +New/modified function signatures: + +```go +// NewCertificateService — MODIFIED: add encryption service dependency +func NewCertificateService(dataDir string, db *gorm.DB, encSvc *crypto.EncryptionService) *CertificateService + +// UploadCertificate — MODIFIED: accepts parsed content, validates, encrypts key +func (s *CertificateService) UploadCertificate(name string, certPEM string, keyPEM string, chainPEM string) (*CertificateInfo, error) + +// GetCertificate — NEW: get single certificate detail by UUID +func (s *CertificateService) GetCertificate(uuid string) (*CertificateDetail, error) + +// UpdateCertificate — NEW: update metadata (name) +func (s *CertificateService) UpdateCertificate(uuid string, name string) (*CertificateInfo, error) + +// DeleteCertificate — MODIFIED: accept UUID instead of numeric ID +func (s *CertificateService) DeleteCertificate(uuid string) error + +// IsCertificateInUse — MODIFIED: accept UUID instead of numeric ID +func (s *CertificateService) IsCertificateInUse(uuid string) (bool, error) + +// ExportCertificate — NEW: export cert in requested format +func (s *CertificateService) ExportCertificate(uuid string, format string, includeKey bool) ([]byte, string, error) + +// ValidateCertificate — NEW: validate without storing +func (s *CertificateService) ValidateCertificate(certPEM string, keyPEM string, chainPEM string) (*ValidationResult, error) + +// GetDecryptedPrivateKey — NEW: internal only, decrypt key for Caddy/export +func (s *CertificateService) GetDecryptedPrivateKey(cert *models.SSLCertificate) (string, error) + +// CheckExpiringCertificates — NEW: called by scheduler +func (s *CertificateService) CheckExpiringCertificates() ([]CertificateInfo, error) + +// MigratePrivateKeys — NEW: one-time migration from plaintext to encrypted +func (s *CertificateService) MigratePrivateKeys() error +``` + +#### New: `CertificateValidator` (`backend/internal/services/certificate_validator.go`) + +```go +// ParseCertificateInput handles PEM, PFX, and DER input parsing +func ParseCertificateInput(certData []byte, keyData []byte, chainData []byte, pfxPassword string) (*ParsedCertificate, error) + +// ValidateKeyMatch checks that the private key matches the certificate public key +func ValidateKeyMatch(cert *x509.Certificate, key crypto.PrivateKey) error + +// ValidateChain verifies the certificate chain from leaf to root. +// Uses x509.Certificate.Verify() with an intermediate cert pool to validate +// the chain against system roots (or provided root certificates). +func ValidateChain(leaf *x509.Certificate, intermediates []*x509.Certificate) error + +// DetectFormat determines the certificate format from file content +func DetectFormat(data []byte) (string, error) + +// ConvertDERToPEM converts DER-encoded certificate to PEM +func ConvertDERToPEM(derData []byte) (string, error) + +// ConvertPFXToPEM extracts cert, key, and chain from PFX/PKCS12 +func ConvertPFXToPEM(pfxData []byte, password string) (certPEM string, keyPEM string, chainPEM string, err error) + +// ConvertPEMToPFX bundles cert, key, chain into PFX +func ConvertPEMToPFX(certPEM string, keyPEM string, chainPEM string, password string) ([]byte, error) + +// ConvertPEMToDER converts PEM certificate to DER +func ConvertPEMToDER(certPEM string) ([]byte, error) + +// ExtractCertificateMetadata extracts fingerprint, serial, issuer, key type, etc. +func ExtractCertificateMetadata(cert *x509.Certificate) *CertificateMetadata +``` + +### 4.4 Caddy Integration Changes + +#### Modified: Config Generation (`backend/internal/caddy/config.go`) + +The existing custom certificate loading logic (lines 418-453) needs modification to: + +1. **Decrypt private keys** before passing to Caddy's `LoadPEM` +2. **Include certificate chain** in the `Certificate` field (full PEM chain) +3. **Add TLS automation policy** with `skip` for custom cert domains (prevent ACME from trying to issue for those domains) + +Updated custom cert loading block: + +```go +for _, cert := range customCerts { + if cert.Certificate == "" || cert.PrivateKeyEncrypted == "" { + logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing data, skipping") + continue + } + + decryptedKey, err := encSvc.Decrypt(cert.PrivateKeyEncrypted) + if err != nil { + logger.Log().WithError(err).WithField("cert", cert.Name).Warn("Failed to decrypt custom cert key, skipping") + continue + } + + fullCert := cert.Certificate + if cert.CertificateChain != "" { + fullCert = cert.Certificate + "\n" + cert.CertificateChain + } + + loadPEM = append(loadPEM, LoadPEMConfig{ + Certificate: fullCert, + Key: string(decryptedKey), + Tags: []string{cert.UUID}, + }) +} +``` + +Additionally, add a TLS automation policy that skips ACME for custom cert domains: + +```go +if len(customCertDomains) > 0 { + tlsPolicies = append(tlsPolicies, &AutomationPolicy{ + Subjects: customCertDomains, + IssuersRaw: nil, + }) +} +``` + +### 4.5 Encryption Strategy + +**Private Key Encryption at Rest**: + +1. On upload: `encSvc.Encrypt([]byte(keyPEM))` stores in `PrivateKeyEncrypted` +2. On Caddy config generation: `encSvc.Decrypt(cert.PrivateKeyEncrypted)` passes decrypted PEM to Caddy +3. On export: `encSvc.Decrypt(cert.PrivateKeyEncrypted)` converts to requested format +4. `KeyVersion` tracks which encryption key version was used (for rotation via `RotationService`) + +**Migration**: Existing certificates with plaintext `PrivateKey` will be migrated to encrypted form during application startup if `CHARON_ENCRYPTION_KEY` is set. The migration: + +- Reads `private_key` column +- Encrypts with current key +- Writes to `private_key_enc` column +- Sets `key_version = 1` +- Clears `private_key` column +- Logs migration progress + +### 4.6 Certificate Format Handling + +| Input Format | Detection | Processing | +|-------------|-----------|------------| +| **PEM** | Trial parse: `pem.Decode` succeeds | Direct parse via `pem.Decode` + `x509.ParseCertificate` | +| **PFX/PKCS12** | Trial parse: if PEM fails, attempt `pkcs12.Decode` | `pkcs12.Decode(pfxData, password)` then extract cert, key, chain and store as PEM | +| **DER** | Trial parse: if PEM and PFX fail, attempt `x509.ParseCertificate(raw)` | `x509.ParseCertificate(derBytes)` then convert to PEM for storage | + +**Detection strategy**: Use trial-parse (not magic bytes). Try PEM decode first → if that fails, try PFX/PKCS12 decode → if that also fails, try raw DER parse via `x509.ParseCertificate`. This is more reliable than magic byte sniffing, especially for DER which shares ASN.1 structure with PFX. + +**Dependencies**: Use `software.sslmate.com/src/go-pkcs12` for PFX handling (widely used, maintained). + +### 4.7 Expiry Warning Scheduler + +Add a background goroutine in `CertificateService` that runs daily: + +```go +func (s *CertificateService) StartExpiryChecker(ctx context.Context, notificationSvc *NotificationService, warningDays int) { + // Startup delay: avoid notification bursts during frequent restarts + startupDelay := 5 * time.Minute + select { + case <-ctx.Done(): + return + case <-time.After(startupDelay): + } + + // Add random jitter (0-60 minutes) to stagger checks across instances/restarts + jitter := time.Duration(rand.Int63n(int64(60 * time.Minute))) + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + s.checkExpiry(notificationSvc, warningDays) + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.checkExpiry(notificationSvc, warningDays) + } + } +} +``` + +**Configuration**: `warningDays` is read from `CHARON_CERT_EXPIRY_WARNING_DAYS` environment variable at startup (default: `30`). The startup wiring reads the config value and passes it to `StartExpiryChecker`. + +The checker: + +1. Queries all custom certificates +2. For certs expiring within `warningDays` days: create warning notification + send external notification (rate-limited per cert per 24h) +3. For expired certs: update status to "expired" + send critical notification --- -## 9. Commands Reference +## 5. Frontend Design -```bash -# === Backend dependency upgrades === -cd /projects/Charon/backend +### 5.1 New/Modified Components -go get google.golang.org/grpc@v1.80.0 -go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0 -go mod tidy +| Component | Type | Path | Description | +|-----------|------|------|-------------| +| `CertificateUploadDialog` | Modified | `frontend/src/components/dialogs/CertificateUploadDialog.tsx` | Extract from `Certificates.tsx`; add drag-and-drop, format detection, chain file, PFX password, validation preview | +| `CertificateDetailDialog` | New | `frontend/src/components/dialogs/CertificateDetailDialog.tsx` | Full metadata view, chain visualization, assigned hosts list, export button | +| `CertificateExportDialog` | New | `frontend/src/components/dialogs/CertificateExportDialog.tsx` | Format selector (PEM/PFX/DER), include-key toggle, PFX password field | +| `CertificateValidationPreview` | New | `frontend/src/components/CertificateValidationPreview.tsx` | Shows parsed cert info before upload confirmation | +| `CertificateChainViewer` | New | `frontend/src/components/CertificateChainViewer.tsx` | Visual chain display (leaf then intermediate then root) | +| `FileDropZone` | New | `frontend/src/components/ui/FileDropZone.tsx` | Reusable drag-and-drop file upload component | +| `CertificateList` | Modified | `frontend/src/components/CertificateList.tsx` | Add detail view button, export button, expiry warning badges, use UUID for actions | -# === Validate backend === -go build ./cmd/api -go test ./... -go vet ./... -govulncheck ./... +### 5.2 API Client Updates (`frontend/src/api/certificates.ts`) -# === Docker build (after Dockerfile edits) === -cd /projects/Charon -docker build -t charon:vuln-fix . +```typescript +export interface Certificate { + uuid: string + name?: string + common_name?: string + domains: string + issuer: string + issuer_org?: string + fingerprint?: string + serial_number?: string + key_type?: string + expires_at: string + not_before?: string + status: 'valid' | 'expiring' | 'expired' | 'untrusted' + provider: string + chain_depth?: number + has_key: boolean + in_use: boolean +} -# === Scan built image === -docker run --rm \ - -v /var/run/docker.sock:/var/run/docker.sock \ - aquasec/trivy:latest image \ - --severity CRITICAL,HIGH \ - charon:vuln-fix +export interface CertificateDetail extends Certificate { + assigned_hosts: { uuid: string; name: string; domain_names: string }[] + chain: { subject: string; issuer: string; expires_at: string }[] + auto_renew: boolean + created_at: string + updated_at: string +} -# === Quick container health check === -docker run -d --name charon-vuln-test -p 8080:8080 charon:vuln-fix -sleep 10 -curl -f http://localhost:8080/health -docker stop charon-vuln-test && docker rm charon-vuln-test +export interface ValidationResult { + valid: boolean + common_name: string + domains: string[] + issuer_org: string + expires_at: string + key_match: boolean + chain_valid: boolean + chain_depth: number + warnings: string[] + errors: string[] +} + +export async function getCertificateDetail(uuid: string): Promise +export async function uploadCertificate( + name: string, certFile: File, keyFile?: File, chainFile?: File, pfxPassword?: string +): Promise +export async function updateCertificate(uuid: string, name: string): Promise +export async function deleteCertificate(uuid: string): Promise +export async function exportCertificate( + uuid: string, format: string, includeKey: boolean, pfxPassword?: string +): Promise +export async function validateCertificate( + certFile: File, keyFile?: File, chainFile?: File, pfxPassword?: string +): Promise ``` + +### 5.3 Hook Updates (`frontend/src/hooks/useCertificates.ts`) + +```typescript +export function useCertificates(options?: UseCertificatesOptions) +export function useCertificateDetail(uuid: string | null) +export function useUploadCertificate() +export function useUpdateCertificate() +export function useDeleteCertificate() +export function useExportCertificate() +export function useValidateCertificate() +``` + +### 5.4 Upload Flow UX + +1. User clicks "Add Certificate" +2. **Upload Dialog** opens with: + - Name input field + - File drop zones (certificate file, key file, optional chain file) + - Auto-format detection on file drop/select (show detected format badge: PEM/PFX/DER) + - If PFX detected: show password field, hide key file input + - "Validate" button calls `/certificates/validate` and shows `CertificateValidationPreview` +3. Validation preview shows: CN, SANs, issuer, expiry, chain depth, key-match status, warnings +4. User confirms and submits to `POST /certificates` +5. On success: toast + refresh list + close dialog + +### 5.5 Expiry Warning Display + +- Certificates expiring in 30 days or less: yellow warning badge + tooltip with days remaining +- Expired certificates: red expired badge +- The existing `status` field already provides `"expiring"` and `"expired"` values — the UI enhancement adds visual prominence + +--- + +## 6. Security Considerations + +### 6.1 Private Key Encryption + +- **🔴 ACTIVE VULNERABILITY FIX**: The current Upload handler (`certificate_handler.go:137`) returns `c.JSON(http.StatusCreated, cert)` where `cert` is the full `*SSLCertificate` struct. Because `PrivateKey` currently has `json:"private_key"`, the raw PEM private key is disclosed to the client in every upload response. **Commit 1 fixes this** by changing the tag to `json:"-"`, immediately closing this private key disclosure vulnerability. +- All private keys encrypted at rest using AES-256-GCM +- Encryption uses the same `CHARON_ENCRYPTION_KEY` and rotation infrastructure as DNS provider credentials +- Keys are decrypted only in-memory when needed (Caddy reload, export) +- The `PrivateKey` field is hidden from JSON serialization (`json:"-"`) and excluded from GORM queries (`gorm:"-"`) +- The `PrivateKeyEncrypted` field is also hidden from JSON (`json:"-"`) + +### 6.2 File Upload Security + +- Maximum file size: 1MB per file (enforced in handler) +- File content validated (must parse as valid certificate/key/PFX) +- No path traversal risk: files are read into memory, never written to arbitrary paths +- Content-Type and extension validation (`.pem`, `.crt`, `.cer`, `.key`, `.pfx`, `.p12`, `.der`) +- PFX password is not stored; used only during parsing + +### 6.3 GORM Model Security + +- `SSLCertificate.ID` uses `json:"-"` (numeric ID hidden) +- `SSLCertificate.Certificate` uses `json:"-"` (PEM content hidden from list) +- `SSLCertificate.PrivateKey` uses `json:"-"` (transient, not persisted) +- `SSLCertificate.PrivateKeyEncrypted` uses `json:"-"` (encrypted, hidden) +- All API endpoints use UUID for identification +- `CertificateInfo` no longer exposes numeric `ID` + +### 6.4 Export Security + +- Export endpoint requires authentication (existing middleware) +- `include_key: true` requires **password re-confirmation** — the user must supply their current password in the request body; the handler validates it before decrypting the key (implements R-EXP-04) +- PFX export requires a password (enforced) +- Audit log entry for key exports (via notification service) + +--- + +## 7. Implementation Phases (Tasks) + +### Phase 1: Backend Foundation — Model, Encryption, Validation (Commit 1) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 1.1 | Add new fields to SSLCertificate model | `backend/internal/models/ssl_certificate.go` | S | Add `CommonName`, `CertificateChain`, `PrivateKeyEncrypted`, `KeyVersion`, `Fingerprint`, `SerialNumber`, `IssuerOrg`, `KeyType`, `NotBefore`. Hide sensitive fields from JSON. | +| 1.2 | Update AutoMigrate | `backend/internal/api/routes/routes.go` | S | Already migrates `SSLCertificate`; GORM auto-adds new columns. | +| 1.3 | Create certificate validator | `backend/internal/services/certificate_validator.go` | L | `ParseCertificateInput()`, `ValidateKeyMatch()`, `ValidateChain()`, `DetectFormat()`, format conversion functions. | +| 1.4 | Add `go-pkcs12` dependency | `backend/go.mod` | S | `go get software.sslmate.com/src/go-pkcs12` | +| 1.5 | Write private key migration function | `backend/internal/services/certificate_service.go` | M | `MigratePrivateKeys()` — encrypts existing plaintext keys. | +| 1.6 | Modify `UploadCertificate()` | `backend/internal/services/certificate_service.go` | L | Full validation pipeline, encrypt key, store chain, extract metadata. | +| 1.7 | Add `GetCertificate()` | `backend/internal/services/certificate_service.go` | M | Get single cert by UUID with full detail (assigned hosts, chain). | +| 1.8 | Add `ValidateCertificate()` | `backend/internal/services/certificate_service.go` | M | Validate without storing. | +| 1.9 | Modify `DeleteCertificate()` | `backend/internal/services/certificate_service.go` | S | Accept UUID instead of numeric ID. | +| 1.10 | Add `ExportCertificate()` | `backend/internal/services/certificate_service.go` | M | Decrypt key, convert to requested format. | +| 1.11 | Add `GetDecryptedPrivateKey()` | `backend/internal/services/certificate_service.go` | S | Internal decrypt helper. | +| 1.12 | Update `CertificateInfo` | `backend/internal/services/certificate_service.go` | S | Remove numeric ID, add new metadata fields. | +| 1.13 | Update `refreshCacheFromDB()` | `backend/internal/services/certificate_service.go` | M | Populate new fields (fingerprint, chain depth, has_key, in_use). | +| 1.14 | Add constructor changes | `backend/internal/services/certificate_service.go` | S | Accept `*crypto.EncryptionService` in `NewCertificateService`. | +| 1.15 | Unit tests for validator | `backend/internal/services/certificate_validator_test.go` | L | PEM/DER/PFX parsing, key match, chain validation, format detection. | +| 1.16 | Unit tests for upload | `backend/internal/services/certificate_service_test.go` | L | Upload with encryption, migration, export. | +| 1.17 | GORM security scan | — | S | Run `./scripts/scan-gorm-security.sh --check` on new model fields. | + +### Phase 2: Backend API — Handlers, Routes, Caddy (Commit 2) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 2.1 | Update Upload handler | `backend/internal/api/handlers/certificate_handler.go` | L | Accept chain file, PFX password, detect format, call enhanced service. **Fix unsafe read**: replace `certSrc.Read(certBytes)` with `io.ReadAll(io.LimitReader(src, 1<<20))` for safe bounded reads (see Section 2.2 gaps). | +| 2.2 | Add Get handler | `backend/internal/api/handlers/certificate_handler.go` | M | `GET /certificates/:uuid` calls `GetCertificate()`. | +| 2.3 | Add Export handler | `backend/internal/api/handlers/certificate_handler.go` | M | `POST /certificates/:uuid/export` streams file download. | +| 2.4 | Add Update handler | `backend/internal/api/handlers/certificate_handler.go` | S | `PUT /certificates/:uuid` updates name. | +| 2.5 | Add Validate handler | `backend/internal/api/handlers/certificate_handler.go` | M | `POST /certificates/validate` validation-only endpoint. | +| 2.6 | Modify Delete handler | `backend/internal/api/handlers/certificate_handler.go` | S | Use UUID param instead of numeric ID. | +| 2.7 | Register new routes | `backend/internal/api/routes/routes.go` | S | Add new routes, pass encryption service. | +| 2.8 | Update Caddy config generation | `backend/internal/caddy/config.go` | M | Decrypt keys, include chains, skip ACME for custom cert domains. | +| 2.9 | Call migration on startup | `backend/internal/api/routes/routes.go` | S | Call `MigratePrivateKeys()` after service init. | +| 2.10 | Handler unit tests | `backend/internal/api/handlers/certificate_handler_test.go` | L | Test all new endpoints. | +| 2.11 | Caddy config tests | `backend/internal/caddy/config_test.go` | M | Update existing tests, add encrypted key test. | + +### Phase 3: Expiry Warnings & Notifications (within Commit 2) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 3.1 | Add `CheckExpiringCertificates()` | `backend/internal/services/certificate_service.go` | M | Query custom certs expiring in 30 days or less. | +| 3.2 | Add `StartExpiryChecker()` | `backend/internal/services/certificate_service.go` | M | Background goroutine, daily tick, rate-limited notifications. | +| 3.3 | Wire scheduler on startup | `backend/internal/api/routes/routes.go` | S | Start goroutine with context from server. | +| 3.4 | Unit tests for expiry checker | `backend/internal/services/certificate_service_test.go` | M | Mock time, verify notification calls. | + +### Phase 4: Frontend — Enhanced Upload, Detail, Export (Commit 3) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 4.1 | Create `FileDropZone` component | `frontend/src/components/ui/FileDropZone.tsx` | M | Reusable drag-and-drop with format badge. | +| 4.2 | Create `CertificateUploadDialog` | `frontend/src/components/dialogs/CertificateUploadDialog.tsx` | L | Full upload dialog with validation preview, chain, PFX. | +| 4.3 | Create `CertificateValidationPreview` | `frontend/src/components/CertificateValidationPreview.tsx` | M | Parsed cert preview before upload. | +| 4.4 | Create `CertificateDetailDialog` | `frontend/src/components/dialogs/CertificateDetailDialog.tsx` | L | Full metadata, chain, assigned hosts, export action. | +| 4.5 | Create `CertificateChainViewer` | `frontend/src/components/CertificateChainViewer.tsx` | M | Visual chain display. | +| 4.6 | Create `CertificateExportDialog` | `frontend/src/components/dialogs/CertificateExportDialog.tsx` | M | Format + key options. | +| 4.7 | Update `CertificateList` | `frontend/src/components/CertificateList.tsx` | M | Add detail/export buttons, use UUID, expiry badges. | +| 4.8 | Refactor `Certificates` page | `frontend/src/pages/Certificates.tsx` | M | Use new dialog components. | +| 4.9 | Update API client | `frontend/src/api/certificates.ts` | M | New functions, updated types. | +| 4.10 | Update hooks | `frontend/src/hooks/useCertificates.ts` | M | New hooks for detail, export, validate, update. | +| 4.11 | Add translations | `frontend/src/locales/en/translation.json` (+ other locales) | S | New keys for chain, export, validation messages. | +| 4.12 | Frontend unit tests | `frontend/src/components/__tests__/` | L | Tests for new components. | +| 4.13 | Vitest coverage | — | M | Ensure 85% coverage on new code. | + +### Phase 5: E2E Tests & Hardening (Commit 4) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 5.1 | E2E: Certificate upload flow | `tests/certificate-upload.spec.ts` | L | Upload PEM cert + key, validate preview, verify list. | +| 5.2 | E2E: Certificate detail view | `tests/certificate-detail.spec.ts` | M | Open detail dialog, verify metadata, chain view. | +| 5.3 | E2E: Certificate export | `tests/certificate-export.spec.ts` | M | Export PEM, verify download blob. | +| 5.4 | E2E: Certificate assignment | `tests/certificate-assignment.spec.ts` | M | Assign cert to proxy host, verify Caddy reload. | +| 5.5 | Update existing delete tests | `tests/certificate-delete.spec.ts` | S | Use UUID instead of numeric ID. | +| 5.6 | CodeQL scans | — | S | Run Go + JS security scans. | +| 5.7 | GORM security scan | — | S | Final scan on all model changes. | +| 5.8 | Update documentation | `docs/features.md`, `CHANGELOG.md` | S | Document new capabilities. | + +--- + +## 8. Commit Slicing Strategy + +### Decision: 1 PR with 5 logical commits + +**Rationale**: Single feature = single PR. Charon is a self-hosted tool where users track merged PRs to know when features are available. Merging partial PRs (e.g., backend-only) creates false confidence that a feature is complete, leading to user-filed issues and discussions asking why the feature is missing or broken. A single PR ensures the feature ships atomically — users see one merge and get the full capability. + +Each commit maps to an implementation phase; this keeps the diff reviewable by walking through commits sequentially while guaranteeing the feature is never partially deployed. + +### Commit Structure + +| Commit | Phase | Scope | Key Files | +|--------|-------|-------|-----------| +| **Commit 1** | Backend Foundation | Tasks 1.1–1.17 | `backend/internal/models/ssl_certificate.go`, `backend/internal/services/certificate_validator.go`, `backend/internal/services/certificate_service.go`, `backend/go.mod`, `backend/go.sum`, test files | +| **Commit 2** | Backend API + Caddy + Expiry | Tasks 2.1–2.11, 3.1–3.4 | `backend/internal/api/handlers/certificate_handler.go`, `backend/internal/api/routes/routes.go`, `backend/internal/caddy/config.go`, test files | +| **Commit 3** | Frontend | Tasks 4.1–4.13 | `frontend/src/` components, pages, API client, hooks, locales | +| **Commit 4** | E2E Tests & Hardening | Tasks 5.1–5.7 | `tests/` E2E specs, CodeQL/GORM scans | +| **Commit 5** | Documentation | Task 5.8 | `docs/features.md`, `CHANGELOG.md` | + +### Commit Descriptions + +#### Commit 1: Backend Foundation (Model + Validator + Encryption) +- SSLCertificate model with all new fields and correct JSON tags +- Certificate validator: PEM, DER, PFX parsing, key-cert match, chain validation +- Private key encryption/decryption via `CHARON_ENCRYPTION_KEY` +- Migration function for existing plaintext keys +- Unit tests with 85% coverage on new code +- GORM security scan clean + +#### Commit 2: Backend API (Handlers + Routes + Caddy + Expiry Checker) +- Upload endpoint accepts PEM/PFX/DER with safe bounded reads +- Get/Export/Validate/Update endpoints (UUID-based) +- Delete uses UUID instead of numeric ID +- Caddy loads encrypted custom certs with chain support +- Expiry checker: background goroutine, daily tick, notifications for certs expiring within 30 days +- Handler and Caddy config unit tests + +#### Commit 3: Frontend (Upload + Detail + Export + UI Enhancements) +- Enhanced upload dialog with drag-and-drop, format detection, chain file, PFX password +- Validation preview before upload +- Certificate detail dialog with chain viewer +- Export dialog with format selection and key password confirmation +- List uses UUID for all operations, expiry warning badges +- Vitest coverage at 85% on new components + +#### Commit 4: E2E Tests & Hardening +- E2E tests covering upload, detail, export, assignment flows +- Existing delete tests updated for UUID +- CodeQL Go + JS scans clean (no HIGH/CRITICAL) +- GORM security scan clean + +#### Commit 5: Documentation +- `docs/features.md` updated with certificate management capabilities +- `CHANGELOG.md` updated + +### PR-Level Validation Gates + +The PR is merged only when **all** of the following pass: + +- [ ] All backend unit tests pass with 85% coverage on new code +- [ ] All frontend Vitest tests pass with 85% coverage on new code +- [ ] All E2E tests pass (Firefox, Chromium, WebKit) +- [ ] GORM security scan clean (`./scripts/scan-gorm-security.sh --check`) +- [ ] CodeQL Go + JS scans: no HIGH/CRITICAL findings +- [ ] staticcheck pass +- [ ] TypeScript check pass +- [ ] Local patch coverage report generated and reviewed +- [ ] Documentation updated + +### Rollback + +Revert the single PR. All changes are additive (new columns, new endpoints, new components). Reverting removes the feature atomically with no partial state left in production. + +--- + +## 9. Testing Strategy + +### 9.1 Backend Unit Tests + +| Test File | Coverage | +|-----------|----------| +| `backend/internal/services/certificate_validator_test.go` | PEM/DER/PFX parsing, key match (RSA + ECDSA), chain validation (valid/invalid/self-signed), format detection, error cases | +| `backend/internal/services/certificate_service_test.go` | Upload (all formats), encryption/decryption, migration, list (with new fields), get detail, export (all formats), delete by UUID, expiry checker, cache invalidation | +| `backend/internal/api/handlers/certificate_handler_test.go` | All endpoints: upload (multipart), get, export (file download), validate, update, delete; error cases (invalid format, missing key, expired cert) | +| `backend/internal/caddy/config_test.go` | Custom cert with encrypted key, chain inclusion, ACME skip for custom cert domains | + +### 9.2 Frontend Unit Tests + +| Test File | Coverage | +|-----------|----------| +| `frontend/src/components/__tests__/FileDropZone.test.tsx` | Drag-and-drop, file selection, format detection badge | +| `frontend/src/components/__tests__/CertificateUploadDialog.test.tsx` | Full upload flow, PFX mode toggle, validation preview | +| `frontend/src/components/__tests__/CertificateDetailDialog.test.tsx` | Metadata display, chain viewer, export action | +| `frontend/src/components/__tests__/CertificateExportDialog.test.tsx` | Format selection, key toggle, PFX password | +| `frontend/src/components/__tests__/CertificateList.test.tsx` | Updated: UUID-based actions, expiry badges, detail button | +| `frontend/src/hooks/__tests__/useCertificates.test.ts` | New hooks: detail, export, validate | + +### 9.3 E2E Playwright Tests + +| Spec File | Scenarios | +|-----------|-----------| +| `tests/certificate-upload.spec.ts` | Upload PEM cert + key, validate preview, verify list | +| `tests/certificate-detail.spec.ts` | Open detail dialog, verify metadata, chain view | +| `tests/certificate-export.spec.ts` | Export PEM, verify download blob | +| `tests/certificate-assignment.spec.ts` | Assign cert to proxy host, verify Caddy reload | +| `tests/certificate-delete.spec.ts` | Updated: UUID-based deletion | +| `tests/certificate-bulk-delete.spec.ts` | Updated: UUID-based bulk deletion | + +#### Negative / Error Scenarios (Commit 4) + +| Spec File | Scenarios | +|-----------|-----------| +| `tests/certificate-upload-errors.spec.ts` | Mismatched key/cert upload (expect error), invalid file upload (non-cert file), expired cert upload (expect warning + accept), oversized file upload (expect 413) | +| `tests/certificate-export-auth.spec.ts` | Export with `include_key: true` flow — verify password confirmation required, verify incorrect password rejected, verify export without key does not require password | + +### 9.4 Security Scans + +- GORM security scan (`./scripts/scan-gorm-security.sh --check`) — after Phase 1 +- CodeQL Go scan — after Phase 2 +- CodeQL JS scan — after Phase 3 +- Trivy container scan — after final build + +--- + +## 10. Config/Infrastructure Changes + +### 10.1 No Changes Required + +| File | Reason | +|------|--------| +| `.gitignore` | Uploaded certificates stored in database, not on disk. Existing `/data/` ignore covers Caddy runtime data. | +| `codecov.yml` | Existing configuration covers `backend/` and `frontend/src/`. | +| `.dockerignore` | No new file types to ignore. | +| `Dockerfile` | `go-pkcs12` dependency is a Go module pulled during build automatically. | + +### 10.2 Environment Variables + +| Variable | Status | Description | +|----------|--------|-------------| +| `CHARON_ENCRYPTION_KEY` | Existing | Required for private key encryption. Already used for DNS provider credentials. | +| `CHARON_ENCRYPTION_KEY_NEXT` | Existing | Used during key rotation. Rotation service already handles re-encryption. | +| `CHARON_CERT_EXPIRY_WARNING_DAYS` | New (optional) | Override default 30-day warning threshold. Default: `30`. Wired into `StartExpiryChecker()` at startup — see Section 4.7. | + +### 10.3 Database Migration + +GORM `AutoMigrate` handles additive column changes automatically. The private key migration from plaintext to encrypted is a one-time startup operation handled in code (see section 4.5). + +**Migration sequence**: + +1. GORM adds new columns (`common_name`, `certificate_chain`, `private_key_enc`, `key_version`, `fingerprint`, `serial_number`, `issuer_org`, `key_type`, `not_before`) +2. `MigratePrivateKeys()` runs once: reads `private_key`, encrypts to `private_key_enc`, clears `private_key` +3. Subsequent starts skip migration (checks if any rows have `private_key` non-empty and `private_key_enc` empty) + +--- + +## 11. Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| **Private key migration fails mid-way** | Low | High | Migration is transactional per-row. Idempotent — can be re-run. Original `private_key` column kept until migration verified complete. | +| **`CHARON_ENCRYPTION_KEY` not set** | Medium | High | Graceful degradation: upload/export of custom certs disabled when key not set. Clear error message in UI. ACME certs unaffected. | +| **PFX parsing edge cases** | Medium | Medium | Use well-maintained `go-pkcs12` library. Comprehensive test suite with real-world PFX files. Fall back to descriptive error messages. | +| **Caddy reload failure with bad cert** | Low | High | Caddy config generation validates cert/key pairing before including in config. Caddy itself validates on load and reports errors. Rollback logic already exists in Caddy manager. | +| **Breaking API change (numeric ID to UUID)** | Medium | Medium | Frontend and backend changes in separate PRs but deployed together. No external API consumers currently (self-hosted tool). Existing E2E tests catch regressions. | +| **Performance impact of encryption/decryption** | Low | Low | AES-256-GCM is hardware-accelerated on modern CPUs. Only custom certs are encrypted (typically fewer than 10 per instance). Caddy config generation is not a hot path. | +| **Large file upload DoS** | Low | Medium | 1MB file size limit enforced in handler. Gin's `MaxMultipartMemory` also provides protection. | + +--- + +## 12. Acceptance Criteria (Definition of Done) + +- [ ] Can upload custom certificates in PEM, PFX, and DER formats +- [ ] Certificate and key are validated before acceptance (format, key match, chain) +- [ ] Private keys are encrypted at rest using `CHARON_ENCRYPTION_KEY` +- [ ] Certificate detail view shows full metadata (CN, SANs, issuer, chain, fingerprint) +- [ ] Certificates can be assigned to proxy hosts +- [ ] Caddy serves custom certificates for assigned domains +- [ ] Expiry warnings fire as in-app and external notifications at 30 days +- [ ] Certificates can be exported in PEM, PFX, and DER formats +- [ ] All API endpoints use UUID (no numeric ID exposure) +- [ ] 85% test coverage on all new backend and frontend code +- [ ] E2E tests pass for upload, detail, export, assignment flows +- [ ] GORM security scan reports zero CRITICAL/HIGH findings +- [ ] CodeQL scans report zero HIGH/CRITICAL findings +- [ ] No plaintext private keys in database after migration