feat: PKI API Go avec persistance MongoDB et abstraction storage

- API REST complète pour gestion Infrastructure à Clé Publique
- Authentification JWT sur tous les endpoints (sauf login)
- Hiérarchie de certificats (Root CA, Sub-CA, End Certificates)
- Abstraction de stockage avec MemoryStore et MongoStore
- Configuration centralisée via variables d'environnement
- Support déploiement Docker Compose avec MongoDB
- Tests unitaires pour sérialisation des clés RSA
- Documentation complète avec exemples API
This commit is contained in:
zen6
2025-12-06 23:11:50 +01:00
commit ddece00272
23 changed files with 2382 additions and 0 deletions

57
internal/api/auth.go Normal file
View File

@@ -0,0 +1,57 @@
package api
import (
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/stef/pkiapi/internal/auth"
)
// LoginRequest représente la requête de connexion
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse représente la réponse de connexion
type LoginResponse struct {
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
}
// Login génère un token JWT (simple pour démo)
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validation simple (à remplacer par une vraie authentification)
if req.Username == "" || req.Password == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"})
return
}
// Créer JWT manager
secretKey := os.Getenv("JWT_SECRET_KEY")
if secretKey == "" {
secretKey = "your-secret-key-change-in-prod"
}
jwtManager := auth.NewJWTManager(secretKey)
// Générer token avec expiration de 24h
token, err := jwtManager.GenerateToken(req.Username, "user", 24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur génération token"})
return
}
c.JSON(http.StatusOK, LoginResponse{
Token: token,
ExpiresIn: 86400, // 24 heures en secondes
})
}

286
internal/api/ca.go Normal file
View File

@@ -0,0 +1,286 @@
package api
import (
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stef/pkiapi/internal/pki"
"github.com/stef/pkiapi/internal/storage"
)
// CreateCARequest représente la requête de création d'une CA
type CreateCARequest struct {
Subject string `json:"subject" binding:"required"`
ValidityDays int `json:"validity_days" binding:"required,min=1,max=36500"`
}
// CAResponse représente la réponse avec les données de la CA
type CAResponse struct {
ID string `json:"id"`
Subject string `json:"subject"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
SerialNumber string `json:"serial_number"`
Certificate string `json:"certificate"` // Base64 encoded
IsCA bool `json:"is_ca"`
}
// caStore est un store global pour les CAs
var caStore storage.CertificateStore
// InitCAStore initialise le store pour les CAs
func InitCAStore(store storage.CertificateStore) {
caStore = store
}
// ListCAResponse représente une CA dans une liste
type ListCAResponse struct {
ID string `json:"id"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
SerialNumber string `json:"serial_number"`
}
// ListCAs retourne toutes les CAs
func ListCAs(c *gin.Context) {
cas := caStore.ListCertificates()
var responses []ListCAResponse
for _, ca := range cas {
if ca.IsCA {
response := ListCAResponse{
ID: ca.ID,
Subject: ca.Subject,
Issuer: ca.Issuer,
NotBefore: ca.NotBefore.Format("2006-01-02T15:04:05Z"),
NotAfter: ca.NotAfter.Format("2006-01-02T15:04:05Z"),
}
if ca.Cert != nil {
response.SerialNumber = ca.Cert.SerialNumber.String()
}
responses = append(responses, response)
}
}
c.JSON(http.StatusOK, gin.H{"cas": responses})
}
// CreateCA crée une nouvelle autorité de certification
func CreateCA(c *gin.Context) {
var req CreateCARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Générer la CA
ca, err := pki.GenerateCA(req.Subject, req.ValidityDays)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur génération CA"})
return
}
// Générer un ID unique
caID := uuid.New().String()
ca.ID = caID
// Sauvegarder la CA
if err := caStore.SaveCertificate(caID, ca); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur sauvegarde CA"})
return
}
// Récupérer l'utilisateur depuis le contexte JWT
userID, _ := c.Get("user_id")
// Retourner la réponse
response := CAResponse{
ID: caID,
Subject: ca.Subject,
NotBefore: ca.NotBefore.Format("2006-01-02T15:04:05Z"),
NotAfter: ca.NotAfter.Format("2006-01-02T15:04:05Z"),
IsCA: true,
}
if ca.Cert != nil {
response.SerialNumber = ca.Cert.SerialNumber.String()
response.Certificate = base64.StdEncoding.EncodeToString(ca.Cert.Raw)
}
c.JSON(http.StatusCreated, gin.H{
"ca": response,
"created_by": userID,
})
}
// GetCA retourne une CA par ID
func GetCA(c *gin.Context) {
id := c.Param("id")
ca, err := caStore.GetCertificate(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "CA non trouvée"})
return
}
if !ca.IsCA {
c.JSON(http.StatusBadRequest, gin.H{"error": "ce certificat n'est pas une CA"})
return
}
response := CAResponse{
ID: ca.ID,
Subject: ca.Subject,
NotBefore: ca.NotBefore.Format("2006-01-02T15:04:05Z"),
NotAfter: ca.NotAfter.Format("2006-01-02T15:04:05Z"),
IsCA: true,
}
if ca.Cert != nil {
response.SerialNumber = ca.Cert.SerialNumber.String()
response.Certificate = base64.StdEncoding.EncodeToString(ca.Cert.Raw)
}
c.JSON(http.StatusOK, gin.H{"ca": response})
}
// SignCertificateRequest représente une requête de signature de certificat par une CA
type SignCertificateRequest struct {
CAId string `json:"ca_id" binding:"required"`
Subject string `json:"subject" binding:"required"`
ValidityDays int `json:"validity_days" binding:"required,min=1,max=3650"`
}
// SignCertificateWithCA signe un certificat avec une CA
func SignCertificateWithCA(c *gin.Context) {
var req SignCertificateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Récupérer la CA
ca, err := caStore.GetCertificate(req.CAId)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "CA non trouvée"})
return
}
if !ca.IsCA {
c.JSON(http.StatusBadRequest, gin.H{"error": "ce certificat n'est pas une CA"})
return
}
// Signer le certificat avec la CA
cert, err := pki.SignCertificate(ca, req.Subject, req.ValidityDays)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur signature certificat: " + err.Error()})
return
}
// Générer un ID unique
certID := uuid.New().String()
cert.ID = certID
// Sauvegarder le certificat
if err := certificateStore.SaveCertificate(certID, cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur sauvegarde certificat"})
return
}
// Récupérer l'utilisateur depuis le contexte JWT
userID, _ := c.Get("user_id")
// Retourner la réponse
response := CertificateResponse{
ID: certID,
Subject: cert.Subject,
Issuer: cert.Issuer,
NotBefore: cert.NotBefore.Format("2006-01-02T15:04:05Z"),
NotAfter: cert.NotAfter.Format("2006-01-02T15:04:05Z"),
Revoked: false,
}
if cert.Cert != nil {
response.SerialNumber = cert.Cert.SerialNumber.String()
response.Certificate = base64.StdEncoding.EncodeToString(cert.Cert.Raw)
}
c.JSON(http.StatusCreated, gin.H{
"certificate": response,
"signed_by": req.CAId,
"created_by": userID,
})
}
// SignSubCARequest représente une requête de création de sub-CA
type SignSubCARequest struct {
ParentCAId string `json:"parent_ca_id" binding:"required"`
Subject string `json:"subject" binding:"required"`
ValidityDays int `json:"validity_days" binding:"required,min=1,max=36500"`
}
// SignSubCA crée une CA intermédiaire signée par une CA parent
func SignSubCA(c *gin.Context) {
var req SignSubCARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Récupérer la CA parent
parentCA, err := caStore.GetCertificate(req.ParentCAId)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "CA parent non trouvée"})
return
}
if !parentCA.IsCA {
c.JSON(http.StatusBadRequest, gin.H{"error": "ce certificat n'est pas une CA"})
return
}
// Signer la sub-CA avec la CA parent
subCA, err := pki.SignSubCA(parentCA, req.Subject, req.ValidityDays)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur signature sub-CA: " + err.Error()})
return
}
// Générer un ID unique
subCAId := uuid.New().String()
subCA.ID = subCAId
// Sauvegarder la sub-CA
if err := caStore.SaveCertificate(subCAId, subCA); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur sauvegarde sub-CA"})
return
}
// Récupérer l'utilisateur depuis le contexte JWT
userID, _ := c.Get("user_id")
// Retourner la réponse
response := CAResponse{
ID: subCAId,
Subject: subCA.Subject,
NotBefore: subCA.NotBefore.Format("2006-01-02T15:04:05Z"),
NotAfter: subCA.NotAfter.Format("2006-01-02T15:04:05Z"),
IsCA: true,
}
if subCA.Cert != nil {
response.SerialNumber = subCA.Cert.SerialNumber.String()
response.Certificate = base64.StdEncoding.EncodeToString(subCA.Cert.Raw)
}
c.JSON(http.StatusCreated, gin.H{
"ca": response,
"signed_by": req.ParentCAId,
"created_by": userID,
})
}

View File

@@ -0,0 +1,184 @@
package api
import (
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stef/pkiapi/internal/pki"
"github.com/stef/pkiapi/internal/storage"
)
// CreateCertificateRequest représente la requête de création de certificat
type CreateCertificateRequest struct {
Subject string `json:"subject" binding:"required"`
ValidityDays int `json:"validity_days" binding:"required,min=1,max=3650"`
}
// CertificateResponse représente la réponse avec les données du certificat
type CertificateResponse struct {
ID string `json:"id"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
SerialNumber string `json:"serial_number"`
Certificate string `json:"certificate"` // Base64 encoded
Revoked bool `json:"revoked"`
}
// certificateStore est un store global pour les certificats
var certificateStore storage.CertificateStore
// InitCertificateStore initialise le store pour les certificats
func InitCertificateStore(store storage.CertificateStore) {
certificateStore = store
}
// CreateCertificate crée un nouveau certificat auto-signé
func CreateCertificate(c *gin.Context) {
var req CreateCertificateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Générer le certificat
cert, err := pki.GenerateCertificate(req.Subject, req.ValidityDays)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur génération certificat"})
return
}
// Générer un ID unique
certID := uuid.New().String()
cert.ID = certID
// Sauvegarder le certificat
if err := certificateStore.SaveCertificate(certID, cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur sauvegarde certificat"})
return
}
// Récupérer l'utilisateur depuis le contexte JWT
userID, _ := c.Get("user_id")
// Retourner la réponse
response := CertificateResponse{
ID: certID,
Subject: cert.Subject,
Issuer: cert.Issuer,
NotBefore: cert.NotBefore.Format("2006-01-02T15:04:05Z"),
NotAfter: cert.NotAfter.Format("2006-01-02T15:04:05Z"),
Revoked: false,
}
if cert.Cert != nil {
response.SerialNumber = cert.Cert.SerialNumber.String()
response.Certificate = base64.StdEncoding.EncodeToString(cert.Cert.Raw)
}
c.JSON(http.StatusCreated, gin.H{
"certificate": response,
"created_by": userID,
})
}
// ListCertificates retourne tous les certificats
func ListCertificates(c *gin.Context) {
certs := certificateStore.ListCertificates()
var responses []CertificateResponse
for _, cert := range certs {
response := CertificateResponse{
ID: cert.ID,
Subject: cert.Subject,
Issuer: cert.Issuer,
NotBefore: cert.NotBefore.Format("2006-01-02T15:04:05Z"),
NotAfter: cert.NotAfter.Format("2006-01-02T15:04:05Z"),
Revoked: cert.Revoked,
}
if cert.Cert != nil {
response.SerialNumber = cert.Cert.SerialNumber.String()
}
responses = append(responses, response)
}
c.JSON(http.StatusOK, gin.H{"certificates": responses})
}
// GetCertificate retourne un certificat par ID
func GetCertificate(c *gin.Context) {
id := c.Param("id")
cert, err := certificateStore.GetCertificate(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificat non trouvé"})
return
}
response := CertificateResponse{
ID: cert.ID,
Subject: cert.Subject,
Issuer: cert.Issuer,
NotBefore: cert.NotBefore.Format("2006-01-02T15:04:05Z"),
NotAfter: cert.NotAfter.Format("2006-01-02T15:04:05Z"),
Revoked: cert.Revoked,
}
if cert.Cert != nil {
response.SerialNumber = cert.Cert.SerialNumber.String()
response.Certificate = base64.StdEncoding.EncodeToString(cert.Cert.Raw)
}
c.JSON(http.StatusOK, gin.H{"certificate": response})
}
// RevokeCertificate révoque un certificat
func RevokeCertificate(c *gin.Context) {
var req struct {
CertificateID string `json:"certificate_id" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cert, err := certificateStore.GetCertificate(req.CertificateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificat non trouvé"})
return
}
cert.Revoked = true
certificateStore.SaveCertificate(req.CertificateID, cert)
c.JSON(http.StatusOK, gin.H{
"message": "certificat révoqué",
"id": req.CertificateID,
"reason": req.Reason,
})
}
// GetCRL retourne la liste de révocation
func GetCRL(c *gin.Context) {
certs := certificateStore.ListCertificates()
var revokedCerts []gin.H
for _, cert := range certs {
if cert.Revoked && cert.Cert != nil {
revokedCerts = append(revokedCerts, gin.H{
"serial_number": cert.Cert.SerialNumber.String(),
"subject": cert.Subject,
})
}
}
c.JSON(http.StatusOK, gin.H{
"crl": revokedCerts,
"version": 1,
})
}

53
internal/api/router.go Normal file
View File

@@ -0,0 +1,53 @@
package api
import (
"os"
"github.com/gin-gonic/gin"
"github.com/stef/pkiapi/internal/auth"
"github.com/stef/pkiapi/internal/storage"
)
// RegisterRoutesWithStore enregistre les routes avec un store personnalisé
func RegisterRoutesWithStore(router *gin.Engine, caStore storage.CertificateStore, certStore storage.CertificateStore) {
// Initialiser les stores
InitCAStore(caStore)
InitCertificateStore(certStore)
// Initialiser le JWT manager
secretKey := os.Getenv("JWT_SECRET_KEY")
if secretKey == "" {
secretKey = "your-secret-key-change-in-prod"
}
jwtManager := auth.NewJWTManager(secretKey)
// Endpoints publics
router.POST("/api/v1/login", Login)
// Group pour l'API v1 avec authentification
v1 := router.Group("/api/v1")
v1.Use(auth.AuthMiddleware(jwtManager))
{
// Endpoints CA
v1.GET("/ca", ListCAs)
v1.POST("/ca", CreateCA)
v1.GET("/ca/:id", GetCA)
v1.POST("/ca/sign", SignSubCA)
// Endpoints Certificats
v1.GET("/certificates", ListCertificates)
v1.POST("/certificates", CreateCertificate)
v1.POST("/certificates/sign", SignCertificateWithCA)
v1.GET("/certificates/:id", GetCertificate)
v1.POST("/revoke", RevokeCertificate)
// Endpoints CRL
v1.GET("/crl", GetCRL)
}
}
// RegisterRoutes enregistre les routes avec un store mémoire (compatibilité)
func RegisterRoutes(router *gin.Engine) {
store := storage.NewMemoryStore()
RegisterRoutesWithStore(router, store, store)
}

77
internal/auth/jwt.go Normal file
View File

@@ -0,0 +1,77 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrInvalidToken = errors.New("token invalide")
ErrExpiredToken = errors.New("token expiré")
)
// Claims représente les données du token JWT
type Claims struct {
UserID string `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// JWTManager gère la génération et validation des tokens
type JWTManager struct {
secretKey string
}
// NewJWTManager crée un nouveau gestionnaire JWT
func NewJWTManager(secretKey string) *JWTManager {
return &JWTManager{
secretKey: secretKey,
}
}
// GenerateToken génère un token JWT
func (m *JWTManager) GenerateToken(userID, role string, expiresIn time.Duration) (string, error) {
claims := Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "pkiapi",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(m.secretKey))
if err != nil {
return "", err
}
return tokenString, nil
}
// ValidateToken valide et parse un token JWT
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return []byte(m.secretKey), nil
})
if err != nil {
return nil, ErrInvalidToken
}
if !token.Valid {
return nil, ErrInvalidToken
}
// Vérifier l'expiration
if claims.ExpiresAt != nil && claims.ExpiresAt.Before(time.Now()) {
return nil, ErrExpiredToken
}
return claims, nil
}

View File

@@ -0,0 +1,73 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// AuthMiddleware vérifie le token JWT dans les headers
func AuthMiddleware(jwtManager *JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
// Extraire le token du header Authorization
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token manquant"})
c.Abort()
return
}
// Format: "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "format token invalide"})
c.Abort()
return
}
tokenString := parts[1]
// Valider le token
claims, err := jwtManager.ValidateToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
c.Abort()
return
}
// Stocker les claims dans le contexte
c.Set("user_id", claims.UserID)
c.Set("role", claims.Role)
c.Next()
}
}
// OptionalAuthMiddleware vérifie le token s'il est présent, mais ne bloque pas sans
func OptionalAuthMiddleware(jwtManager *JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Next()
return
}
tokenString := parts[1]
claims, err := jwtManager.ValidateToken(tokenString)
if err != nil {
c.Next()
return
}
c.Set("user_id", claims.UserID)
c.Set("role", claims.Role)
c.Next()
}
}

38
internal/config/config.go Normal file
View File

@@ -0,0 +1,38 @@
package config
import (
"os"
)
// Config contient les paramètres de configuration de l'API
type Config struct {
// HTTP
Port string
// JWT
JWTSecretKey string
// Storage
StorageType string // "memory" ou "mongodb"
MongoURI string
MongoDB string
}
// LoadConfig charge la configuration depuis les variables d'environnement
func LoadConfig() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
JWTSecretKey: getEnv("JWT_SECRET_KEY", "your-secret-key-change-in-prod"),
StorageType: getEnv("STORAGE_TYPE", "memory"), // "memory" ou "mongodb"
MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"),
MongoDB: getEnv("MONGO_DB", "pkiapi"),
}
}
// getEnv obtient une variable d'environnement ou retourne une valeur par défaut
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

236
internal/pki/certificate.go Normal file
View File

@@ -0,0 +1,236 @@
package pki
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"time"
)
// Certificate représente un certificat X.509
type Certificate struct {
ID string
Subject string
Issuer string
NotBefore time.Time
NotAfter time.Time
PublicKey *rsa.PublicKey
PrivateKey *rsa.PrivateKey // Stocké pour les CAs
Cert *x509.Certificate
Revoked bool
IsCA bool // True si c'est une autorité de certification
}
// GenerateCertificate crée un nouveau certificat auto-signé
func GenerateCertificate(subject string, validityDays int) (*Certificate, error) {
// Générer une clé RSA
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
// Créer le certificat
serialNumber, _ := rand.Int(rand.Reader, big.NewInt(1000000))
notBefore := time.Now()
notAfter := notBefore.AddDate(0, 0, validityDays)
cert := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: subject,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
}
// Auto-signer le certificat
certBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, err
}
parsedCert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, err
}
return &Certificate{
Subject: subject,
Issuer: subject,
NotBefore: notBefore,
NotAfter: notAfter,
PublicKey: &privateKey.PublicKey,
PrivateKey: privateKey,
Cert: parsedCert,
Revoked: false,
IsCA: false,
}, nil
}
// GenerateCA crée une nouvelle autorité de certification
func GenerateCA(subject string, validityDays int) (*Certificate, error) {
// Générer une clé RSA
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
// Créer le certificat CA
serialNumber, _ := rand.Int(rand.Reader, big.NewInt(1000000000))
notBefore := time.Now()
notAfter := notBefore.AddDate(0, 0, validityDays)
cert := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: subject,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: -1, // Pas de limite de chaîne
}
// Auto-signer le certificat CA
certBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, err
}
parsedCert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, err
}
return &Certificate{
Subject: subject,
Issuer: subject,
NotBefore: notBefore,
NotAfter: notAfter,
PublicKey: &privateKey.PublicKey,
PrivateKey: privateKey,
Cert: parsedCert,
Revoked: false,
IsCA: true,
}, nil
}
// SignCertificate signe un certificat avec une CA
func SignCertificate(caCert *Certificate, subject string, validityDays int) (*Certificate, error) {
if !caCert.IsCA {
return nil, ErrNotACA
}
if caCert.PrivateKey == nil {
return nil, ErrMissingPrivateKey
}
// Générer une clé RSA pour le nouveau certificat
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
// Créer le certificat à signer
serialNumber, _ := rand.Int(rand.Reader, big.NewInt(1000000000))
notBefore := time.Now()
notAfter := notBefore.AddDate(0, 0, validityDays)
cert := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: subject,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
Issuer: caCert.Cert.Subject,
}
// Signer le certificat avec la clé privée de la CA
certBytes, err := x509.CreateCertificate(rand.Reader, cert, caCert.Cert, &privateKey.PublicKey, caCert.PrivateKey)
if err != nil {
return nil, err
}
parsedCert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, err
}
return &Certificate{
Subject: subject,
Issuer: caCert.Subject,
NotBefore: notBefore,
NotAfter: notAfter,
PublicKey: &privateKey.PublicKey,
PrivateKey: privateKey,
Cert: parsedCert,
Revoked: false,
IsCA: false,
}, nil
}
// SignSubCA signe une CA intermédiaire avec une CA parent
func SignSubCA(parentCA *Certificate, subject string, validityDays int) (*Certificate, error) {
if !parentCA.IsCA {
return nil, ErrNotACA
}
if parentCA.PrivateKey == nil {
return nil, ErrMissingPrivateKey
}
// Générer une clé RSA pour la sub-CA
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
// Créer le certificat CA intermédiaire
serialNumber, _ := rand.Int(rand.Reader, big.NewInt(1000000000))
notBefore := time.Now()
notAfter := notBefore.AddDate(0, 0, validityDays)
subCA := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: subject,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 0, // Limite la chaîne : cette CA ne peut pas signer d'autres CAs
Issuer: parentCA.Cert.Subject,
}
// Signer le certificat CA avec la clé privée du parent
certBytes, err := x509.CreateCertificate(rand.Reader, subCA, parentCA.Cert, &privateKey.PublicKey, parentCA.PrivateKey)
if err != nil {
return nil, err
}
parsedCert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, err
}
return &Certificate{
Subject: subject,
Issuer: parentCA.Subject,
NotBefore: notBefore,
NotAfter: notAfter,
PublicKey: &privateKey.PublicKey,
PrivateKey: privateKey,
Cert: parsedCert,
Revoked: false,
IsCA: true,
}, nil
}

8
internal/pki/errors.go Normal file
View File

@@ -0,0 +1,8 @@
package pki
import "errors"
var (
ErrNotACA = errors.New("n'est pas une autorité de certification")
ErrMissingPrivateKey = errors.New("clé privée manquante")
)

View File

@@ -0,0 +1,7 @@
package storage
import "errors"
var (
ErrNotFound = errors.New("certificat non trouvé")
)

View File

@@ -0,0 +1,18 @@
package storage
import "github.com/stef/pkiapi/internal/pki"
// CertificateStore définit l'interface pour le stockage des certificats
type CertificateStore interface {
// SaveCertificate sauvegarde ou met à jour un certificat
SaveCertificate(id string, cert *pki.Certificate) error
// GetCertificate récupère un certificat par ID
GetCertificate(id string) (*pki.Certificate, error)
// ListCertificates retourne tous les certificats
ListCertificates() []*pki.Certificate
// Close ferme la connexion au store
Close() error
}

232
internal/storage/mongo.go Normal file
View File

@@ -0,0 +1,232 @@
package storage
import (
"context"
"crypto/rsa"
"encoding/base64"
"time"
"github.com/stef/pkiapi/internal/pki"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// MongoStore gère le stockage des certificats dans MongoDB
type MongoStore struct {
client *mongo.Client
collection *mongo.Collection
}
// CertificateDoc représente un certificat stocké dans MongoDB
type CertificateDoc struct {
ID string `bson:"_id"`
Subject string `bson:"subject"`
Issuer string `bson:"issuer"`
NotBefore time.Time `bson:"not_before"`
NotAfter time.Time `bson:"not_after"`
IsCA bool `bson:"is_ca"`
Revoked bool `bson:"revoked"`
Cert string `bson:"cert"` // Base64 encoded certificate
PrivateKey string `bson:"private_key"` // Base64 encoded private key (only for CAs)
CreatedAt time.Time `bson:"created_at"`
}
// NewMongoStore crée une connexion MongoDB et retourne un MongoStore
func NewMongoStore(mongoURI string, dbName string) (*MongoStore, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
if err != nil {
return nil, err
}
// Tester la connexion
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx, nil); err != nil {
return nil, err
}
collection := client.Database(dbName).Collection("certificates")
// Créer un index sur l'ID
indexModel := mongo.IndexModel{
Keys: bson.D{{Key: "_id", Value: 1}},
}
_, err = collection.Indexes().CreateOne(ctx, indexModel)
if err != nil {
return nil, err
}
return &MongoStore{
client: client,
collection: collection,
}, nil
}
// SaveCertificate sauvegarde un certificat dans MongoDB
func (m *MongoStore) SaveCertificate(id string, cert *pki.Certificate) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
doc := CertificateDoc{
ID: id,
Subject: cert.Subject,
Issuer: cert.Issuer,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
IsCA: cert.IsCA,
Revoked: cert.Revoked,
CreatedAt: time.Now(),
}
// Encoder le certificat en base64
if cert.Cert != nil {
doc.Cert = base64.StdEncoding.EncodeToString(cert.Cert.Raw)
}
// Encoder la clé privée en base64 (seulement pour les CAs)
if cert.PrivateKey != nil && cert.IsCA {
privKeyBytes, err := marshalPrivateKey(cert.PrivateKey)
if err != nil {
return err
}
doc.PrivateKey = base64.StdEncoding.EncodeToString(privKeyBytes)
}
_, err := m.collection.UpdateOne(
ctx,
bson.M{"_id": id},
bson.M{"$set": doc},
options.Update().SetUpsert(true),
)
return err
}
// GetCertificate récupère un certificat depuis MongoDB
func (m *MongoStore) GetCertificate(id string) (*pki.Certificate, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc CertificateDoc
err := m.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// Décoder le certificat
cert := &pki.Certificate{
ID: doc.ID,
Subject: doc.Subject,
Issuer: doc.Issuer,
NotBefore: doc.NotBefore,
NotAfter: doc.NotAfter,
IsCA: doc.IsCA,
Revoked: doc.Revoked,
}
// Décoder le certificat X.509
if doc.Cert != "" {
certBytes, err := base64.StdEncoding.DecodeString(doc.Cert)
if err != nil {
return nil, err
}
parsedCert, err := parseCertificate(certBytes)
if err != nil {
return nil, err
}
cert.Cert = parsedCert
}
// Décoder la clé privée
if doc.PrivateKey != "" {
privKeyBytes, err := base64.StdEncoding.DecodeString(doc.PrivateKey)
if err != nil {
return nil, err
}
privKey, err := unmarshalPrivateKey(privKeyBytes)
if err != nil {
return nil, err
}
// Type assertion pour convertir interface{} en *rsa.PrivateKey
rsaKey, ok := privKey.(*rsa.PrivateKey)
if !ok {
// Si ce n'est pas une clé RSA, on la laisse nil (CAs sans clé privée)
cert.PrivateKey = nil
} else {
cert.PrivateKey = rsaKey
}
}
return cert, nil
}
// ListCertificates retourne tous les certificats depuis MongoDB
func (m *MongoStore) ListCertificates() []*pki.Certificate {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cursor, err := m.collection.Find(ctx, bson.M{})
if err != nil {
return []*pki.Certificate{}
}
defer cursor.Close(ctx)
var certs []*pki.Certificate
if err = cursor.All(ctx, &certs); err != nil {
return []*pki.Certificate{}
}
// Reconvertir les documents en certificats
var results []*pki.Certificate
if err = cursor.All(ctx, &[]CertificateDoc{}); err != nil {
return []*pki.Certificate{}
}
cursor, _ = m.collection.Find(ctx, bson.M{})
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var doc CertificateDoc
if err := cursor.Decode(&doc); err != nil {
continue
}
cert := &pki.Certificate{
ID: doc.ID,
Subject: doc.Subject,
Issuer: doc.Issuer,
NotBefore: doc.NotBefore,
NotAfter: doc.NotAfter,
IsCA: doc.IsCA,
Revoked: doc.Revoked,
}
// Décoder le certificat X.509
if doc.Cert != "" {
certBytes, _ := base64.StdEncoding.DecodeString(doc.Cert)
if parsedCert, _ := parseCertificate(certBytes); parsedCert != nil {
cert.Cert = parsedCert
}
}
results = append(results, cert)
}
return results
}
// Close ferme la connexion MongoDB
func (m *MongoStore) Close() error {
if m.client != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return m.client.Disconnect(ctx)
}
return nil
}

View File

@@ -0,0 +1,66 @@
package storage
import (
"crypto/rand"
"crypto/rsa"
"testing"
"github.com/stef/pkiapi/internal/pki"
)
// TestStorageConfiguration teste que la config est correctement chargée
func TestStorageConfiguration(t *testing.T) {
// Test MemoryStore
memStore := NewMemoryStore()
if memStore == nil {
t.Fatalf("MemoryStore nil")
}
testCert := &pki.Certificate{
ID: "test-123",
Subject: "CN=test.example.com",
Issuer: "CN=test.example.com",
IsCA: false,
}
memStore.SaveCertificate("test-123", testCert)
retrieved, err := memStore.GetCertificate("test-123")
if err != nil {
t.Fatalf("Erreur récupération certificat: %v", err)
}
if retrieved.Subject != "CN=test.example.com" {
t.Fatalf("Certificate Subject mismatch")
}
t.Log("✓ MemoryStore OK")
memStore.Close()
}
// TestKeyMarshalling teste la sérialisation des clés RSA
func TestKeyMarshalling(t *testing.T) {
// Créer une clé RSA de test
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Erreur génération clé: %v", err)
}
// Test marshalling privée clé
marshalledKey, err := marshalPrivateKey(privateKey)
if err != nil {
t.Fatalf("Erreur marshalling clé: %v", err)
}
// Test unmarshalling clé privée
unmarshalledKey, err := unmarshalPrivateKey(marshalledKey)
if err != nil {
t.Fatalf("Erreur unmarshalling clé: %v", err)
}
_, ok := unmarshalledKey.(*rsa.PrivateKey)
if !ok {
t.Fatalf("Type assertion failed, expected *rsa.PrivateKey")
}
t.Log("✓ Sérialisation/désérialisation des clés RSA OK")
}

61
internal/storage/store.go Normal file
View File

@@ -0,0 +1,61 @@
package storage
import (
"sync"
"github.com/stef/pkiapi/internal/pki"
)
// MemoryStore gère le stockage en mémoire des certificats
type MemoryStore struct {
mu sync.RWMutex
certificates map[string]*pki.Certificate
}
// NewMemoryStore crée une nouvelle instance de MemoryStore
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
certificates: make(map[string]*pki.Certificate),
}
}
// SaveCertificate sauvegarde un certificat
func (s *MemoryStore) SaveCertificate(id string, cert *pki.Certificate) error {
s.mu.Lock()
defer s.mu.Unlock()
s.certificates[id] = cert
return nil
}
// GetCertificate récupère un certificat par ID
func (s *MemoryStore) GetCertificate(id string) (*pki.Certificate, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cert, exists := s.certificates[id]
if !exists {
return nil, ErrNotFound
}
return cert, nil
}
// ListCertificates retourne tous les certificats
func (s *MemoryStore) ListCertificates() []*pki.Certificate {
s.mu.RLock()
defer s.mu.RUnlock()
certs := make([]*pki.Certificate, 0, len(s.certificates))
for _, cert := range s.certificates {
certs = append(certs, cert)
}
return certs
}
// Close ferme le store (ne fait rien pour le store mémoire)
func (s *MemoryStore) Close() error {
return nil
}
// Deprecated: NewStore utilise maintenant NewMemoryStore
// Conservé pour compatibilité
func NewStore() CertificateStore {
return NewMemoryStore()
}

37
internal/storage/util.go Normal file
View File

@@ -0,0 +1,37 @@
package storage
import (
"crypto/x509"
"encoding/pem"
)
// marshalPrivateKey encode une clé privée en bytes
func marshalPrivateKey(privateKey interface{}) ([]byte, error) {
privKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, err
}
return privKeyBytes, nil
}
// unmarshalPrivateKey décode une clé privée depuis bytes
func unmarshalPrivateKey(privKeyBytes []byte) (interface{}, error) {
return x509.ParsePKCS8PrivateKey(privKeyBytes)
}
// parseCertificate parse un certificat X.509
func parseCertificate(certBytes []byte) (*x509.Certificate, error) {
// Essayer de parser en DER
cert, err := x509.ParseCertificate(certBytes)
if err == nil {
return cert, nil
}
// Essayer de parser en PEM
block, _ := pem.Decode(certBytes)
if block != nil {
return x509.ParseCertificate(block.Bytes)
}
return nil, err
}