Cloud sécurisé

Application CLI en Python implémentant un mini-cloud chiffré

Table des matières

Introduction

Le but de ce document est d’expliquer les choix cryptographiques et d’implémentation du projet. Ce projet consistait en la modélisation et l’implémentation d’un système de fichiers chiffrés légers.

Les fonctionnalités principales sont les suivantes :

  • S’enregistrer
  • Se connecter
  • Changer son mot de passe
  • Créer un dossier
  • Download un fichier depuis le serveur
  • Upload un fichier au serveur
  • Partager un dossier avec d’autres utilisateurs

Le choix du langage a été le python.

Manuel d’installation

Le code source est disponible sur le repo GitHub suivant :

https://github.com/hugoducom/secure-cloud

La procédure d’installation est la suivante :

git clone https://github.com/hugoducom/secure-cloud.git
cd secure-cloud
# Possibilité de faire un venv
pip install -r requirements.txt
python main.py

Application testée et recommandé d’utilisé avec Python 3.10 ou plus.

Il est important de souligner que vous pouvez créer autant de fichiers que vous voulez “à la main” directement depuis votre système de fichiers, en revanche les dossiers doivent être créé via l’option dédiée directement depuis le programme.

Sécurité

Modèle d’adversaire

Le modèle d’adversaire supposé est le suivant :

  • Le serveur stockant les fichiers est honnête mais curieux
  • Les attaquants sont considérés comme actifs

De cela en découlent les propriétés du système suivantes :

  • Les noms des fichiers/dossiers doivent être confidentiels
  • La taille et la structure peuvent leak
  • Le contenu des fichiers doit être confidentiel

Primitives

Chiffrement symétrique

Primitive choisie :

XChaCha20_Poly1305

Raisons :

  • Chiffrement authentifié
  • Nonce grand (24 bytes) pour éviter le nonce reuse au maximum
  • Recommandé par ECRYPT
  • Déchiffrement partiel possible (utile en cas d’adaptation pour des gros fichiers)
  • Rapide

AES-GCM-SIV aurait aussi été un choix judicieux car il regroupe également les mêmes avantages. Le fait que ce soit un stream cipher ou un block cipher importe peu. Le block cipher impose simplement de gérer un bon padding et des textes chiffrés de taille minimum.

Chiffrement asymétrique

Primitive choisie :

RSA OAEP PKCS#1 v2.2

Raisons :

  • Simple à implémenter
  • Disponible dans pycryptodome
  • Standardisé
  • Recommandé par ECRYPT

RSA OAEP n’est pas utilisé de pair avec une signature digitale, car l’authenticité est suposée tacite à travers le canal sécurisé (TLS).

L’implémentation a été discutée dans la partie Problèmes connus.

Nombres aléatoires

Le générateur de nombre aléatoire utilise la librairie pycryptodome :

Crypto.Random.get_random_bytes()

Qui prend sa source directement depuis l’OS et non pas depuis un CSPRNG de l’espace utilisateur.

Dérivation de clés

Primitive choisie :

HKDF

Raisons :

  • Standardisé dans la RFC 5869
  • Implémenté dans pycryptodome

Fonction de hashage

Notre implémentation n’avait pas besoin de fonction de hashage à une exception.

Lorsque l’utilisateur rentre son nom d’utilisateur et son mot de passe, le mot de passe est hashé avec argon2id et le nom d’utilisateur est fourni en tant que sel.

Pour cela, il a fallu hashé le nom d’utilisateur pour qu’il ait la taille du sel grâce à une fonction de hashage.

La primitive choisie a donc été SHAKE256 pour bénéficier du hash de taille variable en sortie. SHAKE256 à la place de SHAKE128 car il offre plus de résistance aux préimages et aux collisions.

Hash de mots de passe

Primitive choisie :

argon2id

Paramètres choisis :

  • pysodium.crypto_pwhash_argon2i_OPSLIMIT_MODERATE
  • pysodium.crypto_pwhash_argon2i_MEMLIMIT_MODERATE

Avec ces paramètres, le hash de mot de passe prend environ 1 seconde sur un laptop classique (testé sur Lenovo Thinkpad E14).

Raisons :

  • Recommandé pour parer les attaques GPU et par canaux auxiliaires
  • Moderne
  • Meilleure sécurité que bcrypt, scrypt et PBKDF2

Le canal entre le client et le serveur est supposé comme sécurisé et le serveur est considéré comme authentifié.

Taille des clés

La taille des clés a été définies sur la base de keylength.com selon les recommandations d’ECRYPT.

Clés symétriques : 256 bits

Basé sur la long-term protection (> 30 ans).

Module (RSA) : 3072 bits

Basé sur la near-term protection (> 10 ans).

Architecture

L’architecture utilisée est une architecture client-server.

Le fichier server/api.py simule une API qui n’est pas implémentée mais consiste en l’unique point d’entrée au serveur pour le client afin de prévoir l’éventuelle implémentation d’une vraie API.

Les communications sont considérés comme sûres (via un canal TLS par exemple).

Les utilisateurs auront deux clés générées :

  • Une clé symétrique utilisée pour :
    • Chiffre le contenu du dossier root de l’utilisateur
  • Une paire de clés asymétriques pour :
    • Les partages de dossier

Création de compte

Voici un schéma récapitulatif des actions faites à l’enregistrement :

Schéma d’enregistrement

Voici à quoi ressemble les metadatas associées à l’utilisateur enregisté :

{
  "username": "alexis",
  "password_hash": "JGFyZ29uMmlkJH...",
  "encrypted_sym_key": {
    "cipher": "W/V2BEdTPbFVMgeYOXrhKcgJXTpwCt0bLNZxR/ktTak=",
    "nonce": "0Rwsjlote6YkM/TaS1s+6v1nb/5eNv7W",
    "tag": "CVCKPtYjnh5wVaO+JDCQ/w=="
  },
  "encrypted_private_key": {
    "cipher": "u13yDq...",
    "nonce": "5eilAJ8SdKm6xgb7rNDXtJ3yZgp7JB3z",
    "tag": "s1A3CMrGeTew0wVimnh2Tg=="
  },
  "public_key": "LS0tLS1CRUdJTi...",
  "shares": []
}

Les actions qui vont se passer côté client :

  • Génération de la master_key
  • Dérivation de la stretched_master_key
  • Génération de la clé symétrique
  • Chiffrement de la clé symétrique
  • Génération de la paire de clés asymétriques
  • Chiffrement de la clé privée

Les actions qui vont se passe côté serveur :

  • Ajout d’un sel aléatoire au password_hash fourni avec argon2id
  • Stockage des metadata utilisateur

Le but est donc de :

  • Envoyer le mot de passe au serveur (hashé bien sûr)
  • Envoyer la clé symétrique (chiffrée bien sûr)
  • Envoyer sa clé asymétrique privée (chiffrée bien sûr)

Et cela afin de pouvoir récupérer toutes ses clés en cas de connexion depuis un autre poste qui n’a pas les clés en local. Dès lors, il pourra télécharger les dossier/fichiers et les déchiffrer.

La propriété shares sera discuté au chapitre Partage de dossiers.

Connexion

Voici un schéma récapitulatif des actions faites à la connexion :

Schéma de connexion

La client va tout simplement répéter le actions faites à l’enregistrement et soumettre son username et son hash de mot de passe au serveur pour validation.

S’en suit ensuite une étape pour récupérer sa clé symétrique et sa clé privée et les déchiffrer.

Changement de mot de passe

L’architecture du système a été faite pour simplifier cette fonctionnalité.

L’astuce permettant cela est que la clé symétrique de l’utilisateur n’est pas changée. Ce qui est changé c’est la manière dont elle est chiffrée.

Ainsi, pas besoin de re-chiffrer des dossiers ou même la clé privée.

Voici la démarche :

  1. L’utilisateur se connecte
  2. Récupère la clé symétrique en clair
  3. Récupère la clé privée en clair
  4. L’utilisateur tape un nouveau mot de passe
  5. Chiffre la clé symétrique
  6. Envoi au serveur pour la remplacer dans les metadata

Rien de plus simple !

Metadata

Afin d’expliquer comment le serveur stocke les metadata, nous allons prendre une structure de fichiers exemple :

Structure de fichiers exemple

Il y a donc 1 metadata à la racine du serveur par utilisateur. Une metadata utilisateur ressemble à ce qui a été listé dans le chapitre Création de compte.

Lorsque l’utilisateur user1 va partager son dossier to_share avec le user2, la metadata de user2 va être modifié comme suit :

// metadata-user2.json
{
  "username": "user2",
  "password_hash": "...",
  "encrypted_sym_key": {
    "cipher": "...",
    "nonce": "...",
    "tag": "..."
  },
  "encrypted_private_key": {
    "cipher": "...",
    "nonce": "...",
    "tag": "..."
  },
  "public_key": "...",
  "shares": [
    // NEW OBJECT
    {
      "enc_name": "OhjcGywA...",
      "enc_sym_key": "r+76ao5IB4D...",
      "vault_path": "C:\\...\\secure-cloud\\server\\vault\\files\\user1",
      "uuid": "c44c9f90-4dc2-45b3-bdbf-a6df321f4333"
    }
  ]
}

Un objet share lui a été rajouté qui contient la clé symétrique du dossier partagé chiffré avec sa propre clé public (détails au chapitre Partage de dossier), le nom du dossier partagé chiffré avec sa clé public, son uuid et son chemin sur le serveur.

Concernant les dossiers, il y a 1 metadata par dossier. Qui ressemble à cela :

// metadata-user1.json
{
  "uuid": "user1",
  // No enc_name because root user folder
  "enc_name": { "cipher": "AA==", "nonce": "AA==", "tag": "AA==" },
  "enc_sym_key": {
    "cipher": "...",
    "nonce": "...",
    "tag": "..."
  },
  "vault_path": "C:\\...\\secure-cloud\\server\\vault\\files",
  "owner": "user1",
  "nodes": [
    // Corresponding to `to_share` folder
    {
      "uuid": "c44c9f90-4dc2-45b3-bdbf-a6df321f4333",
      "enc_name": {
        "cipher": "...",
        "nonce": "...",
        "tag": "..."
      },
      "vault_path": "C:\\...\\secure-cloud\\server\\vault\\files\\user1",
      "node_type": "folder"
    },
    // Corresponding to `test.txt` file
    {
      "uuid": "ee6073ca-42f7-4095-9daa-22a02746ea86",
      "enc_name": {
        "cipher": "...",
        "nonce": "...",
        "tag": "..."
      },
      "vault_path": "C:\\...\\secure-cloud\\server\\vault\\files\\user1",
      "node_type": "file"
    }
  ]
}

Les nodes correspondent aux enfants directs du dossier en question. Cela permet notamment à l’utilisateur de savoir les noms de fichiers à télécharger sans pour autant les avoir récupérés en local.

Le vault_path est le chemin du dossier ou du node sur le serveur (n’inclus pas son UUID dans notre implémentation).

Chiffrement des fichiers

Si nous reprenons la structure exemple précédente. Lorsque le user1 a créé le fichier user1/test.txt, qui contient test.

Si l’utilisateur upload ce fichier, il va se passer la chose suivante :

  1. Construit une structure NodeMetadata :
NodeMetadata(
  # Lui assigne un UUID
  uuid=str(uuid.uuid4()),
  # A préalablement chiffré son nom de fichier
  enc_name=(enc_name, nonce, tag),
  # Chemin vers son dossier parent
  vault_path=vault_path,
  # Fichier
  node_type=node_type,
)

Cette metadata sera ajoutée à la metadat du DOSSIER dans la propriété nodes.

  1. Chiffre le contenu du fichier
def encrypt_file_content(self, content: bytes) -> (bytes, bytes, bytes):
    """
    Encrypt the content of a file
    :param content: Content of the file as bytes
    :return: EncryptedFile object
    """
    nonce = Crypto.Random.get_random_bytes(24)
    _, enc_content, tag = xcha_cha_20_poly_1305_encrypt(content, nonce, self.current_folder.sym_key)
    return EncryptedFile(
      (enc_content, nonce, tag)
    )

Chiffrement des dossiers

Pour le chiffrement des noms des dossiers et de leur metadata, c’est un peu plus compliqué, nous allons nous aider d’un schéma :

Chiffrement des dossiers

Chaque dossier (et ses metadonnées secrètes, soit sa clé symétrique et son nom) sont chiffrés avec la clé symétrique du dossier parent.

Dans le cas du dossier racine de l’utilisateur, tout ça est chiffré avec la clé symétrique de l’utilisateur.

Partage de dossiers

Afin d’expliquer comment se passe le partage de dossier, reprenons notre cas exemple où le user1 partage son dossier to_share avec le user2.

Le but pour le user2 est de pouvoir accéder à la clé symétrique du dossier to_share afin de le déchiffrer.

Grâce au chiffrement des dossiers en cascade, pas besoin pour lui d’accéder à d’autres clés symétriques, il pourra les déchiffrer par lui-même grâce à la clé symétrique du dossier to_share.

Voici un schéma du partage en question :

Partage du dossier <code>to_share</code>

Grâce au chiffrement asymétrique, le user1 peut donc chiffrer la clé symétrique de son propre dossier et l’envoyer dans les shares des metadata de user2.

Ainsi, pas besoin que le user2 soit connecté ou autre, à sa reconnexion, il pourra accéder à cette clé symétrique grâce à sa clé privée.

Révocation

Cette partie n’a pas été implémentée.

La révocation est la seule étape très lourde à effectuer pour le serveur contrairement aux fonctionnalités ci-dessus.

La révocation consiste à départager un dossier avec un utilisateur.

Cela implique, que quoiqu’il arrive, après révocation, cet utilisateur ne pourra plus accéder au fichier sur le serveur (même ceux qu’il connaissait déjà).

Imaginons la structure de dossier suivante :

to_share <-- partagé avec user2
    |---sub
        |---subsub

Pour cela, on doit partir du dossier le plus profond (subsub ici).

Déchiffrer tous les fichiers présents, générer une nouvelle clé symétrique et rechiffrer les fichiers avec cette dernières.

Lorsque c’est fait, nous devons générer une nouvelle clé symétrique pour le dossier parent (sub ici) et chiffrer la nouvelle clé symétrique de subsub avec. Puis déchiffrer et rechiffrer les fichiers du dossier sub avec la nouvelle clé et recommencer jusqu’à arriver à to_share.

Dans le cas où un dossier contient plusieurs dossiers, c’est le même procédé.

Après cela, on se retrouve avec une nouvelle clé symétrique pour le dossiers to_share, il faut refaire le procédé de partage (c’est à dire chiffrer avec la clé public de chaque utilisateur à qui nous avons partagé le dossier) et mettre à jour les metadata de ces utilisateurs avec la nouvelle enc_sym_key et le nouveau enc_name.

Bien sûr, il ne faut pas refaire ce procédé de partage avec la personne concernée par la révocation.

Ainsi, l’utilisateur avec qui nous avons voulu départager le dossier se retrouvera avec une clé symétrique invalide pour déchiffrer le dossier to_share !

La révocation se fait donc en complexité \(O(n)\) où \(n\) est le nombre de dossiers enfants du dossier à révoqué.

Performances

Au niveau des performances, les algorithmes de chiffrement utilisés sont standardisés et pour la plupart rapides.

Pour ce qui est des metadata, il y a en a une par utilisateur et une par dossier, ce qui fait beaucoup de fichiers à ouvrir et fermer en permance.

Bien sûr, une base de données n’était pas demandé mais cela aurait probablement augmenter les performances en y stockant toute les metadata. Mais cela rajoute une couche en plus à gérer pour le serveur.

Au niveau des performances côté utilisateur, les seuls grand moments d’attente sont lorsque l’utilisateur se crée un compte (génération de la master_key avec argon2 soit environ 1 seconde) ainsi qu’à la génération de sa paire de clé asymétrique de 3072 bits.

Pour cela, on ne peut pas faire grand chose et l’action de s’enregistrer est tellement ponctuel que c’est acceptable.

Pour ce qui est des téléchargements de fichiers, dans notre implémentation, l’utilisateur doit télécharger les fichiers et dossiers 1 par 1. Bien sûr téléchager tout un dossier d’un coup aurait été plus performant et confortable.

Parlons maintenant du problème des gros fichiers, j’ai essayé d’upload et de télécharger quelques fichiers de test :

Vidéo :

Decrypted file size      : 126 Mo
Time for file encryption : 1.29 sec
Time for file decryption : 0.89 sec
Encrypted file size      : 168 Mo

Disque de machine virtuelle :

Decrypted file size      : 2,5 Go
Time for file encryption : 22.12 sec
Time for file decryption : -
Encrypted file size      : -

La fichier a pu se chiffrer convenablement en revanche envoyer un si gros objet et l’écrire en entier avec un f.write() demande trop de travail au serveur. Un erreur survient même avant la fin de l’écriture :

OSError: [Errno 28] No space left on device

À savoir que de plus, ici tout se fait localement sur la même machine !

Pour supporter les plus gros fichiers il faudrait envoyer le fichiers chiffré par chunks au serveur et les chiffrer petit à petit, même chose pour le déchiffrement.

Pour les données non-textuels (ce qui est notre cas), on peut faire du buffer writing en Python :

def write_large_data_to_file(filename, data, chunk_size=8192):
    with open(filename, "wb", buffering=chunk_size) as file:
        for chunk in data:
            file.write(chunk)

Par dessus cela, il existe une librairie multiprocessing pour paralléliser ces actions aux coeurs du processeur.

Problèmes connus

RSA OAEP

L’implémentation de RSA OAEP PKCS#1 v2.2 a été faite avec pycryptodome.

Selon la documentation de ce dernier :

PKCS#1 OAEP is an asymmetric cipher based on RSA and the OAEP padding. It is described in RFC8017 where it is called RSAES-OAEP.

Il cite la RFC 8017, qui décrit bien RSA OAEP PKCS#1 v2.2.

Cependant, toujours dans la documentation :

Cipher object for PKCS#1 v1.5 OAEP. Do not create directly: use new() instead.

Il cite cette fois-ci RSA OAEP PKCS#1 v1.5, qui est à éviter.

Alors quelle est la norme implémentée par pycryptodome, aucune idée. Si dans le futur c’est bel et bien la norme v1.5, il faudrait changer de libraire pour implémenter RSA-OAEP.

Argon2

Dans notre implémentation de argon2id, lorsque le serveur créé les metadata utilisateur associée à ce qu’il a reçu du client, on remarque :

  "password_hash": "JGFyZ29uMmlkJHY9...2xjUDZCRjlaUjAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",

Le password_hash contient une serie de A à la fin, au niveau du bytes cela correspond à plein de \x00 à la fin de la sortie du argon2 effectué côté serveur à l’enregistrement du nouveau compte.

Selon la documentation de libsodium pour la fonction crypto_pwhash_str():

The output string is zero-terminated, includes only ASCII characters, and can be safely stored in SQL databases and other data stores. No extra information has to be stored to verify the password.

Pourtant, l’appel à la librairie pysodium qui implémente libsodium côté serveur me paraît correct :

def argon2(password_hash: bytes) -> bytes:
    return pysodium.crypto_pwhash_str(
      password_hash,
      pysodium.crypto_pwhash_argon2i_OPSLIMIT_MODERATE,
      pysodium.crypto_pwhash_argon2i_MEMLIMIT_MODERATE
    )

Après quelques recherches, impossible de trouver quelque chose de concluant, j’en déduis donc (étant donné qu’il s’agit d’une libraire C importée à Python) que les \x00 ont mal été gérés par la libraire et la taille du hash en sortie. D’autant plus que la documentation de pysodium est pour ainsi dire inexistante et renvoie vers la documentation de libsodium.

Références

Bitwarden Whitepaper : https://bitwarden.com/help/bitwarden-security-white-paper/

pysodium : https://github.com/stef/pysodium

pycryptodome : https://pycryptodome.readthedocs.io/en/latest/