diff --git a/docker-compose.yml b/docker-compose.yml index 0a0bfd3..9b0e470 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME} MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD} MONGO_INITDB_DATABASE: pki_db + ports: + - "27018:27017" volumes: - mongodb_data:/data/db - ./scripts/init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro diff --git a/go.mod b/go.mod index 0b30201..c004895 100644 --- a/go.mod +++ b/go.mod @@ -42,4 +42,5 @@ require ( golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + software.sslmate.com/src/go-pkcs12 v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index b697943..6532860 100644 --- a/go.sum +++ b/go.sum @@ -137,3 +137,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +software.sslmate.com/src/go-pkcs12 v0.6.0 h1:f3sQittAeF+pao32Vb+mkli+ZyT+VwKaD014qFGq6oU= +software.sslmate.com/src/go-pkcs12 v0.6.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 1dd2d1b..ce85a04 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1,13 +1,16 @@ package api import ( + "bytes" + "crypto/x509" + "fmt" "net/http" - "pki-manager/internal/models" "pki-manager/internal/repository" "pki-manager/internal/services" "github.com/gin-gonic/gin" + "software.sslmate.com/src/go-pkcs12" ) type Handlers struct { @@ -334,6 +337,19 @@ func (h *Handlers) DownloadCACertificate(c *gin.Context) { c.String(http.StatusOK, ca.Certificate) } +func (h *Handlers) DownloadCAPrivateKey(c *gin.Context) { + id := c.Param("id") + cert, err := h.repo.GetCA(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"}) + return + } + + c.Header("Content-Type", "application/x-pem-file") + c.Header("Content-Disposition", "attachment; filename="+cert.CommonName+".key") + c.String(http.StatusOK, cert.PrivateKey) +} + func (h *Handlers) DownloadSubCACertificate(c *gin.Context) { id := c.Param("id") subca, err := h.repo.GetSubCA(c.Request.Context(), id) @@ -347,6 +363,19 @@ func (h *Handlers) DownloadSubCACertificate(c *gin.Context) { c.String(http.StatusOK, subca.Certificate) } +func (h *Handlers) DownloadSubCAPrivateKey(c *gin.Context) { + id := c.Param("id") + cert, err := h.repo.GetSubCA(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"}) + return + } + + c.Header("Content-Type", "application/x-pem-file") + c.Header("Content-Disposition", "attachment; filename="+cert.CommonName+".key") + c.String(http.StatusOK, cert.PrivateKey) +} + // Download handlers func (h *Handlers) DownloadCertificate(c *gin.Context) { id := c.Param("id") @@ -374,6 +403,70 @@ func (h *Handlers) DownloadPrivateKey(c *gin.Context) { c.String(http.StatusOK, cert.PrivateKey) } +func (h *Handlers) DownloadP12(c *gin.Context) { + id := c.Param("id") + var p12Data []byte + + cert, err := h.repo.GetCertificate(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"}) + return + } + + ca, err := h.repo.GetCA(c.Request.Context(), cert.IssuerCAID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Certificate CA not found"}) + return + } + + // Choisir la méthode d'encodage selon la configuration + + x509cert, err := h.cryptoService.ParseCertificate([]byte(cert.Certificate)) + if err != nil { + message := fmt.Sprintf("Certificateformat PEM invalide %s", err) + c.JSON(http.StatusNotFound, gin.H{"error": message}) + return + } + x509key, err := h.cryptoService.ParsePrivateKey([]byte(cert.PrivateKey)) + if err != nil { + message := fmt.Sprintf("Keyformat PEM invalide %s", err) + c.JSON(http.StatusNotFound, gin.H{"error": message}) + return + } + + x509cacert, err := h.cryptoService.ParseCertificate([]byte(ca.Certificate)) + if err != nil { + message := fmt.Sprintf("CA Certificateformat PEM invalide %s", err) + c.JSON(http.StatusNotFound, gin.H{"error": message}) + return + } + + caCerts := []*x509.Certificate{x509cacert} + + p12Data, err = pkcs12.Modern.Encode(x509key, x509cert, caCerts, "password") + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "échec encodage P12"}) + return + } + buffer := bytes.NewBuffer(p12Data) + securityLevel := c.DefaultQuery("security", "modern") + filename := cert.CommonName + ".p12" + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + c.Header("Content-Type", "application/x-pkcs12") + c.Header("Content-Length", fmt.Sprintf("%d", buffer.Len())) + c.Header("Content-Transfer-Encoding", "binary") + c.Header("X-Security-Level", securityLevel) + + // Retourner les données + c.DataFromReader( + http.StatusOK, + int64(buffer.Len()), + "application/x-pkcs12", + buffer, + map[string]string{"Content-Disposition": fmt.Sprintf(`attachment; filename="%s"`, filename)}, + ) +} + // Web Interface func (h *Handlers) ServeWebInterface(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) diff --git a/internal/api/routes.go b/internal/api/routes.go index 41bbd95..01d09f9 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -39,6 +39,8 @@ func SetupRoutes(router *gin.Engine, repo repository.Repository, jwtSecret strin ca.PUT("/:id", handlers.UpdateCA) ca.DELETE("/:id", handlers.DeleteCA) ca.GET("/:id/download/cert", handlers.DownloadCACertificate) + ca.GET("/:id/download/key", handlers.DownloadCAPrivateKey) + } // SubCA routes @@ -50,6 +52,8 @@ func SetupRoutes(router *gin.Engine, repo repository.Repository, jwtSecret strin subca.PUT("/:id", handlers.UpdateSubCA) subca.DELETE("/:id", handlers.DeleteSubCA) subca.GET("/:id/download/cert", handlers.DownloadSubCACertificate) + subca.GET("/:id/download/key", handlers.DownloadSubCAPrivateKey) + } // Certificate routes @@ -62,6 +66,7 @@ func SetupRoutes(router *gin.Engine, repo repository.Repository, jwtSecret strin cert.POST("/:id/revoke", handlers.RevokeCertificate) cert.GET("/:id/download/cert", handlers.DownloadCertificate) cert.GET("/:id/download/key", handlers.DownloadPrivateKey) + cert.GET("/:id/download/p12", handlers.DownloadP12) } } } diff --git a/internal/models/ca.go b/internal/models/ca.go index ab14464..dc4d4f2 100644 --- a/internal/models/ca.go +++ b/internal/models/ca.go @@ -13,6 +13,8 @@ type CA struct { Country string `json:"country" bson:"country"` Province string `json:"province" bson:"province"` Locality string `json:"locality" bson:"locality"` + StreetAddress string `json:"street_address" bson:"street_address"` + PostalCode string `json:"postal_code" bson:"postal_code"` Email string `json:"email" bson:"email"` PrivateKey string `json:"private_key,omitempty" bson:"private_key"` Certificate string `json:"certificate" bson:"certificate"` @@ -32,6 +34,8 @@ type CreateCARequest struct { Country string `json:"country" binding:"required"` Province string `json:"province"` Locality string `json:"locality"` + StreetAddress string `json:"street_address"` + PostalCode string `json:"postal_code"` Email string `json:"email" binding:"omitempty,email"` // omitempty permet les chaînes vides KeySize int `json:"key_size" binding:"required,min=2048"` ValidYears int `json:"valid_years" binding:"required,min=1,max=20"` diff --git a/internal/models/certificate.go b/internal/models/certificate.go index 9d68865..5fc44b2 100644 --- a/internal/models/certificate.go +++ b/internal/models/certificate.go @@ -7,7 +7,7 @@ import ( type Certificate struct { ID string `json:"id" bson:"_id"` CommonName string `json:"common_name" bson:"common_name"` - Subject string `json:"subject" bson:"subject"` +// Subject string `json:"subject" bson:"subject"` DNSNames []string `json:"dns_names" bson:"dns_names"` IPAddresses []string `json:"ip_addresses" bson:"ip_addresses"` Type string `json:"type" bson:"type"` // "server" or "client" diff --git a/internal/services/crypto_service.go b/internal/services/crypto_service.go index 8f77c5c..a4baaab 100644 --- a/internal/services/crypto_service.go +++ b/internal/services/crypto_service.go @@ -36,12 +36,14 @@ func (s *CryptoService) GenerateRootCA(req models.CreateCARequest) (*models.CA, } // Préparer le sujet avec email optionnel subject := pkix.Name{ - CommonName: req.CommonName, - Organization: []string{req.Organization}, - OrganizationalUnit []string{req.OrganizationalUnit}, - Country: []string{req.Country}, - Province: []string{req.Province}, - Locality: []string{req.Locality}, + CommonName: req.CommonName, + Organization: []string{req.Organization}, + OrganizationalUnit: []string{req.OrganizationalUnit}, + Country: []string{req.Country}, + Province: []string{req.Province}, + Locality: []string{req.Locality}, + StreetAddress: []string{req.StreetAddress}, + PostalCode: []string{req.PostalCode}, } // Ajouter l'email seulement s'il est fourni @@ -85,21 +87,23 @@ func (s *CryptoService) GenerateRootCA(req models.CreateCARequest) (*models.CA, }) ca := &models.CA{ - ID: fmt.Sprintf("ca_%d", time.Now().UnixNano()), - Name: req.Name, - CommonName: req.CommonName, - Organization: req.Organization, + ID: fmt.Sprintf("ca_%d", time.Now().UnixNano()), + Name: req.Name, + CommonName: req.CommonName, + Organization: req.Organization, OrganizationalUnit: req.OrganizationalUnit, - Country: req.Country, - Province: req.Province, - Locality: req.Locality, - Email: req.Email, - PrivateKey: string(privPEM), - Certificate: string(certPEM), - SerialNumber: serialNumber.String(), - ValidFrom: template.NotBefore, - ValidTo: template.NotAfter, - IsRoot: req.IsRoot, + Country: req.Country, + Province: req.Province, + Locality: req.Locality, + StreetAddress: req.StreetAddress, + PostalCode: req.PostalCode, + Email: req.Email, + PrivateKey: string(privPEM), + Certificate: string(certPEM), + SerialNumber: serialNumber.String(), + ValidFrom: template.NotBefore, + ValidTo: template.NotAfter, + IsRoot: req.IsRoot, } return ca, nil @@ -137,12 +141,12 @@ func (s *CryptoService) GenerateSubCA(req models.CreateSubCARequest, parentCA *m return nil, fmt.Errorf("failed to generate serial number: %v", err) } subject := pkix.Name{ - CommonName: req.CommonName, - Organization: []string{req.Organization}, - OrganizationalUnit []string{req.OrganizationalUnit}, - Country: []string{req.Country}, - Province: []string{req.Province}, - Locality: []string{req.Locality}, + CommonName: req.CommonName, + Organization: []string{req.Organization}, + OrganizationalUnit: []string{req.OrganizationalUnit}, + Country: []string{req.Country}, + Province: []string{req.Province}, + Locality: []string{req.Locality}, } template := x509.Certificate{ SerialNumber: serialNumber, @@ -176,21 +180,21 @@ func (s *CryptoService) GenerateSubCA(req models.CreateSubCARequest, parentCA *m }) subca := &models.SubCA{ - ID: fmt.Sprintf("subca_%d", time.Now().UnixNano()), - Name: req.Name, - CommonName: req.CommonName, - Organization: req.Organization, + ID: fmt.Sprintf("subca_%d", time.Now().UnixNano()), + Name: req.Name, + CommonName: req.CommonName, + Organization: req.Organization, OrganizationalUnit: req.OrganizationalUnit, - Country: req.Country, - Province: req.Province, - Locality: req.Locality, - Email: req.Email, - PrivateKey: string(privPEM), - Certificate: string(certPEM), - SerialNumber: serialNumber.String(), - ValidFrom: template.NotBefore, - ValidTo: template.NotAfter, - ParentCAID: req.ParentCAID, + Country: req.Country, + Province: req.Province, + Locality: req.Locality, + Email: req.Email, + PrivateKey: string(privPEM), + Certificate: string(certPEM), + SerialNumber: serialNumber.String(), + ValidFrom: template.NotBefore, + ValidTo: template.NotAfter, + ParentCAID: req.ParentCAID, } return subca, nil @@ -245,7 +249,6 @@ func (s *CryptoService) GenerateCertificate(req models.CreateCertificateRequest, default: return nil, fmt.Errorf("unsupported issuer type") } - // Generate certificate private key priv, err := rsa.GenerateKey(rand.Reader, req.KeySize) if err != nil { @@ -258,17 +261,25 @@ func (s *CryptoService) GenerateCertificate(req models.CreateCertificateRequest, return nil, fmt.Errorf("failed to generate serial number: %v", err) } + subject := pkix.Name{ + CommonName: req.CommonName, + Organization: []string{issuerCert.Subject.Organization[0]}, + OrganizationalUnit: []string{issuerCert.Subject.OrganizationalUnit[0]}, + Country: []string{issuerCert.Subject.Country[0]}, + Province: []string{issuerCert.Subject.Province[0]}, + Locality: []string{issuerCert.Subject.Locality[0]}, + StreetAddress: []string{issuerCert.Subject.StreetAddress[0]}, + PostalCode: []string{issuerCert.Subject.PostalCode[0]}, + } template := x509.Certificate{ SerialNumber: serialNumber, - Subject: pkix.Name{ - CommonName: req.CommonName, - }, - NotBefore: time.Now(), - NotAfter: time.Now().AddDate(0, 0, req.ValidDays), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: s.getExtKeyUsage(req.Type), - DNSNames: req.DNSNames, - IPAddresses: s.parseIPs(req.IPAddresses), + Subject: subject, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, req.ValidDays), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: s.getExtKeyUsage(req.Type), + DNSNames: req.DNSNames, + IPAddresses: s.parseIPs(req.IPAddresses), } // Sign the certificate @@ -293,7 +304,6 @@ func (s *CryptoService) GenerateCertificate(req models.CreateCertificateRequest, cert := &models.Certificate{ ID: fmt.Sprintf("cert_%d", time.Now().UnixNano()), CommonName: req.CommonName, - Subject: template.Subject.String(), DNSNames: req.DNSNames, IPAddresses: req.IPAddresses, Type: req.Type, @@ -329,3 +339,30 @@ func (s *CryptoService) parseIPs(ips []string) []net.IP { } return parsedIPs } + +func (s *CryptoService) ParseCertificate(data []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("format PEM invalide") + } + return x509.ParseCertificate(block.Bytes) +} + +func (s *CryptoService) ParsePrivateKey(data []byte) (interface{}, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("format PEM invalide") + } + + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + return key, nil + } + if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return key, nil + } + + return nil, fmt.Errorf("format de clé non supporté") +} diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 2891135..a89bc59 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -593,9 +593,12 @@ async fetchSubCAs() { name: data.name, common_name: data.common_name, organization: data.organization, + organization_unit: data.organization_unit, country: data.country, province: data.province || '', locality: data.locality || '', + street_address: data.street_address || '', + postal_code: data.postal_code || '', email: data.email || '', key_size: parseInt(data.key_size) || 4096, valid_years: parseInt(data.valid_years) || 10, @@ -632,6 +635,7 @@ async fetchSubCAs() { name: data.name, common_name: data.common_name, organization: data.organization, + organization_unit: data.organization_unit, email: data.email || '', country: data.country, province: data.province || '', @@ -785,10 +789,16 @@ async fetchSubCAs() {
Common Name: ${ca.common_name}
Organization: ${ca.organization}
+Organizational Unit: ${ca.organization_unit}
+StreetAddress: ${ca.street_address}
+PostalCode: ${ca.postal_code}
Valid From: ${new Date(ca.valid_from).toLocaleString()}
Valid To: ${new Date(ca.valid_to).toLocaleString()}
Serial: ${ca.serial_number}
- + `); } catch (error) { this.showError('Failed to load CA details'); @@ -804,7 +814,9 @@ async fetchSubCAs() { this.showModal('Sub CA Details', `Common Name: ${subca.common_name}
+Subject: ${subca.subject}
Organization: ${subca.organization}
+Organizational Unit: ${subca.organization_unit}
Valid From: ${new Date(subca.valid_from).toLocaleString()}
Valid To: ${new Date(subca.valid_to).toLocaleString()}
Serial: ${subca.serial_number}
@@ -818,12 +830,15 @@ async fetchSubCAs() { async viewCertificate(id) { try { const response = await fetch(`${this.apiBase}/certificates/${id}`); + if (!response.ok) throw new Error('Not found'); const cert = await response.json(); this.showModal('Certificate Details', `Type: ${cert.type}
+SANs: ${cert.dns_names} +
IP Address: ${cert.ip_adresses}
Valid From: ${new Date(cert.valid_from).toLocaleString()}
Valid To: ${new Date(cert.valid_to).toLocaleString()}
Status: ${cert.revoked ? 'Revoked' : new Date(cert.valid_to) > new Date() ? 'Valid' : 'Expired'}
@@ -831,6 +846,7 @@ async fetchSubCAs() { `); } catch (error) { diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index d5115df..d7aa688 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -5,41 +5,13 @@Manage your Certificate Authority infrastructure
-