À la fin des années 2000, je m’étais construit une PKI à base de scripts bash autour d’OpenSSL. C’était le seul truc open-source et simple d’accès que j’avais trouvé à l’époque. Ca marchait plutôt bien, aucun doute là-dessus, mais j’étais tout seul à la gérer.

Tel que je l’avais conçue, ma PKI était encrypted at rest mais toute personne qui devait la gérer avait accès de fait aux clés privées de toutes les CA gérées, pas fou. Je devais également regénérer les CRL régulièrement.

Une de mes sub CA pour gérer mes services maison arrive à expiration. Je me dis que c’est le moment de jouer avec Vault de Hashicorp et voir ce que ça vaut. J’en profiterais également pour me réactualiser sur les best practices actuelles sur les CA, quitte à faire tabula rasa.

Architecture cible

Nous aurons deux CA racine :

  • une avec une clé RSA
  • une autre avec une clé ECDSA

Je privilégierais une autorité racine avec la clé ECDSA, mais s’il arrivait qu’une application ne “supporte” pas ce type de certificat, je pourrais me rabbatre sur une racine plus “classique” avec une clé RSA.
Pour la même raison, l’autorité racine ECDSA sera cross-signée par l’autorité racine RSA, ce qui pourrait me permettre d’utiliser un certificat final ECDSA avec une autorité racine RSA. C’est ce qu’a fait Let’s Encrypt.

Ces autorités racines se limiteront à émettre des certificats d’autorités uniquement. Ces derniers n’émettront en revanche que des certificats finaux, on imposera ainsi un pathlen=0 au niveau des CA intermédiaires.

Architecture cible

Au niveau des noms des CA, j’ai opté pour un schéma simple mais clair :

{émetteur} {role} {type}{génération|itération}

Ainsi,

  • l’émetteur est mon organisation : Kveer
  • le rôle : Root pour les racines, Home pour mon domaine Windows à la maison
  • le type : R pour RSA ou E pour ECDSA
  • l’itération, la n-ième fois que le certificat a été généré/renouvelé pour le même rôle

Sur les différents aspects techniques des CA, à savoir la taille des clés, la durée de validité, les bonnes extensions X509v3 à mettre, en critique ou pas, ceux-ci sont définit par le CA/Browser forum. Ils s’imposent aux CA qui émettent des certificats publiques, pour des sites web publiques, j’en suis donc exclus, mais les suivre ne peut pas abaisser le niveau de sécurité par rapport à faire au feeling, bien au contraire.

Pré-requis

Pour la suite, on suppose une instance ou un cluster Vault fonctionnel et avec les droits suffisants. Il faudra un terminal avec bash et disposant du binaire Vault. Ca marche aussi très bien sous cmd ou Powershell, il faudra simplement adapter les commandes, notamment sur le multiline.

Suivez cette documentation pour l’installer ou télécharger le binaire pour Windows.

On définiera également deux variables pour indiquer l’instance et le token à utiliser par la CLI Vault, ainsi que les champs organisation, locality et country du subject pour rendre les commandes un peu plus génériques.

vault_domain=vault.example.com
VAULT_ADDR="https://$vault_domain"
VAULT_TOKEN="hvs.xxxxxxxxxxxxxxxx"

vault_org=Kveer
vault_loc=Paris
vault_country=FR

Créer la root en RSA

On va créer le tout premier certificat racine avec une clé RSA de 4096 et d’une durée de 25 ans. Elle s’appellera Kveer Root R3 car c’est ma troisième racine.

Les paramètres path et aia_path sont pour l’accès à la CRL, et une CRL doit être spécifié avec une url en HTTP, surtout pas en HTTPS.

engine=pki-kveer-root-r3

vault secrets enable -path=$engine -max-lease-ttl=9132d pki

vault write $engine/root/generate/internal \
  common_name="$vault_org Root R3" organization="$vault_org" locality="$vault_loc" country="$vault_country" \
  issuer_name="kveer-root-r3" \
  ttl=9132d \
  key_type=rsa key_bits=4096 \
  exclude_cn_from_sans=true

vault write $engine/config/crl auto_rebuild=true enable_delta=true

vault write $engine/config/cluster \
    path=http://$vault_domain/v1/$engine \
    aia_path=http://$vault_domain/v1/$engine
vault write $engine/config/urls enable_templating=true
vault write $engine/config/urls \
    crl_distribution_points={{cluster_aia_path}}/crl \
    delta_crl_distribution_points={{cluster_aia_path}}/crl/delta \
    issuing_certificates={{cluster_aia_path}}/issuer/{{issuer_id}}/der \
    ocsp_servers={{cluster_path}}/oscp

Il est fortement recommandé que les autorités racines soient offlines afin d’anéantir complètement la surface d’attaque. Cependant, cela signifie :

  • de régulièrement remettre la CA online pour pouvoir regénérer la CRL
  • de spécifier une durée de vie longue pour la CRL, par exemple 30 jours
  • de spécifier sur les sub CA un point d’accès à la CRL qui doit être tout le temps disponible, donc décorelé de la gestion de l’autorité racine

C’est pour ce dernier point que Vault permet de spécifier librement les points d’accès des CRL. Vault a un endpoint permettant de récupérer les CRLs des autorités de certificats gérées, mais spécifier un point d’accès différent sur les certificats permet :

  • de spécifier un serveur HTTP qui tiendra la charge
  • de spécifier un serveur HTTP accessible, contrairement à Vault qui pourrait être sur un réseau à accès très restreint
  • de faciliter la sécurisation en mettant un “bête” serveur HTTP comme point d’accès de la CRL
  • de supporter les contraintes qu’engendre la gestion d’une CA offline

Créer une seconde racine en ECC

On procède à l’identique pour la seconde racine, celle avec une clé en ECDSA.

J’aurais préféré générer un certificat utilisant la courbe elliptique ED25519, mais elle n’est explicitement pas autorisée par le CA Browser Forum (section 6.1.5) : seule les courbes du NIST (les P-256, P-384 et P-521) sont supportées. Dit autrement, même si je pouvais créer un tel certificat, je courrais le risque de rencontrer des incompatibilitées au niveau des navigateurs ou des serveurs.

engine=pki-kveer-root-e1

vault secrets enable -path=$engine -max-lease-ttl=9131d pki

vault write $engine/root/generate/internal \
    common_name="$vault_org Root E1" organization=$vault_org locality="$vault_loc" country="$vault_country" \
    issuer_name="kveer-root-e1" \
    ttl=9131d \
    key_type=ec key_bits=384 \
    exclude_cn_from_sans=true

vault write $engine/config/crl auto_rebuild=true enable_delta=true

vault write $engine/config/cluster \
    path=http://$vault_domain/v1/$engine \
    aia_path=http://$vault_domain/v1/$engine
vault write $engine/config/urls enable_templating=true
vault write $engine/config/urls \
    crl_distribution_points={{cluster_aia_path}}/crl \
    delta_crl_distribution_points={{cluster_aia_path}}/crl/delta \
    issuing_certificates={{cluster_aia_path}}/issuer/{{issuer_id}}/der \
    ocsp_servers={{cluster_path}}/oscp

Cross-signer l’autorité racine E1 avec l’autorité racine R3

Pour des raisons de compatibilité, et en espérant ne jamais m’en servir, on va signer l’autorité racine E1 avec l’autorité racine R3, avec toutefois une durée de 5 ans, en effet ce certificat est de fait une autorité intermédiaire, et doit donc avoir la durée de validité qui va avec, à savoir 5 ans pour mon organisation.

_cn="$vault_org Root E1"
_engine_r3=pki-kveer-root-r3
_engine_e1=pki-kveer-root-e1
_e1_default_issuer=$(vault read -field=default $_engine_e1/config/issuers)
_key_id=$(vault read $_engine_e1/issuer/$_e1_default_issuer | grep -i key_id | awk '{print $2}')
_csr=$(mktemp)
_crt_xc=$(mktemp)

vault write -format=json $_engine_e1/intermediate/cross-sign \
    common_name="$vault_org Root E1" organization=$vault_org locality="$vault_loc" country="$vault_country" \
    key_ref=$_key_id | jq -r '.data.csr' > $_csr
vault write -format=json $_engine_r3/root/sign-intermediate use_csr_values=true csr=@$_csr ttl=1826d exclude_cn_from_sans=true | jq -r '.data.certificate' | tee $_crt_xc
vault write $_engine_e1/intermediate/set-signed certificate=@$_crt_xc

issuer_uuid=$(vault list -detailed $engine/issuers | grep false | awk '{print $1}')
vault write $_engine_e1/issuer/$issuer_uuid issuer_name=xc-kveer-root-e1

rm $_csr $_crt_xc

Autoriser les points de distributions CRL en HTTP (version Traefik)

Comme spécifié dans les exigences du CAB Forum, section 7.1.2.11.2 (CRL Distributions Points), les points de distributions des CRL doivent être accessibles en HTTP seulement, pas de HTTPS.

Dans mon cas, c’est Vault qui servira les différentes CRLs, et c’est donc Vault qui est spécifié dans les certificats émis pour le point d’accès des CRL.
Si l’on a exposé son vault derrière un reverse proxy avec tout en HTTPS, il faudra y paramétrer une exception. Ici, l’ensemble de mes PKI sont préfixées par pki- pour avoir un semblant d’ordre dans Vault mais également pour pouvoir gérer ce cas simplement.

Les URLs à autoriser seront donc :

  • http://vault.example.com/v1/pki-*/crl
  • http://vault.example.com/v1/pki-*/crl/delta
  • http://vault.example.com/v1/pki/oscp*

Mon instance Vault est exposée au travers de Traefik dont la configuration est faites dynamiquement à l’aide de labels sur le service docker. Je peux autoriser ces URLs en créant un second routeur me permettant de restreindre les accès en HTTP à ces seules URL.

services:
  vault:
    image: hashicorp/vault:1.20
    ...
    labels:
      # mes labels usuels pour exposer vault en HTTPS only
      kveer.dns: vault.example.edu
      traefik.http.routers.vault.entryPoints: web-ssl
      traefik.http.routers.vault.middlewares: source_fr@file
      traefik.http.services.vault.loadbalancer.server.port: 8200
      traefik.http.services.vault.loadbalancer.server.scheme: https
      # autorise l'accès aux CRLs en HTTP
      traefik.http.routers.vault-pub.entryPoints: web
      traefik.http.routers.vault-pub.middlewares: source_fr@file
      traefik.http.routers.vault-pub.rule: Host(`vault.example.edu`) && (Method(`GET`) && PathRegexp(`^/v1/pki-.*/crl(|/delta)$`)) || PathRegexp(`^/v1/pki-.*/oscp(|/.*)$`)

Le service vault est implicitement rattaché aux deux routeurs vault et vault-pub.

Sources