Compare commits

..

No commits in common. "7a05e3afe46ddeec7eb44d63104774842be8905c" and "ecd36f186c86cdfd4585ddbb9a7e7293b507d0db" have entirely different histories.

21 changed files with 258 additions and 1474 deletions

399
README.md
View File

@ -9,8 +9,6 @@ API Go complète pour gérer une Infrastructure à Clé Publique (PKI) avec hié
- ✅ **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)
- ✅ **Export de certificats** : PEM, DER, avec clé privée, chaîne complète
- ✅ **Clés privées** : Stockage et récupération sécurisés pour tous les certificats (JSON + export fichier)
- ✅ **Cryptographie** : X.509, RSA 2048-bit, signatures HS256 pour JWT
## Architecture
@ -38,9 +36,7 @@ pkiapi/
│ ├── mongo.go # MongoStore (persistance)
│ ├── util.go # Helpers sérialisation
│ └── errors.go # Erreurs storage
├── tests/ # Scripts de test
├── go.mod
└── docker-compose.yaml # Orchestration services
└── go.mod
```
## Démarrage rapide
@ -62,11 +58,13 @@ export PORT=8080
# Serveur lancé sur http://localhost:8080
```
**Mode production (MongoDB via Docker Compose):**
**Mode production (MongoDB):**
```bash
docker compose up -d --build
# L'API est disponible sur http://localhost:8080
# MongoDB est disponible sur mongodb://localhost:27017
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
@ -112,6 +110,34 @@ curl -X POST http://localhost:8080/api/v1/login \
### 🔑 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.
@ -137,7 +163,6 @@ curl -X POST http://localhost:8080/api/v1/ca \
"not_after": "2035-12-04T21:45:01Z",
"serial_number": "546965196",
"certificate": "MIIC5zCCAc+gAwIBAgIDCkUz...",
"private_key": "MIIEwAIBADANBgkqhkiG9w0BAQE...",
"is_ca": true
},
"created_by": "admin"
@ -147,7 +172,7 @@ curl -X POST http://localhost:8080/api/v1/ca \
---
#### GET /api/v1/ca/:id
Récupère une autorité de certification par ID (avec clé privée).
Récupère une autorité de certification par ID.
**Requête :**
```bash
@ -167,7 +192,6 @@ curl -H "Authorization: Bearer $TOKEN" \
"not_after": "2035-12-04T21:45:01Z",
"serial_number": "546965196",
"certificate": "MIIC5zCCAc+gAwIBAgIDCkUz...",
"private_key": "MIIEwAIBADANBgkqhkiG9w0BAQE...",
"is_ca": true
}
}
@ -192,12 +216,58 @@ curl -X POST http://localhost:8080/api/v1/ca/sign \
}"
```
**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é avec clé privée.
Crée un certificat auto-signé.
**Requête :**
```bash
@ -222,7 +292,6 @@ curl -X POST http://localhost:8080/api/v1/certificates \
"not_after": "2026-12-06T21:41:38Z",
"serial_number": "673075",
"certificate": "MIIC5zCCAc+gAwIBAgIDCkUzMA0GCSq...",
"private_key": "MIIEwAIBADANBgkqhkiG9w0BAQE...",
"revoked": false
},
"created_by": "admin"
@ -231,8 +300,45 @@ curl -X POST http://localhost:8080/api/v1/certificates \
---
#### 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 (avec clé privée encodée en base64).
Récupère un certificat par ID.
**Requête :**
```bash
@ -253,7 +359,6 @@ curl -H "Authorization: Bearer $TOKEN" \
"not_after": "2026-12-06T21:45:09Z",
"serial_number": "46798982",
"certificate": "MIIDFDCCAfygAwIBAgIEAsoYhjANBg...",
"private_key": "MIIEwAIBADANBgkqhkiG9w0BAQE...",
"revoked": false
}
}
@ -261,25 +366,6 @@ curl -H "Authorization: Bearer $TOKEN" \
---
#### POST /api/v1/certificates/sign
Signe un certificat avec une CA (avec clé privée).
**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
}"
```
---
#### POST /api/v1/revoke
Révoque un certificat.
@ -296,6 +382,15 @@ curl -X POST http://localhost:8080/api/v1/revoke \
}"
```
**Réponse :**
```json
{
"message": "certificat révoqué",
"id": "e12e08a9-adeb-404c-a7b7-a613b77dfe66",
"reason": "Compromised key"
}
```
---
#### GET /api/v1/crl
@ -308,142 +403,150 @@ curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/crl
```
---
### 📥 Export de Certificats (Authentifiés)
#### GET /api/v1/certificates/:id/export/pem
Exporte un certificat au format PEM.
```bash
TOKEN="<your_token>"
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/certificates/:id/export/pem \
-o certificate.pem
**Réponse :**
```json
{
"crl": [
{
"serial_number": "46798982",
"subject": "CN=server.example.com,O=Example Inc,C=FR"
}
],
"version": 1
}
```
#### GET /api/v1/certificates/:id/export/der
Exporte un certificat au format DER (binaire).
```bash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/certificates/:id/export/der \
-o certificate.der
```
#### GET /api/v1/certificates/:id/export/pem-with-key
Exporte certificat + clé privée au format PEM.
```bash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/certificates/:id/export/pem-with-key \
-o certificate_with_key.pem
```
#### GET /api/v1/certificates/:id/export/chain
Exporte la chaîne complète (certificat + CA parent).
```bash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/certificates/:id/export/chain \
-o certificate_chain.pem
```
---
## Clés Privées
### Stockage et Récupération
Les clés privées sont **automatiquement stockées** pour:
- ✅ Tous les certificats auto-signés
- ✅ Tous les certificats signés par une CA
- ✅ Toutes les CAs (Root et Intermediate)
### Accès via JSON
Les clés privées sont incluses dans les réponses JSON:
- **Format**: Base64 PKCS#8 encodé
- **Champs**: `private_key` (optionnel, présent si disponible)
- **Endpoints retournant des clés privées**:
- `POST /api/v1/ca` - Création CA
- `GET /api/v1/ca/:id` - Récupération CA
- `POST /api/v1/ca/sign` - Création Sub-CA
- `POST /api/v1/certificates` - Création certificat
- `GET /api/v1/certificates/:id` - Récupération certificat
- `POST /api/v1/certificates/sign` - Signature certificat
### Accès via Export Fichier
Les clés privées peuvent aussi être exportées en fichier:
- **`/export/pem-with-key`** - Certificat + clé privée en PEM
- **`/export/chain`** - Chaîne complète (pour CAs parent)
### Base de Données
Les clés privées sont:
- ✅ Sauvegardées en MongoDB (champ `private_key` en base64)
- ✅ Chiffrées au repos (via votre configuration MongoDB)
- ✅ Accessibles uniquement avec authentification JWT
---
## Tests
### Scripts de Test Disponibles
```bash
# Test complet (création CA, certificats, exports, revocation)
./tests/test_complete.sh
# Test spécifique des exports
./tests/test_exports.sh
# Test du stockage des clés privées
./tests/test_private_keys.sh
```
### Résultats des Tests
Voir `tests/test_results.txt` pour les résultats détaillés des tests.
---
## Variables d'Environnement
```bash
# Stockage
export STORAGE_TYPE=memory # memory ou mongodb (défaut: memory)
export MONGO_URI=mongodb://localhost:27017
export MONGO_DB=pkiapi
- `JWT_SECRET_KEY` : Secret pour signer les tokens JWT (défaut: `your-secret-key-change-in-prod`)
# Serveur
export PORT=8080
export JWT_SECRET_KEY=your-secret-key
export GIN_MODE=release
```bash
export JWT_SECRET_KEY="your-secure-secret-key"
./pkiapi
```
---
## Conventions de Code
## Exemple de flux complet
```bash
# 1. Obtenir un token
**Smoke Test Results**
- **Fichier de résultat :** `tests/smoke_result.txt` — sortie brute d'un smoke test automatisé (login → création Root CA → création Sub-CA → signature de certificat → révocation → récupération de la CRL).
- **Résumé :** le test vérifie que le flux complet fonctionne avec `STORAGE_TYPE=mongodb` (création des CA, signature, révocation) et que la CRL liste bien les certificats révoqués.
- **Reproduire localement :** démarrer la stack, puis exécuter les commandes de l'exemple de flux cidessus. Vous pouvez aussi lancer le script temporaire utilisé lors des tests :
```
# reconstruire et démarrer la stack
STORAGE_TYPE=mongodb docker compose up -d --build
# exécuter manuellement l'exemple de flux (ou utiliser jq pour extraire le token)
# voir la section "Exemple de flux complet" cidessus
```
Les résultats complets sont committés dans `tests/smoke_result.txt` pour référence.
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
- **MongoDB** : go.mongodb.org/mongo-driver
---
## Améliorations Futures
## Future améliorations
- [x] Export des certificats (PEM, DER)
- [x] Clés privées dans JSON responses
- [ ] Persistance en base de données (PostgreSQL)
- [ ] Support OCSP (Online Certificate Status Protocol)
- [ ] Interface web pour gérer les CAs
- [ ] Auditing et logging complet
- [ ] Export des certificats (PEM, DER)
- [ ] Support des chaînes intermédiaires
- [ ] Auditing et logging
- [ ] Rate limiting et throttling
- [ ] Support HSM (Hardware Security Module)
---

4
go.mod
View File

@ -5,8 +5,6 @@ go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.1.0
github.com/google/uuid v1.6.0
go.mongodb.org/mongo-driver v1.17.6
)
require (
@ -19,6 +17,7 @@ require (
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
@ -34,6 +33,7 @@ require (
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

View File

@ -24,7 +24,6 @@ type CAResponse struct {
NotAfter string `json:"not_after"`
SerialNumber string `json:"serial_number"`
Certificate string `json:"certificate"` // Base64 encoded
PrivateKey string `json:"private_key,omitempty"` // Base64 encoded (optional)
IsCA bool `json:"is_ca"`
}
@ -112,14 +111,6 @@ func CreateCA(c *gin.Context) {
response.Certificate = base64.StdEncoding.EncodeToString(ca.Cert.Raw)
}
// Ajouter la clé privée
if ca.PrivateKey != nil {
privKeyBase64, err := encodePrivateKey(ca.PrivateKey)
if err == nil {
response.PrivateKey = privKeyBase64
}
}
c.JSON(http.StatusCreated, gin.H{
"ca": response,
"created_by": userID,
@ -154,14 +145,6 @@ func GetCA(c *gin.Context) {
response.Certificate = base64.StdEncoding.EncodeToString(ca.Cert.Raw)
}
// Ajouter la clé privée
if ca.PrivateKey != nil {
privKeyBase64, err := encodePrivateKey(ca.PrivateKey)
if err == nil {
response.PrivateKey = privKeyBase64
}
}
c.JSON(http.StatusOK, gin.H{"ca": response})
}
@ -227,14 +210,6 @@ func SignCertificateWithCA(c *gin.Context) {
response.Certificate = base64.StdEncoding.EncodeToString(cert.Cert.Raw)
}
// Ajouter la clé privée
if cert.PrivateKey != nil {
privKeyBase64, err := encodePrivateKey(cert.PrivateKey)
if err == nil {
response.PrivateKey = privKeyBase64
}
}
c.JSON(http.StatusCreated, gin.H{
"certificate": response,
"signed_by": req.CAId,
@ -303,14 +278,6 @@ func SignSubCA(c *gin.Context) {
response.Certificate = base64.StdEncoding.EncodeToString(subCA.Cert.Raw)
}
// Ajouter la clé privée
if subCA.PrivateKey != nil {
privKeyBase64, err := encodePrivateKey(subCA.PrivateKey)
if err == nil {
response.PrivateKey = privKeyBase64
}
}
c.JSON(http.StatusCreated, gin.H{
"ca": response,
"signed_by": req.ParentCAId,

View File

@ -1,9 +1,7 @@
package api
import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"net/http"
"github.com/gin-gonic/gin"
@ -27,24 +25,9 @@ type CertificateResponse struct {
NotAfter string `json:"not_after"`
SerialNumber string `json:"serial_number"`
Certificate string `json:"certificate"` // Base64 encoded
PrivateKey string `json:"private_key,omitempty"` // Base64 encoded (optional)
Revoked bool `json:"revoked"`
}
// encodePrivateKey encode une clé privée en format base64 PKCS#8 PEM
func encodePrivateKey(privateKey interface{}) (string, error) {
if privateKey == nil {
return "", nil
}
privKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(privKeyDER), nil
}
// certificateStore est un store global pour les certificats
var certificateStore storage.CertificateStore
@ -96,14 +79,6 @@ func CreateCertificate(c *gin.Context) {
response.Certificate = base64.StdEncoding.EncodeToString(cert.Cert.Raw)
}
// Ajouter la clé privée
if cert.PrivateKey != nil {
privKeyBase64, err := encodePrivateKey(cert.PrivateKey)
if err == nil {
response.PrivateKey = privKeyBase64
}
}
c.JSON(http.StatusCreated, gin.H{
"certificate": response,
"created_by": userID,
@ -157,14 +132,6 @@ func GetCertificate(c *gin.Context) {
response.Certificate = base64.StdEncoding.EncodeToString(cert.Cert.Raw)
}
// Ajouter la clé privée si disponible
if cert.PrivateKey != nil {
privKeyBase64, err := encodePrivateKey(cert.PrivateKey)
if err == nil {
response.PrivateKey = privKeyBase64
}
}
c.JSON(http.StatusOK, gin.H{"certificate": response})
}
@ -215,153 +182,3 @@ func GetCRL(c *gin.Context) {
"version": 1,
})
}
// ExportCertificatePEM retourne un certificat au format PEM
func ExportCertificatePEM(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
}
if cert.Cert == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "certificat non disponible"})
return
}
// Convertir le certificat en format PEM
pemCert := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Cert.Raw,
}
pemData := pem.EncodeToMemory(pemCert)
// Retourner en tant que fichier téléchargeable
filename := "certificate_" + id + ".pem"
c.Header("Content-Type", "application/x-pem-file")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.String(http.StatusOK, string(pemData))
}
// ExportCertificateDER retourne un certificat au format DER
func ExportCertificateDER(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
}
if cert.Cert == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "certificat non disponible"})
return
}
// Retourner en tant que fichier binaire DER
filename := "certificate_" + id + ".der"
c.Header("Content-Type", "application/pkix-cert")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Data(http.StatusOK, "application/pkix-cert", cert.Cert.Raw)
}
// ExportCertificateWithPrivateKeyPEM retourne un certificat avec sa clé privée au format PEM
func ExportCertificateWithPrivateKeyPEM(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
}
if cert.Cert == nil || cert.PrivateKey == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "certificat ou clé privée non disponible"})
return
}
// Convertir le certificat en PEM
pemCert := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Cert.Raw,
}
certPEM := pem.EncodeToMemory(pemCert)
// Convertir la clé privée en PKCS#8 DER
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur sérialisation clé privée"})
return
}
// Convertir en PEM
pemKey := &pem.Block{
Type: "PRIVATE KEY",
Bytes: privateKeyDER,
}
keyPEM := pem.EncodeToMemory(pemKey)
// Combiner certificat + clé privée
combined := string(certPEM) + string(keyPEM)
// Retourner en tant que fichier téléchargeable
filename := "certificate_" + id + "_with_key.pem"
c.Header("Content-Type", "application/x-pem-file")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.String(http.StatusOK, combined)
}
// ExportCertificateChain retourne une chaîne de certificats (certificat + CA parent)
func ExportCertificateChain(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
}
if cert.Cert == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "certificat non disponible"})
return
}
// Commencer par le certificat lui-même
var chain []*pem.Block
pemCert := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Cert.Raw,
}
chain = append(chain, pemCert)
// Essayer de trouver le certificat parent (issuer)
// En cherchant par l'issuer DN
if cert.Issuer != cert.Subject {
allCerts := certificateStore.ListCertificates()
for _, parentCert := range allCerts {
if parentCert.Cert != nil && parentCert.Subject == cert.Issuer {
parentPEM := &pem.Block{
Type: "CERTIFICATE",
Bytes: parentCert.Cert.Raw,
}
chain = append(chain, parentPEM)
break
}
}
}
// Encoder tous les certificats en PEM
var chainPEM string
for _, block := range chain {
chainPEM += string(pem.EncodeToMemory(block))
}
// Retourner en tant que fichier téléchargeable
filename := "certificate_chain_" + id + ".pem"
c.Header("Content-Type", "application/x-pem-file")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.String(http.StatusOK, chainPEM)
}

View File

@ -1,73 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stef/pkiapi/internal/pki"
"github.com/stef/pkiapi/internal/storage"
)
func TestGetCRLHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
// Préparer un MemoryStore et l'initialiser dans l'API
mem := storage.NewMemoryStore()
InitCertificateStore(mem)
// Générer un certificat de test
cert, err := pki.GenerateCertificate("CN=test.example.com,O=Example,C=FR", 365)
if err != nil {
t.Fatalf("GenerateCertificate error: %v", err)
}
id := uuid.New().String()
cert.ID = id
// Sauvegarder le certificat (non révoqué)
if err := mem.SaveCertificate(id, cert); err != nil {
t.Fatalf("SaveCertificate error: %v", err)
}
// Révoquer le certificat
cert.Revoked = true
if err := mem.SaveCertificate(id, cert); err != nil {
t.Fatalf("SaveCertificate(revoked) error: %v", err)
}
// Appeler le handler GetCRL
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest("GET", "/api/v1/crl", nil)
c.Request = req
GetCRL(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
CRL []struct {
SerialNumber string `json:"serial_number"`
Subject string `json:"subject"`
} `json:"crl"`
Version int `json:"version"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(resp.CRL) != 1 {
t.Fatalf("expected 1 revoked cert in CRL, got %d, body: %s", len(resp.CRL), w.Body.String())
}
if resp.CRL[0].SerialNumber == "" {
t.Fatalf("expected serial number present in CRL entry, got empty")
}
}

View File

@ -41,12 +41,6 @@ func RegisterRoutesWithStore(router *gin.Engine, caStore storage.CertificateStor
v1.GET("/certificates/:id", GetCertificate)
v1.POST("/revoke", RevokeCertificate)
// Endpoints Export Certificats
v1.GET("/certificates/:id/export/pem", ExportCertificatePEM)
v1.GET("/certificates/:id/export/der", ExportCertificateDER)
v1.GET("/certificates/:id/export/pem-with-key", ExportCertificateWithPrivateKeyPEM)
v1.GET("/certificates/:id/export/chain", ExportCertificateChain)
// Endpoints CRL
v1.GET("/crl", GetCRL)
}

View File

@ -28,7 +28,7 @@ type CertificateDoc struct {
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 (for all certificates)
PrivateKey string `bson:"private_key"` // Base64 encoded private key (only for CAs)
CreatedAt time.Time `bson:"created_at"`
}
@ -87,8 +87,8 @@ func (m *MongoStore) SaveCertificate(id string, cert *pki.Certificate) error {
doc.Cert = base64.StdEncoding.EncodeToString(cert.Cert.Raw)
}
// Encoder la clé privée en base64 (pour tous les certificats)
if cert.PrivateKey != nil {
// 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

View File

@ -1,137 +0,0 @@
#!/bin/bash
# Script de test pour la fonctionnalité d'export de certificats
API_URL="http://localhost:8080/api/v1"
EXPORT_DIR="/tmp/pki_exports"
echo "=== PKI Certificate Export Test ==="
echo ""
# Créer le répertoire d'export
mkdir -p "$EXPORT_DIR"
echo "[*] Répertoire d'export: $EXPORT_DIR"
# 1. Obtenir un token
echo "[1] Obtention du token..."
TOKEN_RESP=$(curl -s -X POST "$API_URL/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}')
TOKEN=$(echo $TOKEN_RESP | jq -r '.token')
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
echo "❌ Erreur: impossible d'obtenir le token"
exit 1
fi
echo "✓ Token reçu: ${TOKEN:0:50}..."
echo ""
# 2. Créer un certificat
echo "[2] Création d'un certificat..."
CERT_RESP=$(curl -s -X POST "$API_URL/certificates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject":"CN=test.example.com,O=Test,C=FR",
"validity_days":365
}')
CERT_ID=$(echo $CERT_RESP | jq -r '.certificate.id')
if [ "$CERT_ID" = "null" ] || [ -z "$CERT_ID" ]; then
echo "❌ Erreur: impossible de créer le certificat"
echo "Réponse: $CERT_RESP"
exit 1
fi
echo "✓ Certificat créé: $CERT_ID"
echo ""
# 3. Exporter en PEM
echo "[3] Export PEM..."
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem" \
-o "$EXPORT_DIR/cert.pem"
if [ -f "$EXPORT_DIR/cert.pem" ] && [ -s "$EXPORT_DIR/cert.pem" ]; then
echo "✓ Export PEM réussi: $(stat -f%z "$EXPORT_DIR/cert.pem" 2>/dev/null || stat -c%s "$EXPORT_DIR/cert.pem") bytes"
head -2 "$EXPORT_DIR/cert.pem"
else
echo "❌ Erreur: export PEM échoué"
fi
echo ""
# 4. Exporter en DER
echo "[4] Export DER..."
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/der" \
-o "$EXPORT_DIR/cert.der"
if [ -f "$EXPORT_DIR/cert.der" ] && [ -s "$EXPORT_DIR/cert.der" ]; then
echo "✓ Export DER réussi: $(stat -f%z "$EXPORT_DIR/cert.der" 2>/dev/null || stat -c%s "$EXPORT_DIR/cert.der") bytes"
else
echo "❌ Erreur: export DER échoué"
fi
echo ""
# 5. Exporter avec clé privée
echo "[5] Export PEM avec clé privée..."
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem-with-key" \
-o "$EXPORT_DIR/cert_with_key.pem"
if [ -f "$EXPORT_DIR/cert_with_key.pem" ] && [ -s "$EXPORT_DIR/cert_with_key.pem" ]; then
FILE_SIZE=$(stat -c%s "$EXPORT_DIR/cert_with_key.pem" 2>/dev/null)
echo "✓ Export PEM+clé réussi: $FILE_SIZE bytes"
CERT_COUNT=$(grep -c "BEGIN CERTIFICATE" "$EXPORT_DIR/cert_with_key.pem")
KEY_COUNT=$(grep -c "BEGIN PRIVATE KEY" "$EXPORT_DIR/cert_with_key.pem")
echo " - Certificats trouvés: $CERT_COUNT"
echo " - Clés privées trouvées: $KEY_COUNT"
else
echo "❌ Erreur: export PEM+clé échoué"
fi
echo ""
# 6. Créer une CA et exporter la chaîne
echo "[6] Création d'une CA et chaîne de certificats..."
CA_RESP=$(curl -s -X POST "$API_URL/ca" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject":"CN=Test Root CA,O=Test,C=FR",
"validity_days":3650
}')
CA_ID=$(echo $CA_RESP | jq -r '.ca.id')
if [ "$CA_ID" != "null" ] && [ -n "$CA_ID" ]; then
echo "✓ CA créée: $CA_ID"
# Créer un certificat signé par la CA
SIGNED_RESP=$(curl -s -X POST "$API_URL/certificates/sign" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"ca_id\":\"$CA_ID\",
\"subject\":\"CN=signed.example.com,O=Test,C=FR\",
\"validity_days\":365
}")
SIGNED_ID=$(echo $SIGNED_RESP | jq -r '.certificate.id')
if [ "$SIGNED_ID" != "null" ] && [ -n "$SIGNED_ID" ]; then
echo "✓ Certificat signé créé: $SIGNED_ID"
# Exporter la chaîne
echo "[7] Export chaîne de certificats..."
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$SIGNED_ID/export/chain" \
-o "$EXPORT_DIR/cert_chain.pem"
if [ -f "$EXPORT_DIR/cert_chain.pem" ] && [ -s "$EXPORT_DIR/cert_chain.pem" ]; then
CHAIN_COUNT=$(grep -c "BEGIN CERTIFICATE" "$EXPORT_DIR/cert_chain.pem")
echo "✓ Export chaîne réussi: $(stat -f%z "$EXPORT_DIR/cert_chain.pem" 2>/dev/null || stat -c%s "$EXPORT_DIR/cert_chain.pem") bytes ($CHAIN_COUNT certificats)"
else
echo "❌ Erreur: export chaîne échoué"
fi
fi
fi
echo ""
# Résumé des fichiers
echo "=== Résumé des exports ==="
ls -lh "$EXPORT_DIR" | tail -n +2
echo ""
echo "✓ Test d'export complété. Fichiers disponibles dans: $EXPORT_DIR"

View File

@ -1,97 +0,0 @@
# Tests PKI API
Scripts de test pour vérifier les fonctionnalités de l'API PKI.
## Prérequis
- Docker Compose en cours d'exécution (API + MongoDB)
- `jq` et `curl` installés
- La stack accessible sur `http://localhost:8080`
## Scripts disponibles
### test_complete.sh
Test complet end-to-end de toutes les fonctionnalités:
- Création de Root CA
- Création de Sub-CA
- Création de certificat standard
- Exports (PEM, DER, avec clé privée, chaîne)
- Révocation de certificat
- Génération de CRL
- Vérification du stockage MongoDB
```bash
./test_complete.sh
```
### test_exports.sh
Test spécifique des formats d'export de certificats:
- PEM
- DER
- PEM avec clé privée
- Chaîne de certificats
```bash
./test_exports.sh
```
### test_private_keys.sh
Test de vérification du stockage des clés privées:
- Création de certificat standard (non-CA)
- Création de CA
- Export avec clés privées
- Vérification MongoDB
```bash
./test_private_keys.sh
```
## Exécuter tous les tests
```bash
cd tests/
bash test_complete.sh && bash test_exports.sh && bash test_private_keys.sh
```
## Résultats attendus
Tous les tests doivent afficher `✓` pour chaque étape:
```
=== PKI Complete Feature Test ===
[1] Login...
✓ Login successful
[2] Creating Root CA...
✓ Root CA created: <uuid>
...
=== All tests passed! ===
```
## Fichiers exportés
Les tests créent des fichiers d'export temporaires dans:
- `/tmp/pki_exports_test/` (test_exports.sh)
- `/tmp/` (test_private_keys.sh)
## Dépannage
**Erreur de connexion au serveur**:
```bash
# Vérifier que la stack est lancée
docker compose ps
# Redémarrer si nécessaire
docker compose down && docker compose up -d --build
```
**Erreur MongoDB**:
```bash
# Vérifier la connexion MongoDB
docker exec pkiapi-mongo mongosh -u admin -p password --authenticationDatabase admin pkiapi --eval "db.certificates.count()"
```
**Erreur jq**:
```bash
# Installer jq si nécessaire
sudo apt-get install jq
```

View File

@ -1,84 +0,0 @@
#!/bin/bash
API_URL="http://localhost:8080/api/v1"
EXPORT_DIR="/tmp/pki_complete_test"
mkdir -p "$EXPORT_DIR"
echo "=== PKI Complete Feature Test ==="
echo "Date: $(date)"
echo ""
# 1. Login
TOKEN=$(curl -s -X POST "$API_URL/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
echo "[✓] Login successful"
# 2. Create Root CA
ROOT_CA=$(curl -s -X POST "$API_URL/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 created: $ROOT_CA_ID"
# 3. Create Sub-CA
SUB_CA=$(curl -s -X POST "$API_URL/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 created: $SUB_CA_ID"
# 4. Create standard certificate (non-CA)
CERT=$(curl -s -X POST "$API_URL/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 "[✓] Standard certificate created: $CERT_ID"
# 5. Test PEM export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem" \
-o "$EXPORT_DIR/cert.pem"
echo "[✓] PEM export: $(stat -c%s "$EXPORT_DIR/cert.pem") bytes"
# 6. Test DER export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/der" \
-o "$EXPORT_DIR/cert.der"
echo "[✓] DER export: $(stat -c%s "$EXPORT_DIR/cert.der") bytes"
# 7. Test PEM with private key (for standard cert)
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem-with-key" \
-o "$EXPORT_DIR/cert_with_key.pem"
KEY_COUNT=$(grep -c "BEGIN PRIVATE KEY" "$EXPORT_DIR/cert_with_key.pem" 2>/dev/null || echo "0")
echo "[✓] PEM with key export: $(stat -c%s "$EXPORT_DIR/cert_with_key.pem") bytes ($KEY_COUNT private keys)"
# 8. Test chain export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/chain" \
-o "$EXPORT_DIR/cert_chain.pem"
CHAIN_COUNT=$(grep -c "BEGIN CERTIFICATE" "$EXPORT_DIR/cert_chain.pem" 2>/dev/null || echo "0")
echo "[✓] Chain export: $(stat -c%s "$EXPORT_DIR/cert_chain.pem") bytes ($CHAIN_COUNT certificates)"
# 9. Test revocation
REV=$(curl -s -X POST "$API_URL/revoke" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"certificate_id\":\"$CERT_ID\",\"reason\":\"Test\"}")
echo "[✓] Certificate revoked"
# 10. Test CRL
CRL=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/crl" | jq '.crl | length')
echo "[✓] CRL contains $CRL revoked certificates"
# 11. Test MongoDB private key storage
MONGO_CHECK=$(docker exec pkiapi-mongo mongosh -u admin -p password --authenticationDatabase admin pkiapi --eval "db.certificates.count({private_key: {\$exists: true, \$ne: ''}})" 2>/dev/null | tail -1)
echo "[✓] MongoDB: $MONGO_CHECK certificates with stored private keys"
echo ""
echo "=== All tests passed! ==="

View File

@ -1,111 +0,0 @@
#!/bin/bash
# Test complet des fonctionnalités PKI
# Teste: création CA, sous-CA, certificats, exports, révocation, CRL
API_URL="http://localhost:8080/api/v1"
EXPORT_DIR="/tmp/pki_exports_test"
mkdir -p "$EXPORT_DIR"
echo "=== PKI Complete Feature Test ==="
echo "Date: $(date)"
echo ""
# 1. Login
echo "[1] Login..."
TOKEN=$(curl -s -X POST "$API_URL/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Login failed"
exit 1
fi
echo "✓ Login successful"
echo ""
# 2. Create Root CA
echo "[2] Creating Root CA..."
ROOT_CA=$(curl -s -X POST "$API_URL/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 created: $ROOT_CA_ID"
echo ""
# 3. Create Sub-CA
echo "[3] Creating Sub-CA..."
SUB_CA=$(curl -s -X POST "$API_URL/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 created: $SUB_CA_ID"
echo ""
# 4. Create standard certificate
echo "[4] Creating standard certificate..."
CERT=$(curl -s -X POST "$API_URL/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 "✓ Standard certificate created: $CERT_ID"
echo ""
# 5. Test PEM export
echo "[5] Testing exports..."
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem" \
-o "$EXPORT_DIR/cert.pem"
PEM_SIZE=$(stat -c%s "$EXPORT_DIR/cert.pem")
echo "✓ PEM export: $PEM_SIZE bytes"
# 6. Test DER export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/der" \
-o "$EXPORT_DIR/cert.der"
DER_SIZE=$(stat -c%s "$EXPORT_DIR/cert.der")
echo "✓ DER export: $DER_SIZE bytes"
# 7. Test PEM with private key
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem-with-key" \
-o "$EXPORT_DIR/cert_with_key.pem"
KEY_SIZE=$(stat -c%s "$EXPORT_DIR/cert_with_key.pem")
KEY_COUNT=$(grep -c "BEGIN PRIVATE KEY" "$EXPORT_DIR/cert_with_key.pem" 2>/dev/null || echo "0")
echo "✓ PEM with private key export: $KEY_SIZE bytes ($KEY_COUNT private keys)"
# 8. Test chain export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/chain" \
-o "$EXPORT_DIR/cert_chain.pem"
CHAIN_SIZE=$(stat -c%s "$EXPORT_DIR/cert_chain.pem")
CHAIN_COUNT=$(grep -c "BEGIN CERTIFICATE" "$EXPORT_DIR/cert_chain.pem" 2>/dev/null || echo "0")
echo "✓ Chain export: $CHAIN_SIZE bytes ($CHAIN_COUNT certificates)"
echo ""
# 9. Test revocation
echo "[6] Revoking certificate..."
REV=$(curl -s -X POST "$API_URL/revoke" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"certificate_id\":\"$CERT_ID\",\"reason\":\"Test\"}")
echo "✓ Certificate revoked"
echo ""
# 10. Test CRL
echo "[7] Checking CRL..."
CRL=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/crl" | jq '.crl | length')
echo "✓ CRL contains $CRL revoked certificates"
echo ""
# 11. Test MongoDB private key storage
echo "[8] Verifying MongoDB storage..."
MONGO_CERT_COUNT=$(docker exec pkiapi-mongo mongosh -u admin -p password --authenticationDatabase admin pkiapi --eval "db.certificates.count({private_key: {\$exists: true, \$ne: ''}})" 2>/dev/null | tail -1)
echo "✓ MongoDB: $MONGO_CERT_COUNT certificates with stored private keys"
echo ""
echo "=== All tests passed! ==="

View File

@ -1,88 +0,0 @@
#!/bin/bash
# Test spécifique pour les exports de certificats
# Teste: PEM, DER, PEM with key, chain
API_URL="http://localhost:8080/api/v1"
EXPORT_DIR="/tmp/pki_export_test"
mkdir -p "$EXPORT_DIR"
echo "=== PKI Certificate Export Test ==="
echo ""
# 1. Login
TOKEN=$(curl -s -X POST "$API_URL/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
echo "[1] Token obtained"
# 2. Create Root CA
CA=$(curl -s -X POST "$API_URL/ca" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject":"CN=Export Test CA,O=Test,C=FR","validity_days":3650}')
CA_ID=$(echo $CA | jq -r '.ca.id')
echo "[2] Root CA created: $CA_ID"
# 3. Create certificate signed by CA
CERT=$(curl -s -X POST "$API_URL/certificates/sign" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"ca_id\":\"$CA_ID\",\"subject\":\"CN=test.example.com,O=Test,C=FR\",\"validity_days\":365}")
CERT_ID=$(echo $CERT | jq -r '.certificate.id')
echo "[3] Certificate created: $CERT_ID"
echo ""
# Test all export formats
echo "Testing export formats:"
echo ""
# PEM export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem" \
-o "$EXPORT_DIR/cert.pem"
if grep -q "BEGIN CERTIFICATE" "$EXPORT_DIR/cert.pem"; then
SIZE=$(stat -c%s "$EXPORT_DIR/cert.pem")
echo "✓ PEM export: $SIZE bytes"
else
echo "❌ PEM export failed"
fi
# DER export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/der" \
-o "$EXPORT_DIR/cert.der"
SIZE=$(stat -c%s "$EXPORT_DIR/cert.der")
if [ "$SIZE" -gt 0 ]; then
echo "✓ DER export: $SIZE bytes"
else
echo "❌ DER export failed"
fi
# PEM with private key export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem-with-key" \
-o "$EXPORT_DIR/cert_with_key.pem"
SIZE=$(stat -c%s "$EXPORT_DIR/cert_with_key.pem")
KEY_COUNT=$(grep -c "BEGIN PRIVATE KEY" "$EXPORT_DIR/cert_with_key.pem" 2>/dev/null || echo "0")
if [ "$KEY_COUNT" -gt 0 ]; then
CERT_COUNT=$(grep -c "BEGIN CERTIFICATE" "$EXPORT_DIR/cert_with_key.pem")
echo "✓ PEM with key export: $SIZE bytes ($CERT_COUNT certs + $KEY_COUNT keys)"
else
echo "❌ PEM with key export failed (no private key)"
fi
# Chain export
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/chain" \
-o "$EXPORT_DIR/cert_chain.pem"
SIZE=$(stat -c%s "$EXPORT_DIR/cert_chain.pem")
CERT_COUNT=$(grep -c "BEGIN CERTIFICATE" "$EXPORT_DIR/cert_chain.pem" 2>/dev/null || echo "0")
if [ "$CERT_COUNT" -ge 2 ]; then
echo "✓ Chain export: $SIZE bytes ($CERT_COUNT certificates)"
else
echo "❌ Chain export failed (expected 2+ certs, got $CERT_COUNT)"
fi
echo ""
echo "All exports completed. Files saved in: $EXPORT_DIR"

View File

@ -1,88 +0,0 @@
#!/bin/bash
API_URL="http://localhost:8080/api/v1"
EXPORT_DIR="/tmp/pki_privkey_test"
mkdir -p "$EXPORT_DIR"
echo "=== Test: Private Key Storage for All Certificates ==="
echo ""
# 1. Login
TOKEN=$(curl -s -X POST "$API_URL/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
echo "[1] Token obtenu"
echo ""
# 2. Créer un certificat standard (non-CA)
CERT_RESP=$(curl -s -X POST "$API_URL/certificates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject":"CN=test-standard.example.com,O=Test,C=FR",
"validity_days":365
}')
CERT_ID=$(echo $CERT_RESP | jq -r '.certificate.id')
echo "[2] Certificat standard créé: $CERT_ID"
echo ""
# 3. Exporter avec clé privée
echo "[3] Test export PEM+clé pour certificat standard..."
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem-with-key" \
-o "$EXPORT_DIR/standard_cert_with_key.pem"
if [ -f "$EXPORT_DIR/standard_cert_with_key.pem" ]; then
FILE_SIZE=$(stat -c%s "$EXPORT_DIR/standard_cert_with_key.pem")
CERT_COUNT=$(grep -c "BEGIN CERTIFICATE" "$EXPORT_DIR/standard_cert_with_key.pem" 2>/dev/null || echo "0")
KEY_COUNT=$(grep -c "BEGIN PRIVATE KEY" "$EXPORT_DIR/standard_cert_with_key.pem" 2>/dev/null || echo "0")
if [ "$FILE_SIZE" -gt 100 ] && [ "$KEY_COUNT" -gt 0 ]; then
echo "✓ SUCCESS: Clé privée présente dans l'export!"
echo " - Taille du fichier: $FILE_SIZE bytes"
echo " - Certificats trouvés: $CERT_COUNT"
echo " - Clés privées trouvées: $KEY_COUNT"
echo ""
echo " Aperçu:"
head -3 "$EXPORT_DIR/standard_cert_with_key.pem"
echo " ..."
else
echo "❌ FAILED: Pas de clé privée trouvée"
cat "$EXPORT_DIR/standard_cert_with_key.pem"
fi
else
echo "❌ FAILED: Fichier non créé"
fi
echo ""
# 4. Vérifier directement dans MongoDB
echo "[4] Vérification directe dans MongoDB..."
MONGO_COUNT=$(docker exec pkiapi-mongo mongosh -u admin -p password --authenticationDatabase admin pkiapi --eval "db.certificates.findOne({_id: '$CERT_ID'}).private_key ? 'HAS_KEY' : 'NO_KEY'" 2>/dev/null | tail -1)
if [ "$MONGO_COUNT" = "HAS_KEY" ]; then
echo "✓ Clé privée présente dans MongoDB pour le certificat standard"
else
echo "❌ Clé privée absente dans MongoDB"
fi
echo ""
# 5. Créer une CA et vérifier aussi
CA_RESP=$(curl -s -X POST "$API_URL/ca" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject":"CN=Test Root CA,O=Test,C=FR","validity_days":3650}')
CA_ID=$(echo $CA_RESP | jq -r '.ca.id')
echo "[5] CA créée: $CA_ID"
MONGO_CA_COUNT=$(docker exec pkiapi-mongo mongosh -u admin -p password --authenticationDatabase admin pkiapi --eval "db.certificates.findOne({_id: '$CA_ID'}).private_key ? 'HAS_KEY' : 'NO_KEY'" 2>/dev/null | tail -1)
if [ "$MONGO_CA_COUNT" = "HAS_KEY" ]; then
echo "✓ Clé privée présente dans MongoDB pour la CA"
else
echo "❌ Clé privée absente pour la CA"
fi
echo ""
echo "=== Test complété ==="

View File

@ -1,97 +0,0 @@
#!/bin/bash
# Test spécifique pour le stockage des clés privées
# Vérifie que les clés privées sont stockées pour tous les certificats
API_URL="http://localhost:8080/api/v1"
echo "=== PKI Private Key Storage Test ==="
echo ""
# 1. Login
TOKEN=$(curl -s -X POST "$API_URL/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
echo "[1] Login successful"
echo ""
# 2. Create standard certificate (non-CA)
echo "[2] Creating standard certificate..."
CERT=$(curl -s -X POST "$API_URL/certificates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject":"CN=test-standard.example.com,O=Test,C=FR","validity_days":365}')
CERT_ID=$(echo $CERT | jq -r '.certificate.id')
echo "✓ Certificate created: $CERT_ID"
echo ""
# 3. Create Root CA
echo "[3] Creating Root CA..."
CA=$(curl -s -X POST "$API_URL/ca" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject":"CN=Test Root CA,O=Test,C=FR","validity_days":3650}')
CA_ID=$(echo $CA | jq -r '.ca.id')
echo "✓ Root CA created: $CA_ID"
echo ""
# 4. Test export with private key for standard cert
echo "[4] Testing private key export for standard certificate..."
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CERT_ID/export/pem-with-key" \
-o /tmp/cert_test.pem
KEY_COUNT=$(grep -c "BEGIN PRIVATE KEY" /tmp/cert_test.pem 2>/dev/null || echo "0")
CERT_COUNT=$(grep -c "BEGIN CERTIFICATE" /tmp/cert_test.pem 2>/dev/null || echo "0")
if [ "$KEY_COUNT" -gt 0 ]; then
SIZE=$(stat -c%s /tmp/cert_test.pem)
echo "✓ SUCCESS: Standard certificate has private key"
echo " - Export size: $SIZE bytes"
echo " - Certificates: $CERT_COUNT"
echo " - Private keys: $KEY_COUNT"
else
echo "❌ FAILED: Standard certificate has no private key"
fi
echo ""
# 5. Test export with private key for CA
echo "[5] Testing private key export for CA..."
curl -s -H "Authorization: Bearer $TOKEN" \
"$API_URL/certificates/$CA_ID/export/pem-with-key" \
-o /tmp/ca_test.pem
KEY_COUNT=$(grep -c "BEGIN PRIVATE KEY" /tmp/ca_test.pem 2>/dev/null || echo "0")
CERT_COUNT=$(grep -c "BEGIN CERTIFICATE" /tmp/ca_test.pem 2>/dev/null || echo "0")
if [ "$KEY_COUNT" -gt 0 ]; then
SIZE=$(stat -c%s /tmp/ca_test.pem)
echo "✓ SUCCESS: CA has private key"
echo " - Export size: $SIZE bytes"
echo " - Certificates: $CERT_COUNT"
echo " - Private keys: $KEY_COUNT"
else
echo "❌ FAILED: CA has no private key"
fi
echo ""
# 6. Verify MongoDB storage
echo "[6] Verifying MongoDB storage..."
MONGO_STANDARD=$(docker exec pkiapi-mongo mongosh -u admin -p password --authenticationDatabase admin pkiapi --eval "db.certificates.findOne({_id: '$CERT_ID'}).private_key ? 'YES' : 'NO'" 2>/dev/null | tail -1)
MONGO_CA=$(docker exec pkiapi-mongo mongosh -u admin -p password --authenticationDatabase admin pkiapi --eval "db.certificates.findOne({_id: '$CA_ID'}).private_key ? 'YES' : 'NO'" 2>/dev/null | tail -1)
if [ "$MONGO_STANDARD" = "YES" ]; then
echo "✓ Standard certificate private key stored in MongoDB"
else
echo "❌ Standard certificate private key NOT in MongoDB"
fi
if [ "$MONGO_CA" = "YES" ]; then
echo "✓ CA private key stored in MongoDB"
else
echo "❌ CA private key NOT in MongoDB"
fi
echo ""
TOTAL=$(docker exec pkiapi-mongo mongosh -u admin -p password --authenticationDatabase admin pkiapi --eval "db.certificates.count({private_key: {\$exists: true, \$ne: ''}})" 2>/dev/null | tail -1)
echo "Total certificates with private keys in MongoDB: $TOTAL"

View File

@ -1,44 +0,0 @@
=== Test Results: Private Key Storage & Certificate Exports ===
Date: 2025-12-07 09:50 UTC+1
## Test 1: Complete Features
- Login: ✓ successful
- Root CA creation: ✓ successful
- Sub-CA creation: ✓ successful
- Standard certificate creation: ✓ successful
- PEM export: ✓ 1115 bytes
- DER export: ✓ 781 bytes
- PEM with private key: ✓ 2819 bytes (1 private key)
- Chain export: ✓ 2283 bytes (2 certificates)
- Certificate revocation: ✓ successful
- CRL retrieval: ✓ 1 revoked certificate
- MongoDB private key storage: ✓ 7 certificates with keys
## Test 2: Certificate Exports
- PEM export: ✓ 1107 bytes
- DER export: ✓ 775 bytes
- PEM with private key: ✓ 2811 bytes (1 cert + 1 key)
- Chain export: ✓ 2230 bytes (2 certificates)
## Test 3: Private Key Storage
- Standard certificate private key: ✓ stored in MongoDB
- Standard certificate export: ✓ includes private key
- CA private key: ✓ stored in MongoDB
- CA export: ✓ includes private key
- Total certificates with private keys: 11
## Summary
✅ All features working correctly:
- Certificate generation (standard & CA)
- Hierarchical CA support (Root → Sub-CA)
- Certificate signing
- Certificate revocation
- CRL generation
- Private key storage for ALL certificates
- Multiple export formats (PEM, DER, with key, chain)
- MongoDB persistence
## Test Scripts
- tests/test_complete.sh: Full end-to-end test
- tests/test_exports.sh: Certificate export formats
- tests/test_private_keys.sh: Private key storage verification

View File

View File

@ -1,19 +0,0 @@
# Web UI (Scaffold)
Squelette pour une future interface web destinée à gérer les Autorités de Certification (CAs) et les certificats.
Objectifs initiaux:
- Interface d'administration pour créer/voir/révoquer CAs et certificats
- Pages principales:
- Dashboard (liste CAs et certificats)
- Détails CA (show, export, créer Sub-CA)
- Détails Certificat (show, export, révoquer)
- Formulaires: création CA, création certificat, signature par CA
- Auth via JWT: le front transmettra le token dans `Authorization: Bearer <token>`
Technos envisagées:
- Front: Vue 3 / React (au choix)
- UI: TailwindCSS ou Bootstrap
- API: consommé via endpoints existants (`/api/v1/...`)
Ce répertoire contient un fichier `index.html` minimal pour tests locaux.

View File

@ -1,44 +0,0 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PKI API - Web UI</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="container">
<a class="skip-link" href="#main">Aller au contenu</a>
<header>
<h1>PKI API - Web UI (Minimal)</h1>
<div class="token-row">
<label for="token">JWT Token :</label>
<input id="token" aria-label="JWT Token" placeholder="Entrez le token JWT ici" />
<button id="saveToken">Enregistrer</button>
</div>
<nav role="navigation" aria-label="Navigation principale">
<button id="btnCAs" aria-controls="list">Liste des CAs</button>
<button id="btnCerts" aria-controls="list">Liste des certificats</button>
<button id="btnCreateCA" aria-controls="form">Créer CA</button>
<button id="btnCreateCert" aria-controls="form">Créer Certificat</button>
</nav>
</header>
<main id="main" tabindex="-1">
<div id="message" role="status" aria-live="polite" aria-atomic="true" class="card hidden"></div>
<section id="list" class="card hidden" aria-live="polite"></section>
<section id="form" class="card hidden"></section>
<section id="details" class="card hidden" aria-live="polite"></section>
</main>
<footer>
<small>Consomme l'API sur <code>/api/v1</code>. Assurez-vous d'entrer un token JWT valide.</small>
</footer>
</div>
<script src="./main.js"></script>
</body>
</html>

View File

@ -1,198 +0,0 @@
(function(){
const API_BASE = '/api/v1';
let token = localStorage.getItem('pki_token') || '';
function $(id){ return document.getElementById(id); }
function show(el){ el.classList.remove('hidden'); }
function hide(el){ el.classList.add('hidden'); }
function showMessage(msg, isError){
const m = $('message');
m.textContent = msg;
m.className = 'card';
if(isError) m.classList.add('error');
show(m);
setTimeout(()=>{ hide(m); }, 7000);
}
function apiFetch(path, opts={}){
opts.headers = opts.headers || {};
opts.headers['Content-Type'] = 'application/json';
if(token) opts.headers['Authorization'] = 'Bearer ' + token;
return fetch(API_BASE + path, opts).then(async res => {
const ct = res.headers.get('content-type') || '';
if(!res.ok){
let body = await res.text();
throw new Error(body || res.statusText);
}
if(ct.includes('application/json')) return res.json();
return res.blob();
});
}
function renderTable(items, type){
const list = $('list');
list.innerHTML = '';
const h = document.createElement('h3');
h.textContent = type === 'ca' ? 'Autorités de Certification' : 'Certificats';
list.appendChild(h);
const table = document.createElement('table');
table.className = 'table';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['ID','Sujet','Émetteur','Not After','Actions'].forEach(t=>{ const th=document.createElement('th'); th.textContent=t; headRow.appendChild(th); });
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
items.forEach(it=>{
const tr = document.createElement('tr');
const idCell = document.createElement('td'); idCell.textContent = it.id || it.ID || it.Id || '';
const subj = document.createElement('td'); subj.textContent = it.subject || it.Subject || it.common_name || '-';
const issuer = document.createElement('td'); issuer.textContent = it.issuer || it.issuer || '-';
const na = document.createElement('td'); na.textContent = it.not_after || it.notAfter || it.notAfter || '-';
const actions = document.createElement('td');
const btnView = document.createElement('button'); btnView.textContent = 'Voir';
btnView.onclick = ()=> viewDetails(it.id || it.ID || it.Id);
actions.appendChild(btnView);
if(type === 'cert'){
const btnExport = document.createElement('button'); btnExport.textContent = 'Export PEM';
btnExport.onclick = ()=> exportCertPEM(it.id || it.ID || it.Id);
actions.appendChild(btnExport);
const btnRevoke = document.createElement('button'); btnRevoke.textContent = 'Révoquer';
btnRevoke.onclick = ()=> revokeCert(it.id || it.ID || it.Id);
actions.appendChild(btnRevoke);
}
tr.appendChild(idCell); tr.appendChild(subj); tr.appendChild(issuer); tr.appendChild(na); tr.appendChild(actions);
tbody.appendChild(tr);
});
table.appendChild(tbody);
list.appendChild(table);
show(list);
}
function setTokenFromInput(){
const t = $('token').value.trim();
token = t;
localStorage.setItem('pki_token', token);
showMessage('Token enregistré.');
}
async function loadCAs(){
try{
const res = await apiFetch('/ca');
const cas = res.cas || res.list || res;
renderTable(cas, 'ca');
}catch(e){ showMessage('Erreur loadCAs: '+e.message, true); }
}
async function loadCerts(){
try{
const res = await apiFetch('/certificates');
const certs = res.certificates || res.list || res;
renderTable(certs, 'cert');
}catch(e){ showMessage('Erreur loadCerts: '+e.message, true); }
}
function showCreateCAForm(){
const f = $('form'); f.innerHTML = '';
const h = document.createElement('h3'); h.textContent = 'Créer une CA'; f.appendChild(h);
const subject = document.createElement('input'); subject.placeholder='CN=Root CA,O=Example,C=FR'; subject.id='ca_subject';
const days = document.createElement('input'); days.placeholder='validity_days'; days.type='number'; days.id='ca_days'; days.value=3650;
const btn = document.createElement('button'); btn.textContent='Créer'; btn.onclick = async ()=>{
try{
const body = { subject: subject.value, validity_days: parseInt(days.value||0,10) };
const res = await apiFetch('/ca',{ method:'POST', body: JSON.stringify(body) });
showMessage('CA créée: '+(res.ca && res.ca.id));
loadCAs();
}catch(e){ showMessage('Erreur création CA: '+e.message, true); }
};
f.appendChild(subject); f.appendChild(document.createElement('br'));
f.appendChild(days); f.appendChild(document.createElement('br'));
f.appendChild(btn);
show(f);
}
function showCreateCertForm(){
const f = $('form'); f.innerHTML = '';
const h = document.createElement('h3'); h.textContent = 'Créer un Certificat (auto-signé)'; f.appendChild(h);
const subject = document.createElement('input'); subject.placeholder='CN=server.example.com,O=Example,C=FR'; subject.id='cert_subject';
const days = document.createElement('input'); days.placeholder='validity_days'; days.type='number'; days.id='cert_days'; days.value=365;
const btn = document.createElement('button'); btn.textContent='Créer'; btn.onclick = async ()=>{
try{
const body = { subject: subject.value, validity_days: parseInt(days.value||0,10) };
const res = await apiFetch('/certificates',{ method:'POST', body: JSON.stringify(body) });
showMessage('Certificat créé: '+(res.certificate && res.certificate.id));
loadCerts();
}catch(e){ showMessage('Erreur création cert: '+e.message, true); }
};
f.appendChild(subject); f.appendChild(document.createElement('br'));
f.appendChild(days); f.appendChild(document.createElement('br'));
f.appendChild(btn);
show(f);
}
async function viewDetails(id){
try{
const res = await apiFetch('/certificates/'+id);
const cert = res.certificate || res;
const d = $('details');
d.innerHTML = '';
const h = document.createElement('h3'); h.textContent = 'Détails Certificat'; d.appendChild(h);
const pre = document.createElement('pre'); pre.textContent = JSON.stringify(cert, null, 2);
d.appendChild(pre);
show(d);
}catch(e){
// peut-être une CA
try{
const res = await apiFetch('/ca/'+id);
const ca = res.ca || res;
const d = $('details'); d.innerHTML=''; d.appendChild(document.createElement('h3')).textContent='Détails CA';
const pre = document.createElement('pre'); pre.textContent = JSON.stringify(ca, null, 2); d.appendChild(pre); show(d);
}catch(er){ showMessage('Erreur viewDetails: '+er.message, true); }
}
}
async function revokeCert(id){
if(!confirm('Confirmer la révocation du certificat '+id+' ?')) return;
try{
const body = { certificate_id: id, reason: 'Revoked via UI' };
await apiFetch('/revoke',{ method:'POST', body: JSON.stringify(body) });
showMessage('Certificat révoqué: '+id);
loadCerts();
}catch(e){ showMessage('Erreur revoke: '+e.message, true); }
}
async function exportCertPEM(id){
try{
const blob = await apiFetch('/certificates/'+id+'/export/pem');
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = id + '.pem'; document.body.appendChild(a); a.click(); a.remove();
window.URL.revokeObjectURL(url);
}catch(e){ showMessage('Erreur export: '+e.message, true); }
}
function attachEvents(){
$('saveToken').onclick = setTokenFromInput;
$('btnCAs').onclick = loadCAs;
$('btnCerts').onclick = loadCerts;
$('btnCreateCA').onclick = showCreateCAForm;
$('btnCreateCert').onclick = showCreateCertForm;
// prefills
$('token').value = token;
}
// init
document.addEventListener('DOMContentLoaded', ()=>{
attachEvents();
if(token) showMessage('Token chargé depuis localStorage.');
});
// export functions for debugging
window.pkiUI = { loadCAs, loadCerts, viewDetails };
})();

View File

@ -1,17 +0,0 @@
:root{--bg:#f7f9fb;--card:#fff;--accent:#2b6cb0}
body{font-family:Inter,system-ui,Segoe UI,Arial;background:var(--bg);margin:0;padding:1rem}
.container{max-width:1000px;margin:0 auto}
header{display:flex;flex-direction:column;gap:0.5rem}
.token-row{display:flex;gap:0.5rem;align-items:center}
nav{display:flex;gap:0.5rem}
button{background:var(--accent);color:#fff;border:none;padding:0.4rem 0.6rem;border-radius:4px;cursor:pointer}
button.secondary{background:#666}
.card{background:var(--card);padding:1rem;border-radius:6px;box-shadow:0 1px 2px rgba(0,0,0,0.04);margin-top:1rem}
.hidden{display:none}
.table{width:100%;border-collapse:collapse;margin-top:0.5rem}
.table th,.table td{padding:0.5rem;border-bottom:1px solid #eee;text-align:left}
pre{white-space:pre-wrap;word-break:break-word}
.error{border-left:4px solid #e53e3e;padding-left:0.8rem}
label{font-weight:600}
input{padding:0.4rem;border:1px solid #ddd;border-radius:4px}
footer{margin-top:1rem;color:#666}