Compare commits
10 Commits
ecd36f186c
...
7a05e3afe4
| Author | SHA1 | Date |
|---|---|---|
|
|
7a05e3afe4 | |
|
|
d9e9db06ab | |
|
|
1e52006b46 | |
|
|
028f768299 | |
|
|
3cb1bb4c47 | |
|
|
c7427dae28 | |
|
|
4a06ec52ca | |
|
|
98adf5e971 | |
|
|
1c02d6a4ab | |
|
|
2500292997 |
399
README.md
399
README.md
|
|
@ -9,6 +9,8 @@ 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
|
||||
|
|
@ -36,7 +38,9 @@ pkiapi/
|
|||
│ ├── mongo.go # MongoStore (persistance)
|
||||
│ ├── util.go # Helpers sérialisation
|
||||
│ └── errors.go # Erreurs storage
|
||||
└── go.mod
|
||||
├── tests/ # Scripts de test
|
||||
├── go.mod
|
||||
└── docker-compose.yaml # Orchestration services
|
||||
```
|
||||
|
||||
## Démarrage rapide
|
||||
|
|
@ -58,13 +62,11 @@ export PORT=8080
|
|||
# Serveur lancé sur http://localhost:8080
|
||||
```
|
||||
|
||||
**Mode production (MongoDB):**
|
||||
**Mode production (MongoDB via Docker Compose):**
|
||||
```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
|
||||
docker compose up -d --build
|
||||
# L'API est disponible sur http://localhost:8080
|
||||
# MongoDB est disponible sur mongodb://localhost:27017
|
||||
```
|
||||
|
||||
### 3. Obtenir un token JWT
|
||||
|
|
@ -110,34 +112,6 @@ 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.
|
||||
|
||||
|
|
@ -163,6 +137,7 @@ 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"
|
||||
|
|
@ -172,7 +147,7 @@ curl -X POST http://localhost:8080/api/v1/ca \
|
|||
---
|
||||
|
||||
#### GET /api/v1/ca/:id
|
||||
Récupère une autorité de certification par ID.
|
||||
Récupère une autorité de certification par ID (avec clé privée).
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
|
|
@ -192,6 +167,7 @@ curl -H "Authorization: Bearer $TOKEN" \
|
|||
"not_after": "2035-12-04T21:45:01Z",
|
||||
"serial_number": "546965196",
|
||||
"certificate": "MIIC5zCCAc+gAwIBAgIDCkUz...",
|
||||
"private_key": "MIIEwAIBADANBgkqhkiG9w0BAQE...",
|
||||
"is_ca": true
|
||||
}
|
||||
}
|
||||
|
|
@ -216,58 +192,12 @@ 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é.
|
||||
Crée un certificat auto-signé avec clé privée.
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
|
|
@ -292,6 +222,7 @@ 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"
|
||||
|
|
@ -300,45 +231,8 @@ 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.
|
||||
Récupère un certificat par ID (avec clé privée encodée en base64).
|
||||
|
||||
**Requête :**
|
||||
```bash
|
||||
|
|
@ -359,6 +253,7 @@ curl -H "Authorization: Bearer $TOKEN" \
|
|||
"not_after": "2026-12-06T21:45:09Z",
|
||||
"serial_number": "46798982",
|
||||
"certificate": "MIIDFDCCAfygAwIBAgIEAsoYhjANBg...",
|
||||
"private_key": "MIIEwAIBADANBgkqhkiG9w0BAQE...",
|
||||
"revoked": false
|
||||
}
|
||||
}
|
||||
|
|
@ -366,6 +261,25 @@ 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.
|
||||
|
||||
|
|
@ -382,15 +296,6 @@ 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
|
||||
|
|
@ -403,150 +308,142 @@ 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
|
||||
}
|
||||
---
|
||||
|
||||
### 📥 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
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
- `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
|
||||
# Stockage
|
||||
export STORAGE_TYPE=memory # memory ou mongodb (défaut: memory)
|
||||
export MONGO_URI=mongodb://localhost:27017
|
||||
export MONGO_DB=pkiapi
|
||||
|
||||
# Serveur
|
||||
export PORT=8080
|
||||
export JWT_SECRET_KEY=your-secret-key
|
||||
export GIN_MODE=release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 ci‑dessus. 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" ci‑dessus
|
||||
```
|
||||
|
||||
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
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## Future améliorations
|
||||
## Améliorations Futures
|
||||
|
||||
- [ ] Persistance en base de données (PostgreSQL)
|
||||
- [x] Export des certificats (PEM, DER)
|
||||
- [x] Clés privées dans JSON responses
|
||||
- [ ] 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
|
||||
- [ ] Auditing et logging complet
|
||||
- [ ] Rate limiting et throttling
|
||||
- [ ] Support HSM (Hardware Security Module)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -5,6 +5,8 @@ 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 (
|
||||
|
|
@ -17,7 +19,6 @@ 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
|
||||
|
|
@ -33,7 +34,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ 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"`
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +112,14 @@ 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,
|
||||
|
|
@ -145,6 +154,14 @@ 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})
|
||||
}
|
||||
|
||||
|
|
@ -210,6 +227,14 @@ 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,
|
||||
|
|
@ -278,6 +303,14 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -25,9 +27,24 @@ 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
|
||||
|
||||
|
|
@ -79,6 +96,14 @@ 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,
|
||||
|
|
@ -132,6 +157,14 @@ 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})
|
||||
}
|
||||
|
||||
|
|
@ -182,3 +215,153 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,12 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (only for CAs)
|
||||
PrivateKey string `bson:"private_key"` // Base64 encoded private key (for all certificates)
|
||||
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 (seulement pour les CAs)
|
||||
if cert.PrivateKey != nil && cert.IsCA {
|
||||
// Encoder la clé privée en base64 (pour tous les certificats)
|
||||
if cert.PrivateKey != nil {
|
||||
privKeyBytes, err := marshalPrivateKey(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
#!/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"
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
#!/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! ==="
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
#!/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! ==="
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
#!/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"
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
#!/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é ==="
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
#!/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"
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
=== 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
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
(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 };
|
||||
})();
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
: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}
|
||||
Loading…
Reference in New Issue