package handlers import ( "context" "crypto/x509" "encoding/pem" "errors" "io" "net/http" "net/url" "reflect" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "smoop-api/internal/models" "smoop-api/internal/vault" ) type CertsHandler struct { db *gorm.DB pki *vault.PKIClient ttl string // e.g. "720h" } func NewCertsHandler(db *gorm.DB, pki *vault.PKIClient, ttl string) *CertsHandler { return &CertsHandler{db: db, pki: pki, ttl: ttl} } // ---- helpers ---------------------------------------------------------------- func readCSRFromRequest(c *gin.Context) (string, error) { // Accept: multipart/form with file "csr", or JSON {"csr":"PEM..."} or text/plain body // 1) multipart file if f, err := c.FormFile("csr"); err == nil && f != nil { ff, err := f.Open() if err != nil { return "", err } defer ff.Close() b, err := io.ReadAll(ff) return string(b), err } // 2) JSON var body struct { CSR string `json:"csr"` } if err := c.ShouldBindJSON(&body); err == nil && body.CSR != "" { return body.CSR, nil } // 3) raw text b, _ := io.ReadAll(c.Request.Body) if len(b) > 0 { return string(b), nil } return "", errors.New("csr required") } func parseLeafPEM(pemStr string) (*x509.Certificate, error) { block, _ := pem.Decode([]byte(pemStr)) if block == nil { return nil, errors.New("pem decode failed") } return x509.ParseCertificate(block.Bytes) } func parseClientCertFromHeader(escaped string) (*x509.Certificate, error) { if escaped == "" { return nil, errors.New("empty client cert header") } raw, err := url.QueryUnescape(escaped) if err != nil { // Some Nginx builds already space-escape; still try raw raw = escaped } block, _ := pem.Decode([]byte(raw)) if block == nil { return nil, errors.New("failed to decode PEM in client cert header") } return x509.ParseCertificate(block.Bytes) } // ---- enroll (no mTLS, just device exists) ----------------------------------- // POST /enroll/:guid func (h *CertsHandler) Enroll(c *gin.Context) { guid := c.Param("guid") // ensure device exists var dev models.Device if err := h.db.First(&dev, "guid = ?", guid).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "device not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"}) return } csr, err := readCSRFromRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // sign in Vault ctx, cancel := context.WithTimeout(c, 30*time.Second) defer cancel() sign, err := h.pki.SignCSR(ctx, csr, "urn:device:"+guid, h.ttl) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "vault sign failed"}) return } // persist cert metadata leaf, err := parseLeafPEM(sign.Certificate) if err == nil { _ = h.db.Create(&models.DeviceCertificate{ DeviceGUID: guid, SerialHex: strings.ToUpper(leaf.SerialNumber.Text(16)), IssuerCN: leaf.Issuer.CommonName, SubjectDN: leaf.Subject.String(), NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, PemCert: sign.Certificate, }).Error } // response bundle c.JSON(http.StatusOK, gin.H{ "certificate": sign.Certificate, "issuing_ca": sign.IssuingCA, "ca_chain": sign.CAChain, }) } // ---- renew (mTLS; cert must not be revoked; CSR pubkey must match current) -- // POST /renew/:guid func (h *CertsHandler) Renew(c *gin.Context) { guidAny, _ := c.Get("mtlsDeviceGUID") serialAny, _ := c.Get("mtlsSerialHex") guid := guidAny.(string) currentSerial := serialAny.(string) csr, err := readCSRFromRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Check CSR pubkey == current client cert pubkey (strong binding) clientCert, err := parseClientCertFromHeader(c.GetHeader("X-SSL-Client-Cert")) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid client cert"}) return } csrBlock, _ := pem.Decode([]byte(csr)) if csrBlock == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "csr decode failed"}) return } parsedCSR, err := x509.ParseCertificateRequest(csrBlock.Bytes) if err != nil || parsedCSR.PublicKey == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "csr parse failed"}) return } if !reflect.DeepEqual(parsedCSR.PublicKey, clientCert.PublicKey) { c.JSON(http.StatusForbidden, gin.H{"error": "csr key does not match current certificate key"}) return } // sign ctx, cancel := context.WithTimeout(c, 30*time.Second) defer cancel() sign, err := h.pki.SignCSR(ctx, csr, "urn:device:"+guid, h.ttl) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "vault sign failed"}) return } leaf, _ := parseLeafPEM(sign.Certificate) if leaf != nil { _ = h.db.Create(&models.DeviceCertificate{ DeviceGUID: guid, SerialHex: strings.ToUpper(leaf.SerialNumber.Text(16)), IssuerCN: leaf.Issuer.CommonName, SubjectDN: leaf.Subject.String(), NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, PemCert: sign.Certificate, }).Error } c.JSON(http.StatusOK, gin.H{ "certificate": sign.Certificate, "issuing_ca": sign.IssuingCA, "ca_chain": sign.CAChain, "old_serial": currentSerial, }) }