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 APImain
commit
ddece00272
|
|
@ -0,0 +1,173 @@
|
|||
# PKI API - Instructions pour les Agents IA
|
||||
|
||||
## Architecture Générale
|
||||
|
||||
Cette API Go gère une Infrastructure à Clé Publique (PKI) complète :
|
||||
|
||||
- **API REST** (port 8080, framework Gin) : Endpoints pour CA, certificats, révocation
|
||||
- **Authentication JWT** : Tous les endpoints (sauf `/login`) nécessitent un token JWT valide
|
||||
- **Storage** (`internal/storage/`) : Abstraction de stockage avec implémentations multiples
|
||||
- **MemoryStore** : Stockage en mémoire (développement, tests)
|
||||
- **MongoStore** : Persistance MongoDB (production)
|
||||
- **PKI Core** (`internal/pki/`) : Logique crypto (génération certificats, validation)
|
||||
- **Auth** (`internal/auth/`) : Gestion JWT et middleware
|
||||
- **Config** (`internal/config/`) : Gestion de la configuration centralisée
|
||||
- **Handlers** (`internal/api/router.go`) : Points d'entrée HTTP
|
||||
|
||||
## Conventions du Projet
|
||||
|
||||
### Structure des Répertoires
|
||||
- `cmd/` : Point d'entrée application
|
||||
- `internal/` : Code privé du package
|
||||
- `pkg/` : Code réutilisable public
|
||||
- `internal/api/` : Handlers Gin et routage
|
||||
- `internal/auth/` : JWT manager et middlewares
|
||||
- `internal/pki/` : Logique métier PKI (crypto, certificats)
|
||||
- `internal/storage/` : Abstraction persistance
|
||||
|
||||
### Patterns de Code
|
||||
|
||||
**Gestion des Erreurs** : Utiliser les patterns classiques Go
|
||||
```go
|
||||
if err != nil {
|
||||
return nil, err // Propager sans wrapper sauf cas spécial
|
||||
}
|
||||
```
|
||||
|
||||
**Concurrence** : `sync.RWMutex` pour le Store (voir `internal/storage/store.go`)
|
||||
|
||||
**Certificats X.509** : Utiliser `crypto/x509`, `crypto/rsa`, `crypto/rand` (stdlib)
|
||||
|
||||
**JWT** : Utiliser `github.com/golang-jwt/jwt/v5` avec ClaimsValidator intégré
|
||||
|
||||
### Authentification JWT
|
||||
|
||||
**Structure des Claims** :
|
||||
```go
|
||||
type Claims struct {
|
||||
UserID string
|
||||
Role string
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
```
|
||||
|
||||
**Middleware** :
|
||||
- `AuthMiddleware(jwtManager)` : Bloque les requêtes sans token valide
|
||||
- `OptionalAuthMiddleware(jwtManager)` : Accepte les requêtes sans token
|
||||
|
||||
**Endpoints non sécurisés** :
|
||||
- `POST /api/v1/login` : Obtenir un token
|
||||
|
||||
**Tous les autres endpoints** : Nécessitent l'header `Authorization: Bearer <token>`
|
||||
|
||||
## Workflows Critiques
|
||||
|
||||
### Compiler et Lancer
|
||||
```bash
|
||||
go mod download
|
||||
go build -o pkiapi ./cmd/main.go
|
||||
./pkiapi
|
||||
```
|
||||
|
||||
### Tester avec authentification
|
||||
```bash
|
||||
# 1. Obtenir un token
|
||||
TOKEN=$(curl -X POST http://localhost:8080/api/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
|
||||
|
||||
# 2. Utiliser le token
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/certificates
|
||||
```
|
||||
|
||||
## Points d'Intégration Clés
|
||||
|
||||
- **Gin Router** : Tous les endpoints déclarés dans `RegisterRoutes()`, JWT manager injecté
|
||||
- **Store singleton** : À implémenter comme dépendance injectée dans handlers
|
||||
- **Crypto stdlib** : Pas de dépendances externes pour cryptographie
|
||||
- **JWT Manager** : Créé dans `RegisterRoutes()`, disponible en middleware
|
||||
|
||||
## Variables d'Environnement
|
||||
|
||||
### Configuration Générale
|
||||
- `PORT` : Port du serveur (défaut: `8080`)
|
||||
- `JWT_SECRET_KEY` : Secret pour signer les tokens (défaut: `your-secret-key-change-in-prod`)
|
||||
|
||||
### Configuration du Stockage
|
||||
- `STORAGE_TYPE` : Type de stockage (`memory` ou `mongodb`, défaut: `memory`)
|
||||
- `MONGO_URI` : URI de connexion MongoDB (défaut: `mongodb://localhost:27017`)
|
||||
- `MONGO_DB` : Nom de la base de données MongoDB (défaut: `pkiapi`)
|
||||
|
||||
### Exemples de Configuration
|
||||
|
||||
**Mode Développement (MemoryStore)**
|
||||
```bash
|
||||
export STORAGE_TYPE=memory
|
||||
export PORT=8080
|
||||
./pkiapi
|
||||
```
|
||||
|
||||
**Mode Production (MongoDB)**
|
||||
```bash
|
||||
export STORAGE_TYPE=mongodb
|
||||
export MONGO_URI=mongodb://mongodb-server:27017
|
||||
export MONGO_DB=pkiapi-prod
|
||||
export JWT_SECRET_KEY=super-secret-key
|
||||
./pkiapi
|
||||
```
|
||||
|
||||
## Couche de Stockage
|
||||
|
||||
### Architecture Abstraction
|
||||
La couche stockage utilise une interface `CertificateStore` pour permettre l'injection de dépendances :
|
||||
|
||||
```go
|
||||
type CertificateStore interface {
|
||||
SaveCertificate(id string, cert *pki.Certificate) error
|
||||
GetCertificate(id string) (*pki.Certificate, error)
|
||||
ListCertificates() []*pki.Certificate
|
||||
Close() error
|
||||
}
|
||||
```
|
||||
|
||||
### Implémentations
|
||||
|
||||
#### MemoryStore
|
||||
- Utilise `sync.RWMutex` pour la concurrence
|
||||
- Stockage en mémoire avec `map[string]*pki.Certificate`
|
||||
- Approprié pour développement et tests
|
||||
- Données perdues à l'arrêt du serveur
|
||||
|
||||
#### MongoStore
|
||||
- Driver officiel MongoDB (`go.mongodb.org/mongo-driver`)
|
||||
- Collection `certificates` avec indexation automatique
|
||||
- Sérialisation:
|
||||
- Clés privées RSA: PKCS#8 + base64
|
||||
- Certificats X.509: DER + base64
|
||||
- Métadonnées: BSON natif
|
||||
- Contextes avec timeout 10s pour fiabilité
|
||||
- Approprié pour production avec haute disponibilité
|
||||
|
||||
### Utilisation dans les Handlers
|
||||
```go
|
||||
// Dans main.go
|
||||
cfg := config.LoadConfig()
|
||||
if cfg.StorageType == "mongodb" {
|
||||
mongoStore, _ := storage.NewMongoStore(cfg.MongoURI, cfg.MongoDB)
|
||||
api.RegisterRoutesWithStore(router, mongoStore, mongoStore)
|
||||
} else {
|
||||
memStore := storage.NewMemoryStore()
|
||||
api.RegisterRoutesWithStore(router, memStore, memStore)
|
||||
}
|
||||
```
|
||||
|
||||
## Domaines à Implémenter
|
||||
|
||||
1. **CA Management** ✅ : Création/gestion autorités de certification
|
||||
2. **Certificate Lifecycle** ✅ : Génération, validation, révocation (avec authentification)
|
||||
3. **CRL/OCSP** ✅ : Listes révocation et vérification d'état
|
||||
4. **Persistence** ✅ : Stockage MongoDB avec abstraction pluggable
|
||||
5. **User Management** : Stocker users/credentials au lieu de simples identifiants
|
||||
6. **OCSP Server** : Répondeur OCSP pour vérification d'état en temps réel
|
||||
7. **HSM Integration** : Support matériel de sécurité pour clés privées
|
||||
8. **Audit Logging** : Journalisation complète des opérations
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Binaires
|
||||
pkiapi
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Répertoires temporaires
|
||||
/bin
|
||||
/dist
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Go
|
||||
go.sum
|
||||
vendor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
*.log
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Build stage
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# Installer git pour go mod download
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copier les fichiers go.mod et go.sum
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Télécharger les dépendances
|
||||
RUN go mod download
|
||||
|
||||
# Copier le code source
|
||||
COPY . .
|
||||
|
||||
# Builder l'application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o pkiapi ./cmd/main.go
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Installer les certificats pour les connexions HTTPS/MongoDB
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copier l'exécutable depuis le builder
|
||||
COPY --from=builder /app/pkiapi .
|
||||
|
||||
# Exposer le port
|
||||
EXPOSE 8080
|
||||
|
||||
# Variables d'environnement par défaut
|
||||
ENV PORT=8080
|
||||
ENV STORAGE_TYPE=mongodb
|
||||
ENV MONGO_URI=mongodb://mongo:27017
|
||||
ENV MONGO_DB=pkiapi
|
||||
ENV JWT_SECRET_KEY=change-this-in-production
|
||||
|
||||
# Lancer l'application
|
||||
CMD ["./pkiapi"]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
.PHONY: build run test clean install-deps
|
||||
|
||||
install-deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
build: install-deps
|
||||
go build -o pkiapi ./cmd/main.go
|
||||
|
||||
run: build
|
||||
./pkiapi
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -f pkiapi
|
||||
go clean
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
# PKI API
|
||||
|
||||
API Go complète pour gérer une Infrastructure à Clé Publique (PKI) avec hiérarchie de certificats et persistance MongoDB.
|
||||
|
||||
## Caractéristiques
|
||||
|
||||
- ✅ **Authentification JWT** : Tous les endpoints protégés par JWT
|
||||
- ✅ **Hiérarchie CA** : Support des Root CA, Intermediate CA (Sub-CA) et certificats finaux
|
||||
- ✅ **Signature de certificats** : Certificats auto-signés ou signés par une CA
|
||||
- ✅ **Gestion des révocations** : Révocation et CRL (Certificate Revocation List)
|
||||
- ✅ **Stockage pluggable** : MemoryStore (développement) ou MongoDB (production)
|
||||
- ✅ **Cryptographie** : X.509, RSA 2048-bit, signatures HS256 pour JWT
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pkiapi/
|
||||
├── cmd/main.go # Point d'entrée avec config
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── router.go # Routage Gin
|
||||
│ │ ├── auth.go # Login endpoint
|
||||
│ │ ├── ca.go # Handlers CAs
|
||||
│ │ └── certificates.go # Handlers certificats
|
||||
│ ├── auth/
|
||||
│ │ ├── jwt.go # JWT manager
|
||||
│ │ └── middleware.go # Middleware d'authentification
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Configuration centralisée
|
||||
│ ├── pki/
|
||||
│ │ ├── certificate.go # Logique certificats X.509
|
||||
│ │ └── errors.go # Erreurs PKI
|
||||
│ └── storage/
|
||||
│ ├── interface.go # Interface CertificateStore
|
||||
│ ├── store.go # MemoryStore (en mémoire)
|
||||
│ ├── mongo.go # MongoStore (persistance)
|
||||
│ ├── util.go # Helpers sérialisation
|
||||
│ └── errors.go # Erreurs storage
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### 1. Installer et compiler
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
go build -o pkiapi ./cmd/main.go
|
||||
```
|
||||
|
||||
### 2. Démarrer le serveur
|
||||
|
||||
**Mode développement (MemoryStore):**
|
||||
```bash
|
||||
export STORAGE_TYPE=memory
|
||||
export PORT=8080
|
||||
./pkiapi
|
||||
# Serveur lancé sur http://localhost:8080
|
||||
```
|
||||
|
||||
**Mode production (MongoDB):**
|
||||
```bash
|
||||
export STORAGE_TYPE=mongodb
|
||||
export MONGO_URI=mongodb://mongodb-server:27017
|
||||
export MONGO_DB=pkiapi-prod
|
||||
export JWT_SECRET_KEY=super-secret-key
|
||||
./pkiapi
|
||||
```
|
||||
|
||||
### 3. Obtenir un token JWT
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin"}'
|
||||
|
||||
# Réponse:
|
||||
# {
|
||||
# "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
# "expires_in": 86400
|
||||
# }
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 🔐 Authentification (Public)
|
||||
|
||||
#### POST /api/v1/login
|
||||
Obtient un token JWT pour accéder aux autres endpoints.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "admin"
|
||||
}'
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"expires_in": 86400
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔑 Autorités de Certification (Authentifiés)
|
||||
|
||||
#### GET /api/v1/ca
|
||||
Liste toutes les autorités de certification.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/v1/ca
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"cas": [
|
||||
{
|
||||
"id": "16de28da-f25e-49cd-81de-a929d34dfe08",
|
||||
"subject": "CN=Root CA,O=Example,C=FR",
|
||||
"issuer": "CN=Root CA,O=Example,C=FR",
|
||||
"not_before": "2025-12-06T22:52:48Z",
|
||||
"not_after": "2035-12-04T22:52:48Z",
|
||||
"serial_number": "574847517"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/v1/ca
|
||||
Crée une nouvelle autorité de certification auto-signée.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
curl -X POST http://localhost:8080/api/v1/ca \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"subject": "CN=Root CA,O=Example Inc,C=FR",
|
||||
"validity_days": 3650
|
||||
}'
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"ca": {
|
||||
"id": "ff3ac5c5-08d1-401b-9e83-f18eda4c538b",
|
||||
"subject": "CN=Root CA,O=Example Inc,C=FR",
|
||||
"not_before": "2025-12-06T21:45:01Z",
|
||||
"not_after": "2035-12-04T21:45:01Z",
|
||||
"serial_number": "546965196",
|
||||
"certificate": "MIIC5zCCAc+gAwIBAgIDCkUz...",
|
||||
"is_ca": true
|
||||
},
|
||||
"created_by": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/ca/:id
|
||||
Récupère une autorité de certification par ID.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
CA_ID="ff3ac5c5-08d1-401b-9e83-f18eda4c538b"
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/v1/ca/$CA_ID
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"ca": {
|
||||
"id": "ff3ac5c5-08d1-401b-9e83-f18eda4c538b",
|
||||
"subject": "CN=Root CA,O=Example Inc,C=FR",
|
||||
"not_before": "2025-12-06T21:45:01Z",
|
||||
"not_after": "2035-12-04T21:45:01Z",
|
||||
"serial_number": "546965196",
|
||||
"certificate": "MIIC5zCCAc+gAwIBAgIDCkUz...",
|
||||
"is_ca": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/v1/ca/sign
|
||||
Crée une CA intermédiaire (Sub-CA) signée par une CA parent.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
PARENT_CA_ID="ff3ac5c5-08d1-401b-9e83-f18eda4c538b"
|
||||
curl -X POST http://localhost:8080/api/v1/ca/sign \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"parent_ca_id\": \"$PARENT_CA_ID\",
|
||||
\"subject\": \"CN=Intermediate CA,O=Example Inc,C=FR\",
|
||||
\"validity_days\": 1825
|
||||
}"
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"ca": {
|
||||
"id": "b2350d39-53c2-469a-802c-acc39707e352",
|
||||
"subject": "CN=Intermediate CA,O=Example Inc,C=FR",
|
||||
"not_before": "2025-12-06T21:45:09Z",
|
||||
"not_after": "2030-12-05T21:45:09Z",
|
||||
"serial_number": "576310632",
|
||||
"certificate": "MIIDOTCCAiGgAwIBAgIEIlnNaD...",
|
||||
"is_ca": true
|
||||
},
|
||||
"created_by": "admin",
|
||||
"signed_by": "ff3ac5c5-08d1-401b-9e83-f18eda4c538b"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 📜 Certificats (Authentifiés)
|
||||
|
||||
#### GET /api/v1/certificates
|
||||
Liste tous les certificats.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/v1/certificates
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"certificates": [
|
||||
{
|
||||
"id": "e12e08a9-adeb-404c-a7b7-a613b77dfe66",
|
||||
"subject": "CN=server.example.com,O=Example Inc,C=FR",
|
||||
"issuer": "CN=Intermediate CA,O=Example Inc,C=FR",
|
||||
"not_before": "2025-12-06T21:45:09Z",
|
||||
"not_after": "2026-12-06T21:45:09Z",
|
||||
"serial_number": "46798982",
|
||||
"revoked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/v1/certificates
|
||||
Crée un certificat auto-signé.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
curl -X POST http://localhost:8080/api/v1/certificates \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"subject": "CN=example.com,O=Example Inc,C=FR",
|
||||
"validity_days": 365
|
||||
}'
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"certificate": {
|
||||
"id": "8e77050f-d19f-4607-8c49-b974c5f9cb08",
|
||||
"subject": "CN=example.com,O=Example Inc,C=FR",
|
||||
"issuer": "CN=example.com,O=Example Inc,C=FR",
|
||||
"not_before": "2025-12-06T21:41:38Z",
|
||||
"not_after": "2026-12-06T21:41:38Z",
|
||||
"serial_number": "673075",
|
||||
"certificate": "MIIC5zCCAc+gAwIBAgIDCkUzMA0GCSq...",
|
||||
"revoked": false
|
||||
},
|
||||
"created_by": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/v1/certificates/sign
|
||||
Signe un certificat avec une CA.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
CA_ID="b2350d39-53c2-469a-802c-acc39707e352"
|
||||
curl -X POST http://localhost:8080/api/v1/certificates/sign \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"ca_id\": \"$CA_ID\",
|
||||
\"subject\": \"CN=server.example.com,O=Example Inc,C=FR\",
|
||||
\"validity_days\": 365
|
||||
}"
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"certificate": {
|
||||
"id": "e12e08a9-adeb-404c-a7b7-a613b77dfe66",
|
||||
"subject": "CN=server.example.com,O=Example Inc,C=FR",
|
||||
"issuer": "CN=Intermediate CA,O=Example Inc,C=FR",
|
||||
"not_before": "2025-12-06T21:45:09Z",
|
||||
"not_after": "2026-12-06T21:45:09Z",
|
||||
"serial_number": "46798982",
|
||||
"certificate": "MIIDFDCCAfygAwIBAgIEAsoYhjANBg...",
|
||||
"revoked": false
|
||||
},
|
||||
"created_by": "admin",
|
||||
"signed_by": "b2350d39-53c2-469a-802c-acc39707e352"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/certificates/:id
|
||||
Récupère un certificat par ID.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
CERT_ID="e12e08a9-adeb-404c-a7b7-a613b77dfe66"
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/v1/certificates/$CERT_ID
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"certificate": {
|
||||
"id": "e12e08a9-adeb-404c-a7b7-a613b77dfe66",
|
||||
"subject": "CN=server.example.com,O=Example Inc,C=FR",
|
||||
"issuer": "CN=Intermediate CA,O=Example Inc,C=FR",
|
||||
"not_before": "2025-12-06T21:45:09Z",
|
||||
"not_after": "2026-12-06T21:45:09Z",
|
||||
"serial_number": "46798982",
|
||||
"certificate": "MIIDFDCCAfygAwIBAgIEAsoYhjANBg...",
|
||||
"revoked": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/v1/revoke
|
||||
Révoque un certificat.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
CERT_ID="e12e08a9-adeb-404c-a7b7-a613b77dfe66"
|
||||
curl -X POST http://localhost:8080/api/v1/revoke \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"certificate_id\": \"$CERT_ID\",
|
||||
\"reason\": \"Compromised key\"
|
||||
}"
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"message": "certificat révoqué",
|
||||
"id": "e12e08a9-adeb-404c-a7b7-a613b77dfe66",
|
||||
"reason": "Compromised key"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/crl
|
||||
Récupère la Certificate Revocation List (liste des certificats révoqués).
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
TOKEN="<your_token>"
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/v1/crl
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"crl": [
|
||||
{
|
||||
"serial_number": "46798982",
|
||||
"subject": "CN=server.example.com,O=Example Inc,C=FR"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables d'Environnement
|
||||
|
||||
- `JWT_SECRET_KEY` : Secret pour signer les tokens JWT (défaut: `your-secret-key-change-in-prod`)
|
||||
|
||||
```bash
|
||||
export JWT_SECRET_KEY="your-secure-secret-key"
|
||||
./pkiapi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Exemple de flux complet
|
||||
|
||||
```bash
|
||||
# 1. Obtenir un token
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
|
||||
|
||||
echo "Token: $TOKEN"
|
||||
|
||||
# 2. Créer une Root CA
|
||||
ROOT_CA=$(curl -s -X POST http://localhost:8080/api/v1/ca \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"subject":"CN=Root CA,O=Example,C=FR","validity_days":3650}')
|
||||
|
||||
ROOT_CA_ID=$(echo $ROOT_CA | jq -r '.ca.id')
|
||||
echo "Root CA ID: $ROOT_CA_ID"
|
||||
|
||||
# 3. Créer une Sub-CA
|
||||
SUB_CA=$(curl -s -X POST http://localhost:8080/api/v1/ca/sign \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"parent_ca_id\":\"$ROOT_CA_ID\",\"subject\":\"CN=Intermediate CA,O=Example,C=FR\",\"validity_days\":1825}")
|
||||
|
||||
SUB_CA_ID=$(echo $SUB_CA | jq -r '.ca.id')
|
||||
echo "Sub-CA ID: $SUB_CA_ID"
|
||||
|
||||
# 4. Signer un certificat avec la Sub-CA
|
||||
CERT=$(curl -s -X POST http://localhost:8080/api/v1/certificates/sign \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ca_id\":\"$SUB_CA_ID\",\"subject\":\"CN=app.example.com,O=Example,C=FR\",\"validity_days\":365}")
|
||||
|
||||
CERT_ID=$(echo $CERT | jq -r '.certificate.id')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
|
||||
# 5. Lister toutes les CAs
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/ca | jq .
|
||||
|
||||
# 6. Lister tous les certificats
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/certificates | jq .
|
||||
|
||||
# 7. Révoquer le certificat
|
||||
curl -s -X POST http://localhost:8080/api/v1/revoke \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"certificate_id\":\"$CERT_ID\",\"reason\":\"Test\"}" | jq .
|
||||
|
||||
# 8. Voir la CRL
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/crl | jq .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
pkiapi/
|
||||
├── cmd/main.go # Point d'entrée
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── router.go # Routes Gin
|
||||
│ │ ├── auth.go # Login
|
||||
│ │ ├── ca.go # Handlers CA
|
||||
│ │ └── certificates.go # Handlers certificats
|
||||
│ ├── auth/
|
||||
│ │ ├── jwt.go # JWT manager
|
||||
│ │ └── middleware.go # Middleware JWT
|
||||
│ ├── pki/
|
||||
│ │ ├── certificate.go # Logique X.509
|
||||
│ │ └── errors.go # Erreurs PKI
|
||||
│ └── storage/
|
||||
│ ├── store.go # Store thread-safe
|
||||
│ └── errors.go # Erreurs storage
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── Makefile
|
||||
├── README.md
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conventions de code
|
||||
|
||||
- **Gestion des erreurs** : Propagation simple sans wrapper
|
||||
- **Concurrence** : `sync.RWMutex` pour le store
|
||||
- **Cryptographie** : Stdlib Go (crypto/x509, crypto/rsa, crypto/rand)
|
||||
- **JWT** : github.com/golang-jwt/jwt/v5
|
||||
|
||||
---
|
||||
|
||||
## Future améliorations
|
||||
|
||||
- [ ] Persistance en base de données (PostgreSQL)
|
||||
- [ ] Support OCSP (Online Certificate Status Protocol)
|
||||
- [ ] Interface web pour gérer les CAs
|
||||
- [ ] Export des certificats (PEM, DER)
|
||||
- [ ] Support des chaînes intermédiaires
|
||||
- [ ] Auditing et logging
|
||||
- [ ] Rate limiting et throttling
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stef/pkiapi/internal/api"
|
||||
"github.com/stef/pkiapi/internal/config"
|
||||
"github.com/stef/pkiapi/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Charger la configuration
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
// Initialiser le moteur Gin
|
||||
router := gin.Default()
|
||||
|
||||
// Initialiser le store
|
||||
var caStore storage.CertificateStore
|
||||
var certStore storage.CertificateStore
|
||||
|
||||
if cfg.StorageType == "mongodb" {
|
||||
// Connexion à MongoDB
|
||||
mongoStore, err := storage.NewMongoStore(cfg.MongoURI, cfg.MongoDB)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur connexion MongoDB: %v", err)
|
||||
}
|
||||
defer mongoStore.Close()
|
||||
caStore = mongoStore
|
||||
certStore = mongoStore
|
||||
fmt.Printf("✅ Connecté à MongoDB: %s\n", cfg.MongoDB)
|
||||
} else {
|
||||
// Store mémoire (par défaut)
|
||||
memStore := storage.NewMemoryStore()
|
||||
caStore = memStore
|
||||
certStore = memStore
|
||||
fmt.Println("✅ Utilisation du store mémoire")
|
||||
}
|
||||
|
||||
// Ajouter les routes avec les stores
|
||||
api.RegisterRoutesWithStore(router, caStore, certStore)
|
||||
|
||||
// Démarrer le serveur
|
||||
port := ":" + cfg.Port
|
||||
fmt.Printf("🚀 API PKI démarrée sur %s\n", port)
|
||||
if err := router.Run(port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
services:
|
||||
# MongoDB pour la persistance
|
||||
mongo:
|
||||
image: mongo:latest
|
||||
container_name: pkiapi-mongo
|
||||
ports:
|
||||
- "27017:27017"
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: admin
|
||||
MONGO_INITDB_ROOT_PASSWORD: password
|
||||
MONGO_INITDB_DATABASE: pkiapi
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
networks:
|
||||
- pkiapi-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# API PKI
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: pkiapi
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
PORT: 8080
|
||||
STORAGE_TYPE: mongodb
|
||||
MONGO_URI: mongodb://admin:password@mongo:27017
|
||||
MONGO_DB: pkiapi
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-super-secret-key-change-in-production}
|
||||
GIN_MODE: release
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pkiapi-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
pkiapi-network:
|
||||
driver: bridge
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
module github.com/stef/pkiapi
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package storage
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("certificat non trouvé")
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue