Écrire un client Bitwarden en python : créer, connecter, valider un compte

Je suis en train d'écrire un logiciel qui va interagir avec Bitwarden. Il existe plusieurs clients Bitwarden, mais peu sont suffisamment complet pour mon besoin.

J'ai également trouvé peu de documentation sur comment écrire un client Bitwarden.

La particularité de Bitwarden c'est que toutes la partie cryptographique est réalisé côté client.

Pour être caricatural, le serveur n'est qu'un espace de stockage.

Je n'ai testé ce code que sur l'implémentation Rust de Bitwarden : https://github.com/dani-garcia/bitwarden_rs/.

Dans ce premier billet je vais expliquer comment créer, connecter et valider un compte.

Créer un compte

Créer un compte, signifie d'abord créer deux clés de chiffrement :

  1. une clé de chiffrement symétrique (pour chiffrer le contenu de son coffre)
  2. une clé de chiffrement asymétrique (pour chiffrer les clés de chiffrement symétrique des organisations)

Créer une clé de chiffrement symétrique

Le chiffrement symétrique utilise AES en mode CBC.

Pour créer une clé de chiffrement symétrique, rien de bien compliqué. Il faut un nombre aléatoire de bytes de 64 caractères :

from secrets import token_bytes
token = token_bytes(64)

Chiffrer la clé symétrique

Ces clés sont stockées sur le serveur, mais elles ne doivent pas y être stockées en clair. Sinon quelqu'un qui accèderait à la base de donnée pourrait accéder à l'ensemble des données.

Cette clé est alors chiffrée à partir de quatre éléments :

  1. l'adresse courriel de l'utilisateur
  2. le mot de passe maître de l'utilisateur
  3. un vecteur d'initialisation
  4. un nombre d'itération

Les deux premiers éléments sont connus par l'utilisateur :

password = 'oi29ç&&ZIuçàç3'
email = 'my_mail@gnunux.info'

Attention, l'adresse courriel doit être en minuscule :

email = email.lower()

Le vecteur d'initialisation est généré :

from secrets import token_bytes
iv = token_bytes(16)

Le nombre d'itération peut être récupéré via cURL :

# curl -d '{"email":"my_mail@gnunux.info"}' -H "Content-Type: application/json" -X POST https://localhost:8001/api/accounts/prelogin
{"Kdf":0,"KdfIterations":100000}

Donc ici :

iteration = 100000

Attention la longueur de la clé doit être un multiple de 16. Pour compléter la taille du token on va ajouter n fois la longueur de la clé :

// pad_len = 16 - len(token) % 16 padding = bytes(pad_len * pad_len) content = token + padding // À partir de là il est possible de chiffrer la clé :

from hashlib import pbkdf2_hmac, sha256
from hkdf import hkdf_expand
from base64 import b64encode

master_key = pbkdf2_hmac('sha256', password.encode(), email.encode(), iterations)
enc = hkdf_expand(master_key, b'enc', 32, sha256)
mac = hkdf_expand(master_key, b'mac', 32, sha256)
c = AES.new(enc, AES.MODE_CBC, iv)
ct = c.encrypt(content)
cmac = hmac_new(mac, iv + ct, sha256)

Les informations indispensables sont alors stockées dans une unique chaine.

Quatre informations sont stockées :

  1. le type de la clé (pour les clés symétriques ce type est "2")
  2. le vecteur d'initialisation
  3. le token chiffré
  4. le digest pour valider la clé
type = 2
b64_iv = b64encode(iv).decode()
b64_ct = b64encode(ct).decode()
b64_digest = b64encode(cmac.digest()).decode()

encoded_sym_key = f'{type}.{b64_iv}|{b64_ct}|{b64_digest}'

Créer une clé de chiffrement asymétrique

Le chiffrement asymétrique utilise RSA de longueur 2048.

Pour créer une clé de chiffrement asymétrique :

from Crypto.PublicKey import RSA
asym_key = RSA.generate(2048)
private_key = asym_key.exportKey('DER', pkcs=8)
public_key = asym_key.publickey().exportKey('DER')

Chiffrer la clé asymétrique

La clé asymétrique est chiffrée non pas à partir du mot de passe et de l'adresse courriel, mais à partir de la clé symétrique. Si on change de mot de passe, il n'est ainsi nécessaire que de re-chiffrer la clé symétrique.

La clé symétrique est coupée en deux :

enc=token[:32]
mac=token[32:]

On génère un vecteur d'initialisation :

iv = token_bytes(16)

On ajoute des caractères pour arriver à un multiple de 16 :

pad_len = 16 - len(private_key) % 16
padding = bytes([ pad_len ] * pad_len)
content = private_key + padding

Et on chiffre :

ct = AES.new(enc, AES.MODE_CBC, iv).encrypt(content)
cmac = hmac_new(mac, iv + ct, sha256)

On stocke dans une chaine les informations :

type = 2
b64_iv = b64encode(iv).decode()
b64_ct = b64encode(ct).decode()
b64_digest = b64encode(cmac.digest()).decode()

encoded_asym_key = f'{type}.{b64_iv}|{b64_ct}|{b64_digest}'

Création du compte

Il nous manque une information pour créer le compte, le hash du mot de passe.

Ce hash servira à Bitwarden de valider la connexion de l'utilisateur.

Pour créer le hash :

hash_password = b64encode(pbkdf2_hmac('sha256', master_key, password.encode(), 1)).decode()

Nous pouvons ainsi construire le payload de la fonction de création du compte :

data = {'name': 'Mon nom',
              'email': email,
              'masterPasswordHash': hash_password,
              'masterPasswordHint': None,
              'key': encoded_sym_key,
              'kdf': 0,
              'kdfIterations': iterations,
              'referenceId': None,
              'keys': {
                  'publicKey': public_key,
                  'encryptedPrivateKey': encoded_asym_key
                  }
              }

Il faut faire un POST avec ce payload sur l'URL https://localhost:8001/api/accounts/register pour créer l'utilisateur.

Se connecter au compte

Pour se connecter au compte nous avons besoin :

  1. de l'adresse courriel
  2. du hash du mot de passe (voir au-dessus)
  3. d'un identifiant de client

L'identifiant n'est qu'un UUID, il devra être conservé dans la configuration du client. Sinon, à chaque nouvelle connexion un courriel sera envoyé pour prévenir qu'un nouveau client se connecte à notre compte.

Pour générer un UUID, rien de plus simple :

from uuid import uuid4
uuid = uuid4()

Nous avons toutes les informations pour créer notre payload :

data = {'grant_type': 'password',
              'username': email,
              'password': hash_password,
              'scope': 'api offline_access',
              'client_id': 'desktop',
              'device_type': 7,
              'device_identifier': uuid,
              'device_name': 'my_client',
             }

Il faut faire un POST avec ce payload sur l'URL ''https://localhost:8001/identity/connect/token' pour se connecter à l'utilisateur.

Il faut conserver les informations de connexion. Nous verrons plus tard à quoi cela servira.

Valider le compte

Un compte est valide quelques jours. Il faut donc rapidement valider le compte.

Pour valider un compte il faut soit :

  1. cliquer sur le lien envoyé par mail
  2. créer un JWT valide

Attention, pour créer un JWT valide, il faut avoir accès à la clé privée de Bitwarden_rs.

Pour valider le compte il nous faut l'ID utilisateur. Pour cela nous allons récupérer la clé "access_token" retourné lors de la connexion de l'utilisateur.

Ce "access_token" est un JWT.

from jwt import decode as jwt_decode
user_id = jwt_decode(login['access_token'], algorithm="RS256", verify=False)['sub']

Nous pouvons créer le payload :

from time import time
now = int(time())
data = {'nbf': now, 'exp': now + 432000, 'iss': 'https://localhost:8001|verifyemail', 'sub': user_id}

Il faut générer le JWT à partir de ce payload :

with open('/var/lib/bitwarden_rs/rsa_key.der', 'rb') as private_key_fh:
    private_key = RSA.importKey(private_key_fh.read()).exportKey('PEM')
jwt = jwt_encode(data, private_key, algorithm="RS256").decode()

Enfin nous pouvons faire le payload de validation :

data = {'userId': user_id, 'token': jwt}

Il faut faire un POST avec ce payload sur l'URL ''https://localhost:8001/api/accounts/verify-email-token' pour valider l'utilisateur. Dans l'entête il faut rajouter les informations d'authentification :

{'Authorization': f'Bearer {login["access_token"]}'}

La suite

Nous verrons par la suite comment créer un "identifiant", une "organisation" et une "collection".

Haut de page