Cloud sécurisé
Application CLI en Python implémentant un mini-cloud chiffré
- tags
- #Cryptography #Python #Pysodium #Pycryptodome
- catégories
- School Project
- publié
- temps de lecture
- 13 minutes
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
etPBKDF2
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 :
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 avecargon2id
- 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 :
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 :
- L’utilisateur se connecte
- Récupère la clé symétrique en clair
- Récupère la clé privée en clair
- L’utilisateur tape un nouveau mot de passe
- Chiffre la clé symétrique
- 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 :
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 :
- 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
.
- 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 :
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 :
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/