Files
NewSmoop/server/internal/handlers/certs.go

201 lines
5.2 KiB
Go

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,
})
}