Sécurité Berth : mTLS et enrôlement des agents
Sécurité Berth : mTLS et enrôlement des agents
Section titled “Sécurité Berth : mTLS et enrôlement des agents”1. Introduction
Section titled “1. Introduction”Berth est composé de trois éléments principaux :
- un agent Go, installé sur la machine qui héberge les conteneurs à piloter ;
- une API Go, qui centralise l’état, l’orchestration et les échanges avec les agents ;
- une interface Angular, utilisée par les opérateurs via l’API.
La communication entre l’agent et l’API doit authentifier les deux parties et garantir la confidentialité des échanges. Plusieurs approches étaient possibles, notamment un WebSocket protégé par un token ou un protocole de messagerie comme MQTT. Berth retient toutefois gRPC avec mTLS pour les raisons suivantes :
- l’authentification cryptographique est mutuelle : l’API authentifie l’agent et l’agent authentifie l’API ;
- TLS fournit la confidentialité et l’intégrité du transport ;
- gRPC fournit des contrats d’API structurés avec Protocol Buffers, le streaming bidirectionnel et des intercepteurs ;
- l’agent peut initier une connexion persistante vers l’API, ce qui limite les contraintes liées au NAT et aux pare-feu côté client.
L’enrôlement constitue une étape distincte : tant que l’agent physique ne possède pas encore de certificat client, il appelle l’API REST de Berth via HTTPS avec un token à usage unique. Une fois le certificat délivré, tous les échanges opérationnels normaux passent en gRPC avec mTLS strict.
2. Rappel théorique sur mTLS
Section titled “2. Rappel théorique sur mTLS”TLS classique
Section titled “TLS classique”Avec TLS classique, le client vérifie le certificat du serveur. Le serveur est donc authentifié auprès du client, mais le serveur ne dispose pas nécessairement d’une identité cryptographique équivalente pour le client :
Client -- vérifie le certificat --> ServeurClient <----- canal chiffré -----> ServeurAvec mTLS (mutual TLS), les deux extrémités présentent un certificat et prouvent qu’elles possèdent la clé privée correspondante :
Client <--- certificats vérifiés ---> ServeurClient <-------- canal mTLS --------> ServeurDans Berth, l’agent présente un certificat client et l’API présente un certificat serveur. Chaque côté vérifie le certificat de l’autre selon une chaîne de confiance issue de la CA de l’instance.
Éléments cryptographiques
Section titled “Éléments cryptographiques”- Certificat : document signé qui associe une identité à une clé publique. Il contient notamment la clé publique, la période de validité, l’identité du sujet et les extensions nécessaires, comme les noms alternatifs du serveur.
- Clé privée : secret conservé uniquement par son propriétaire. Elle sert à prouver la possession de la clé publique correspondante et ne doit jamais être transmise à l’autre partie.
- CA (Certificate Authority) : autorité de certification qui signe les certificats. Dans Berth, la CA interne signe le certificat serveur de l’API et les certificats clients des agents.
- Fingerprint : empreinte SHA-256 du certificat, généralement représentée sous forme hexadécimale. Elle donne un identifiant stable du certificat précis présenté par un agent.
La chaîne de confiance ne remplace pas l’autorisation applicative : Berth vérifie aussi que le fingerprint du certificat client est celui enregistré pour l’agent concerné et que cet agent est toujours autorisé.
3. Le fingerprint et l’enrôlement différé
Section titled “3. Le fingerprint et l’enrôlement différé”Le fingerprint est dérivé du certificat. Il est calculé après la génération ou la réception du certificat, par exemple avec la commande suivante :
openssl x509 -in agent.crt -outform DER | sha256sumIl n’est donc pas choisi arbitrairement au moment de créer un enregistrement d’agent. Avant que l’agent physique ne soit installé et enrôlé, la base de données contient un agent logique avec un fingerprint nul :
fingerprint = NULLstatus = pendingenrollment_token = <token à usage unique>enrollment_expires_at = <date d'expiration>Ce modèle correspond au pattern de création logique différée de l’enrôlement : l’API peut préparer un agent sans encore connaître le certificat réel de la machine. Le fingerprint est renseigné uniquement lorsque l’agent présente son CSR et reçoit son certificat signé.
4. Génération manuelle de la CA
Section titled “4. Génération manuelle de la CA”La CA est générée une seule fois par instance/client. Sa clé privée est le secret le plus sensible du dispositif : elle doit rester sur l’API ou dans un stockage de secrets adapté et ne doit jamais être copiée vers un agent.
L’exemple ci-dessous utilise RSA et produit une CA valable environ dix ans :
mkdir -p /etc/berth/pkichmod 700 /etc/berth/pki
# Clé privée RSA de la CAopenssl genrsa -out /etc/berth/pki/ca.key 4096chmod 600 /etc/berth/pki/ca.key
# Certificat auto-signé de la CA, valable 10 ansopenssl req -x509 -new -nodes \ -key /etc/berth/pki/ca.key \ -sha256 -days 3650 \ -out /etc/berth/pki/ca.crt \ -subj "/C=FR/O=Berth/CN=Berth Internal CA"chmod 644 /etc/berth/pki/ca.crtLa CA doit être générée avant les certificats qu’elle signera. En production, les chemins et les permissions doivent être vérifiés au démarrage.
5. Génération manuelle du certificat serveur de l’API
Section titled “5. Génération manuelle du certificat serveur de l’API”Le certificat serveur est signé par la CA. Le champ Subject Alternative Name (SAN) doit contenir les noms DNS et/ou adresses IP utilisés par les agents pour joindre l’API. Le CN seul ne suffit pas aux vérifications TLS modernes.
# Clé privée RSA du serveuropenssl genrsa -out /etc/berth/pki/server.key 4096chmod 600 /etc/berth/pki/server.key
# Fichier d'extensions avec les SAN réellement utilisés par l'APIcat > /tmp/berth-server.ext <<'EXT'basicConstraints = CA:FALSEkeyUsage = digitalSignature, keyEnciphermentextendedKeyUsage = serverAuthsubjectAltName = DNS:api.berth.local, IP:192.0.2.10EXT
# CSR du serveuropenssl req -new \ -key /etc/berth/pki/server.key \ -out /tmp/berth-server.csr \ -subj "/C=FR/O=Berth/CN=api.berth.local"
# Certificat serveur signé par la CA, valable environ 2 ansopenssl x509 -req \ -in /tmp/berth-server.csr \ -CA /etc/berth/pki/ca.crt \ -CAkey /etc/berth/pki/ca.key \ -CAcreateserial \ -out /etc/berth/pki/server.crt \ -days 730 -sha256 \ -extfile /tmp/berth-server.extchmod 644 /etc/berth/pki/server.crtLes valeurs api.berth.local et 192.0.2.10 sont des exemples : elles doivent être remplacées par les noms et adresses réellement utilisés.
6. Flow complet d’enrôlement d’un agent
Section titled “6. Flow complet d’enrôlement d’un agent”6.1 Création logique par l’API REST
Section titled “6.1 Création logique par l’API REST”Un opérateur crée l’agent via l’API REST. L’API crée une ligne dans la table des agents avec, au minimum, les champs suivants :
fingerprint = NULLstatus = pendingenrollment_token = <valeur aléatoire>enrollment_expires_at = <date d'expiration>Le token est à usage unique et possède une expiration. Il sert uniquement à autoriser la première présentation de l’agent physique.
6.2 Installation de l’agent physique
Section titled “6.2 Installation de l’agent physique”Le binaire de l’agent est installé sur la machine cible et reçoit le token d’enrollment par le mécanisme de configuration prévu par l’installation. Le token ne remplace pas le certificat mTLS permanent : il permet seulement d’initialiser l’identité cryptographique.
6.3 Génération locale de la clé et du CSR
Section titled “6.3 Génération locale de la clé et du CSR”L’agent génère localement sa paire de clés RSA ainsi que son CSR (Certificate Signing Request). La clé privée reste sur la machine de l’agent et n’est jamais transmise à l’API.
Agent : génère clé privée + clé publique + CSRAgent : conserve la clé privéeAgent : transmet uniquement CSR + token6.4 Appel de l’endpoint REST d’enrôlement
Section titled “6.4 Appel de l’endpoint REST d’enrôlement”L’agent appelle l’API REST de Berth sur un endpoint HTTPS classique, par exemple POST /api/v1/agents/enroll. Cette étape n’utilise pas gRPC : le corps de la requête est un document JSON transmis sur HTTPS et contient le token à usage unique ainsi que le CSR encodé en PEM ou en base64.
Exemple de contrat illustratif :
POST /api/v1/agents/enroll HTTP/1.1Host: api.berth.local:8443Content-Type: application/json
{ "enrollment_token": "<token à usage unique>", "csr": "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----"}La réponse reste en REST/HTTPS et renvoie le certificat signé ainsi que le certificat de la CA :
{ "signed_agent_certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", "ca_certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}Le serveur REST authentifie cette première demande par le token à usage unique et par la protection HTTPS du serveur. Il ne demande pas encore de certificat client mTLS, car l’agent ne possède pas encore cette identité. Le token protège cette étape : l’API vérifie sa valeur, son expiration et l’agent logique auquel il est associé. Un token déjà consommé ou expiré est refusé.
Exemple de handler HTTP Go, uniquement illustratif :
type EnrollRequest struct { EnrollmentToken string `json:"enrollment_token"` CSR string `json:"csr"`}
type EnrollResponse struct { SignedAgentCertificate string `json:"signed_agent_certificate"` CACertificate string `json:"ca_certificate"`}
func enrollHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return }
var req EnrollRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } // Vérifier le token à usage unique, parser le CSR, signer avec la CA, // enregistrer le fingerprint et invalider le token avant de répondre. // La clé privée de l'agent n'est jamais reçue par ce handler. _ = req
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(EnrollResponse{ SignedAgentCertificate: "<certificat signé PEM>", CACertificate: "<certificat CA PEM>", })}L’endpoint REST d’enrôlement doit être publié sur la surface HTTPS dédiée de l’API et ne doit exposer aucune opération gRPC métier. Une fois la réponse reçue et les certificats installés, l’agent abandonne ce flow REST d’initialisation et ouvre le canal gRPC opérationnel avec mTLS strict.
Le token à usage unique protège cette étape : l’API vérifie sa valeur, son expiration et l’agent logique auquel il est associé. Un token déjà consommé ou expiré est refusé.
6.5 Signature et enregistrement par l’API
Section titled “6.5 Signature et enregistrement par l’API”La structure logique de la table des agents peut être représentée ainsi (les types exacts dépendent du moteur SQL retenu) :
CREATE TABLE agents ( id UUID PRIMARY KEY, name TEXT NOT NULL, hostname TEXT NULL, description TEXT NULL, agent_version TEXT NULL, agent_status VARCHAR(50) NOT NULL, last_seen_at TIMESTAMPTZ NULL, enrollment_token TEXT NULL, mtls_cert_fingerprint TEXT NULL, created_at TIMESTAMPTZ NULL, updated_at TIMESTAMPTZ NULL);Après vérification du token, l’API :
- signe le CSR avec la CA interne ;
- produit le certificat client de l’agent ;
- calcule le fingerprint SHA-256 du certificat généré ;
- met à jour l’agent en base de données ;
- renseigne
fingerprintet passestatusàenrolled; - supprime le
enrollment_tokenet rend le token inutilisable.
Le certificat client agent est destiné à l’authentification TLS client. Sa validité cible est d’environ deux ans, conformément à la politique de renouvellement de Berth.
6.6 Réponse à l’agent
Section titled “6.6 Réponse à l’agent”L’API renvoie à l’agent :
- le certificat client signé ;
- le certificat de la CA, nécessaire pour vérifier le certificat serveur ;
- les éléments non secrets nécessaires à la configuration TLS.
La clé privée générée à l’étape précédente reste locale à l’agent.
6.7 Fonctionnement normal en mTLS
Section titled “6.7 Fonctionnement normal en mTLS”Toutes les communications suivantes — heartbeat, contrôle des conteneurs et transport des logs — passent par gRPC avec mTLS complet. L’API vérifie :
- la chaîne de confiance du certificat client ;
- son usage TLS approprié ;
- la correspondance entre le certificat présenté et le fingerprint enregistré pour l’agent ;
- le statut et l’autorisation de l’agent en base de données.
La vérification du fingerprint est effectuée par un interceptor gRPC côté serveur, en plus de la vérification TLS de la chaîne de certificats. Cette seconde vérification permet notamment de refuser un agent révoqué ou désactivé même si son certificat est encore techniquement valide.
7. Bootstrap automatique de la CA côté API en Go
Section titled “7. Bootstrap automatique de la CA côté API en Go”Au premier démarrage, l’API doit charger les fichiers de PKI existants ou les générer lorsqu’ils sont absents. L’exemple suivant montre une structure possible. Il utilise RSA, des certificats X.509 et un répertoire /etc/berth/pki. Les clés privées sont écrites avec des permissions 0600.
package pki
import ( "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "fmt" "math/big" "net" "os" "path/filepath" "time")
type Material struct { CAKey *rsa.PrivateKey CACert *x509.Certificate ServerKey *rsa.PrivateKey ServerCert *x509.Certificate}
func Bootstrap(dir string, serverDNS string, serverIP net.IP) (*Material, error) { if err := os.MkdirAll(dir, 0700); err != nil { return nil, err }
caKeyPath := filepath.Join(dir, "ca.key") caCertPath := filepath.Join(dir, "ca.crt") serverKeyPath := filepath.Join(dir, "server.key") serverCertPath := filepath.Join(dir, "server.crt")
var caKey *rsa.PrivateKey var caCert *x509.Certificate var err error if _, err = os.Stat(caKeyPath); os.IsNotExist(err) { caKey, caCert, err = generateCA() if err != nil { return nil, err } if err = writeKeyPEM(caKeyPath, caKey); err != nil { return nil, err } if err = writeCertPEM(caCertPath, caCert); err != nil { return nil, err } } else if err != nil { return nil, err } else { caKey, caCert, err = loadCA(caKeyPath, caCertPath) if err != nil { return nil, err } }
var serverKey *rsa.PrivateKey var serverCert *x509.Certificate if _, err = os.Stat(serverKeyPath); os.IsNotExist(err) { serverKey, serverCert, err = generateServerCert(caKey, caCert, serverDNS, serverIP) if err != nil { return nil, err } if err = writeKeyPEM(serverKeyPath, serverKey); err != nil { return nil, err } if err = writeCertPEM(serverCertPath, serverCert); err != nil { return nil, err } } else if err != nil { return nil, err } else { serverKey, serverCert, err = loadKeyAndCert(serverKeyPath, serverCertPath) if err != nil { return nil, err } }
return &Material{caKey, caCert, serverKey, serverCert}, nil}
func generateCA() (*rsa.PrivateKey, *x509.Certificate, error) { key, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return nil, nil, err } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, nil, err } tpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{Organization: []string{"Berth"}, CommonName: "Berth Internal CA"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().AddDate(10, 0, 0), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, BasicConstraintsValid: true, IsCA: true, } der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) if err != nil { return nil, nil, err } cert, err := x509.ParseCertificate(der) return key, cert, err}
func generateServerCert(caKey *rsa.PrivateKey, caCert *x509.Certificate, dns string, ip net.IP) (*rsa.PrivateKey, *x509.Certificate, error) { key, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return nil, nil, err } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, nil, err } tpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{Organization: []string{"Berth"}, CommonName: dns}, DNSNames: []string{dns}, IPAddresses: []net.IP{ip}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().AddDate(2, 0, 0), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, &key.PublicKey, caKey) if err != nil { return nil, nil, err } cert, err := x509.ParseCertificate(der) return key, cert, err}
func loadCA(keyPath, certPath string) (*rsa.PrivateKey, *x509.Certificate, error) { return loadKeyAndCert(keyPath, certPath)}
func loadKeyAndCert(keyPath, certPath string) (*rsa.PrivateKey, *x509.Certificate, error) { keyPEM, err := os.ReadFile(keyPath) if err != nil { return nil, nil, err } keyBlock, _ := pem.Decode(keyPEM) if keyBlock == nil { return nil, nil, fmt.Errorf("clé PEM invalide") } key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) if err != nil { return nil, nil, err } certPEM, err := os.ReadFile(certPath) if err != nil { return nil, nil, err } certBlock, _ := pem.Decode(certPEM) if certBlock == nil { return nil, nil, fmt.Errorf("certificat PEM invalide") } cert, err := x509.ParseCertificate(certBlock.Bytes) return key, cert, err}
func writeKeyPEM(path string, key *rsa.PrivateKey) error { data := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) return os.WriteFile(path, data, 0600)}
func writeCertPEM(path string, cert *x509.Certificate) error { data := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) return os.WriteFile(path, data, 0644)}Attention : cet exemple illustre le bootstrap et le chargement. En production, le démarrage doit aussi valider les permissions, vérifier que la clé privée correspond au certificat, gérer les erreurs de fichiers partiels et protéger le répertoire contre les accès non autorisés. La clé privée de la CA ne doit jamais être envoyée à un agent.
8. Architecture réseau : deux surfaces distinctes
Section titled “8. Architecture réseau : deux surfaces distinctes”Berth sépare les usages sur deux surfaces réseau et deux ports distincts :
- un serveur REST/HTTPS classique (par exemple le port
8443) pour l’enrôlement et la gestion générale de l’API ; - un serveur gRPC avec mTLS strict (par exemple le port
9443) pour les communications opérationnelles des agents : heartbeat, contrôle des conteneurs, logs etCommandChannel.
API Berth +-----------------------+ | |Agent non enrôlé | REST / HTTPS | Agent enrôlé---------------->| Port 8443 |<---------------- gRPC + mTLS strict POST /api/v1/ | enrollment + API | port 9443 agents/enroll | générale | services normaux CSR + token | | +-----------------------+La surface REST/HTTPS traite l’enrôlement avec le token à usage unique et les autres opérations générales de l’API. La surface gRPC sur le port 9443 est réservée aux communications opérationnelles et exige un mTLS strict ; elle ne doit pas être utilisée pour l’enrôlement initial.
9. Canal bidirectionnel gRPC CommandChannel
Section titled “9. Canal bidirectionnel gRPC CommandChannel”CommandChannel est un stream gRPC bidirectionnel persistant. L’agent initie la connexion vers l’API, puis la conserve ouverte. Ce modèle évite que l’API ait à ouvrir une connexion entrante vers une machine souvent située derrière un NAT ou un pare-feu client.
Le stream permet aux deux côtés d’émettre des messages. Pour obtenir un comportement proche d’un appel RPC classique au-dessus du stream, Berth associe chaque commande à un identifiant de corrélation :
Agent API | | |==== ouverture gRPC + mTLS ============>| | | |<--- Command{id: 42, action: ...} ------| | | |--- Result{id: 42, payload: ...} ------->| | | |--- Heartbeat -------------------------->| |<--- Command{id: 43, ...} --------------|L’API conserve les commandes en attente par identifiant. Lorsque l’agent renvoie un résultat portant le même ID, le serveur retrouve l’opération correspondante et la termine. Les messages entrants doivent rester associés à l’agent authentifié par le certificat mTLS ; un ID de corrélation ne constitue pas une identité ni une autorisation.
10. Bonnes pratiques et points de vigilance
Section titled “10. Bonnes pratiques et points de vigilance”Rotation et renouvellement
Section titled “Rotation et renouvellement”- Prévoir le renouvellement des certificats agents et serveur avec une durée de vie cible d’environ 2 ans.
- Prévoir une durée de vie d’environ 10 ans pour la CA, avec une procédure de remplacement planifiée.
- Renouveler avant expiration afin d’éviter une interruption de connexion.
- Lors d’un renouvellement, mettre à jour le fingerprint enregistré selon le protocole prévu par l’API.
Révocation d’un agent
Section titled “Révocation d’un agent”La révocation peut être représentée par une suppression ou un flag en base de données. L’interceptor gRPC doit vérifier le statut et le fingerprint à chaque authentification ou connexion pertinente. Ainsi, un certificat encore valide cryptographiquement ne suffit pas à maintenir l’accès d’un agent révoqué.
Protection de la CA
Section titled “Protection de la CA”La clé privée de la CA est le composant le plus sensible du système. Toute personne ou processus qui la possède peut potentiellement signer de nouveaux certificats acceptés par les composants qui font confiance à cette CA. Elle doit donc :
- rester sur l’API ou dans un stockage de secrets contrôlé ;
- être lisible uniquement par le compte de service approprié ;
- être sauvegardée de façon chiffrée et auditée ;
- ne jamais être incluse dans la configuration ou l’archive distribuée à un agent.
Enrollment et audit
Section titled “Enrollment et audit”- Rendre les
enrollment_tokenaléatoires, à usage unique et limités parenrollment_expires_at. - Supprimer le token après une réussite et refuser toute réutilisation.
- Enregistrer des logs d’audit pour les créations logiques, les demandes de CSR, les signatures, les échecs, les expirations et les révocations.
- Ne jamais écrire une clé privée ou un token complet dans les logs.
11. Tableau récapitulatif du cycle de vie d’un agent
Section titled “11. Tableau récapitulatif du cycle de vie d’un agent”| Étape | État / données en BDD | Action principale | Canal et sécurité |
|---|---|---|---|
| Création logique | fingerprint = NULL, status = pending, token et expiration présents |
L’API prépare l’identité logique | API REST authentifiée selon la politique de l’API |
| Installation physique | Toujours pending |
Le binaire est installé avec le token | Configuration locale de l’agent |
| Préparation cryptographique | Toujours pending |
L’agent génère sa clé privée, sa clé publique et son CSR localement | La clé privée ne quitte jamais l’agent |
| Demande d’enrollment | Toujours pending |
L’agent envoie CSR + token | REST/HTTPS POST /api/v1/agents/enroll, token à usage unique |
| Validation et signature | Transition vers enrolled |
L’API vérifie le token, signe le CSR avec la CA et calcule le fingerprint SHA-256 | Endpoint Enrollment limité à cette opération |
| Finalisation | fingerprint renseigné, status = enrolled, token supprimé |
L’API renvoie le certificat signé et le certificat CA | Réponse REST/HTTPS avec le certificat signé et le certificat CA |
| Fonctionnement normal | enrolled et fingerprint vérifié |
Heartbeat, commandes, contrôle des conteneurs et logs | gRPC + mTLS ; interceptor avec vérification du fingerprint |
| Renouvellement ou révocation | Nouveau fingerprint, ou statut désactivé/révoqué | Renouveler le certificat ou bloquer l’agent | Contrôles mTLS et vérifications BDD |
