[Clients security] How the keypair should be generated / Comment le trousseau de clés devrait être généré?

:arrow_down: An English version is available below :arrow_down:

Introduction

J’ai consacré beaucoup de temps ces dernières années à étudier le fonctionnement des autres crypto et à discuter avec des ingénieurs d’autres crypto, et cela m’a fait prendre conscience, entre autres choses, que nos pratiques actuelles de génération des trousseaux de clés sont dangereuses !

Vu la naissance d’une nouvelle génération de clients conséquente à l’arrivée de GVA (Tikka, Gecko, cesiumV2). C’est le meilleur moment pour repartir sur de bonnes bases en matière de génération de trousseau.

Les clients pourront évidemment proposer une authentification en mode «legacy» pour accéder aux portefeuilles existants, mais ce mode legacy ne doit pas être proposé par défaut et l’interface doit clairement indiquer à l’utilisateur que ce mode d’authentification est obsolète et ne doit être utilisé que pour accéder à un portefeuille créer de cette ancienne manière.

Comment générer le trousseau de clés ?

Une phrase de sécurité doit être générée aléatoirement, phrase de sécurité qui ne sera nécessaire à l’utilisateur que lorsqu’il changera d’appareil.

La seed doit être dérivée de cette phrase de sécurité via un protocole commun pour l’interopérabilité.

Je propose le protocole suivant :

Puis le trousseau de clés doit être stocké de manière chiffrée, avec une clé de chiffrement dérivée via scrypt, sinon il est trop facile de brute forcer le chiffrement, il y a d’autres subtilités importantes à respecter pour chiffrer correctement un trousseau, c’est pourquoi je vous recommande d’utiliser le format DEWIF :

https://git.duniter.org/documents/rfcs/blob/dewif/rfc/0013_Duniter_Encrypted_Wallet_Import_Format.md

Implémentation de référence

J’ai réalisé une implémentation de référence en Rust (intégrée dans dup-crypto) :

Un exemple d’utilisation dans la doc : dup_crypto::mnemonic::mnemonic_to_seed - Rust

Bibliothèques pour vous aider

Il existe des bibliothèques de génération de mnemonic dans quasiment tous les langages :

Python : GitHub - trezor/python-mnemonic: 🐍 Mnemonic code for generating deterministic keys, BIP39

Dart: GitHub - yshrsmz/bip39-dart: BIP39 mnemonic code implementation in Dart lang

Javascript : GitHub - bitcoinjs/bip39: JavaScript implementation of Bitcoin BIP39: Mnemonic code for generating deterministic keys


Introduction

I have spent a lot of time in the last few years studying the workings of other crypto’s and talking with engineers from other crypto’s, and this has made me realize’ among other things, that our current keychain generation practices are dangerous!

Given the birth of a new generation of customers resulting from the arrival of GVA (Tikka, Gecko, cesiumV2). This is the best time to get back on a good footing when it comes to keychain generation.

Customers will obviously be able to offer authentication in “legacy” mode to access existing portfolios, but this legacy mode should not be offered by default and the interface should clearly indicate to the user that this authentication mode is obsolete and should only be used to access a portfolio created in this old way.

How to generate the keypair?

A random passphrase must be generated which will only be necessary for the user when changing devices.

The seed must be derived from this mnemonic via a common protocol for interoperability.

I propose the following protocol:

Then the keypair must be stored encrypted, with an encryption key that must also be obtained via scrypt, otherwise it is too easy to brute force the encryption, there are other important subtleties to respect to correctly encrypt a keychain, that’s why I recommend to use the DEWIF format :

https://git.duniter.org/documents/rfcs/blob/dewif/rfc/0013_Duniter_Encrypted_Wallet_Import_Format.md

Reference implementation

I realized a reference implementation in Rust (integrated in dup-crypto) :

An example of usage in the doc: dup_crypto::mnemonic::mnemonic_to_seed - Rust

Libraries to help you

There are mnemonic generation libraries in almost all languages:

Python : GitHub - trezor/python-mnemonic: 🐍 Mnemonic code for generating deterministic keys, BIP39

Dart: GitHub - yshrsmz/bip39-dart: BIP39 mnemonic code implementation in Dart lang

Javascript : GitHub - bitcoinjs/bip39: JavaScript implementation of Bitcoin BIP39: Mnemonic code for generating deterministic keys

4 Likes

Ou qu’il ajoute un nouvel appareil, par exemple son pc ou son mobile ?

Du coup si je comprends, l’idée c’est que tous les clients nouvelle génération stock impérativement les clé au format DEWIF pour une utilisation non éphémère d’un wallet ?

For a member account the password entropy should be at least 10^15, which corresponds to 10 random alphanumeric characters.
For a single wallet account, the minimum entropy must be 10^9, which corresponds to 6 random alphanumeric characters.

Donc ce password sera le seul élément que l’utilisateur aura à se souvenir et remplir pour utiliser son client avec le fichier de trousseau sélectionné ?

On force donc les gens à devoir se servir d’un mot de passe qu’ils n’auront pas choisi ?

Je pensais que seul le la passphrase devait être généré aléatoirement, et que l’utilisateur pourra choisir son code PIN 6 caractère ou 10, contenant minimum 1 lettre minuscule + 1 lettre majuscule ?

1 Like

Oui en effet, changer d’appareil ou ajouter un appareil :slight_smile:

Pas nécessairement. La seule obligation pour l’interopérabilité c’est que tous les clients dérivent la seed de la même manière depuis un mnemonic.
Ainsi il est possible de configurer son wallet sur un autre client en saisissant son mnemonic.

Si en plus, les clients chiffrent le trousseau de clés dans le même format, ça permet d’utiliser un même fichier trousseau sur plusieurs clients, sans avoir à ressaisir son mnemonic, c’est plus confortable pour l’utilisateur, mais ce n’est pas indispensable.

En revanche, si les clients dérives des seed différentes à partir du même mnemonic là c’est bloquant.

Oui, tant qu’il utilise le même appareil. Mais l’utilisateur doit impérativement noter son mnemonic quelque part, seul moyen de récupérer ses sous si le fichier trousseau est détruit pour X raison (panne de l’appareil par exemple).

A l’instar du fichier de révocation, le mnemonic devrait être imprimé et gardé en lieu sûr.
Pour les plus paranos, il reste possible d’apprendre par cœur son mnemonic (c’est fait pour) et de supprimer toute trace papier, faut juste avoir fortement confiance en sa mémoire biologique.

Un compromis est de découper ce mnemonic et plusieurs morceaux et d’en distribuer à des membres de sa famille ou amis de trèèèèèès longue date et de totale confiance. Ça peut même être fait par un logiciel de «découpage de secret» (à utiliser hors ligne).

Pas nécessairement, j’ai marqué je cite :

The password used to encrypt the seed must be sufficiently robust and preferably randomly generated.

C’est une forte recommandation mais pas une obligation. En tous les cas le logiciel client doit s’assurer de la robustesse du mot de passe, donc si ce dernier n’est pas aléatoire ça nécessite de créer des règles du genre «au moins 1 caractère spécial, i majuscule et 1 chiffre et au moins 12 de caractères».

Il faut comprendre qu’un mot de passe créé par un cerveau humain aura toujours moins d’entropie qu’un mot de passe aléatoire de même longueur. Car le cerveau humain est très mauvais pour générer de l’aléatoire, il est trop prédictible.
Pire encore, la plupart des utilisateurs cherchent à se créer le mot de passe le plus simple possible qui respecte les règles dictées par le logiciel, donc un mot de passe très prédictible et facile à bruteforcer.

In finé, perso je trouve plus simple de devoir mémoriser un code pin de 6 caractères que je n’ai pas choisi plutôt que de devoir me casser la tête à créer un mot de passe suffisamment robuste que je n’arriverai par à retenir car trop complexe.

le code pin aléatoire est plus facile à générer et plus facile à retenir. C’est mieux pour l’utilisateur et pour le développeur.

Donc oui, je pense qu’il faut forcer les gens à devoir utiliser un code pin qu’ils n’ont pas choisi, je pense que c’est mieux pour eux et mieux pour la confiance en la monnaie.

Non le code pin de 6 caractères c’est seulement dans le cas où il est généré aléatoirement, Si l’utilisateur le choisi il faut beaucoup plus de caractères et une plage de caractères plus large.

Le plus simple est de générer aléatoirement un code pin à 6 caractères avec un alphabet de 36 caractères (0-9A-Z). Ainsi, pas de problèmes de confusion majuscule/minuscule :slight_smile:

2 Likes

Un exemple pour comprendre :

Supposons que je sois un hacker disposant d’une machine pouvant brute forcer 1000 combinaisons par seconde (sur mon poste de dev je suis à environ 400/s donc 1000/s c’est atteignable sur un pc de gamer haut de gamme récent).

Alors si un tel hacker dérobe un fichier trousseau chiffré par un code pin aléatoire à 6 caractères dans l’alphabet (0-9A-Z), il lui faut 10 jours pour cracker le fichier trousseau. Mais 10 jours avec tous les cœurs à 100% non-stop jour et nuit 24h/24.

C’est un coût suffisamment élevé pour que le hacker ne prenne pas le risque, ça peut endommager son matériel un usage aussi intensif sur une si longue durée, et il est privé de PC pendant 10 jours.

Le hacker ne prendra le risque que si le solde sur le wallet est suffisamment élevé pour que ça en vaille la peine (voila pourquoi il ne faut pas stocker des millions sur un wallet à code pin).

Si en revanche, le code pin à 6 caractères dans le même alphabet a été choisi par l’utilisateur. Le hacker va pouvoir utiliser un dictionnaire des 1 millions de codes PIN les plus fréquemment choisis :

123456
654321
ABCDEF
etc

Si par malheur l’utilisateur a choisi l’un des 1 million de code pin les plus fréquents (ce qui sera le cas pour 80% des utilisateurs), alors il ne faudra au hacker que 1000 secondes pour cracker le trousseau, soit moins de 20 minutes !!!

Voici pourquoi un mot de passe ou code pin choisi par un humain est moins robuste, ce qui implique d’augmenter le nombre de caractères et la plage de l’alphabet pour être aussi robuste qu’une génération aléatoire, mais du coup c’est plus difficile à retenir pour l’utilisateur.

La conclusion contre intuitive est que : c’est plus confortable pour l’utilisateur de devoir retenir un code pin qu’il n’a pas choisi.

D’ailleurs on le fait tous pour notre code de CB et personne ne râle :wink:

3 Likes

Est-ce que ça peut être vulgarisé comme si c’était un gestionnaire de mot de passe intégré ? « L’appli se charge de se souvenir du mot de passe aléatoire que vous avez noté sur un papier à condition que vous reteniez un code PIN. Vous pouvez garder votre compte sur une autre appli ou un autre appareil en copiant le mot de passe généré. »

1 Like

Attention ce n’est pas un mot de passe mais une phrase de sécurité !

1 Like

Tikka vient d’implémenter la création de compte avec cette méthode.

Une phrase de passe est auto-générée avec mnemonic pour python, et une clef publique dérivée respectant les spécifications données ici est également générée.

Elles peuvent être copiées/collées.

Je vais ensuite ajouter le chargement et la sauvegarde en fichier DEWIF dans DuniterPy.

4 Likes

À tu vérifié que tu trouvait le même résultat que l’exemple donné dans la RFC ?

Trouve tu bien la seed indiquée ?

Je viens de rajouter la clé publique correspondante dans l’exemple, trouve tu bier la clé publique 732SSfuwjB7jkt9th1zerGhphs6nknaCBCTozxUcPWPU ?

Disons que vu les écarts qu’on a constatés avec DEWIF je serais agréablement surpris que les spec de Dubp-Mnemonic soit parfaitement correctes du 1er coup, dans tous les cas il faut vérifier :slight_smile:

1 Like

Je viens de faire ce que je comptais faire demain (ah bas les cadences infernales :wink: ) : un test unitaire.

Effectivement, j’ai le bon salt, mais pas la bonne seed, j’utilise ce code :

self.password = mnemonic
self.salt = hashlib.sha256(f"dubp{self.password}".encode("utf-8")).hexdigest().upper()
self.seed = hashlib.scrypt(
    password=self.password.encode("utf-8"),
    salt=self.salt.encode("utf-8"),
    n=scrypt_params.N,  # 4096
    r=scrypt_params.r,  # 16
    p=scrypt_params.p,  # 1
    #dklen=scrypt_params.seed_length,  # 32
    dklen=libnacl.crypto_sign_SEEDBYTES,
)

A noter qu’il manque un paramètre dans les specs, la seedlength.
Mais que je mette l’une ou l’autre des valeurs qu’on voit ici, dans dklen, la seed ne change pas.

Avec le test suivant (tout le monde peut jouer) :

mnemonic = "tongue cute mail fossil great frozen same social weasel impact brush kind"

keypair = DUBPKeyPair(mnemonic)

assert keypair.password == mnemonic
assert keypair.salt == "13EB03436DD9374B554648237AEF473117FCB3D985FBC78B3C397BD3EAD9CFE6"
assert base64.b64encode(keypair.seed).decode("utf-8") == "qGdvpbP9lJe7ZG4ZUSyu33KFeAEs/KkshAp9gEI4ReY="
assert keypair.pubkey == "732SSfuwjB7jkt9th1zerGhphs6nknaCBCTozxUcPWPU"
1 Like

Ce n’est pas parce que je te demande quelque chose que tu es obligé de le faire de suite, ça pouvait attendre demain :slight_smile:

Normal tu ne respectes pas les spec. Il est indiqué :

Hash SHA256 of the string “dubp” + mnemonic phrase.

Il n’est pas indiqué :

UTF-8 encoding of the base 16 representation of the hash SHA256 of the string “dubp” + mnemonic phrase.

Donc pourquoi donne tu a scrypt l’encodage utf-8 de la représentation en base 16 du hash SHA256 ?
Il faut donner le hash SHA256 directement (sous-entendu sa valeur binaire).

Bon, il faut que je fasse un petit cours de Python. :wink:

encode(charset) est une méthode de la classe str, qui permet de convertir la chaîne selon son encodage charset vers une représentation en type bytes.

Comme la méthode sha256(bytes) demande des bytes en argument, j’encode ma chaîne en bytes.
ici, je convertis une string en bytes avec :

f"dubp{self.password}".encode("utf-8")

J’ajoute les types pour que ce soit plus clair:

self.password: str = mnemonic
self.salt: str = hashlib.sha256(f"dubp{self.password}".encode("utf-8")).hexdigest().upper()

self.seed: bytes = hashlib.scrypt(
    password: bytes = self.password.encode("utf-8"),
    salt: bytes = self.salt.encode("utf-8"),
    n: int =scrypt_params.N,  # 4096
    r: int =scrypt_params.r,  # 16
    p: int=scrypt_params.p,  # 1
    #dklen: int = scrypt_params.seed_length,  # 32
    dklen: int = libnacl.crypto_sign_SEEDBYTES,
)

Pour être sûr que la fonction script donne le même résultat que toi,
peux-tu me donner ta seed en base64 pour password = salt = "1" ?

assert keypair.password == "1"
assert keypair.salt == "1"
assert base64.b64encode(keypair.seed).decode("utf-8") == "YVW2djzsh5WhPPdNhkj/dlc2bvGveeoSfVnoflXL0zE="

Dsl, mais je pense que tu inverses les choses. J’avais déjà parfaitement compris ce que tu expliques :confused:

Je l’avais bien compris comme ça.

Oui et ça c’est juste, mais ce n’est pas de ça dont je parle puisqu’on a le même hash…

En fait tu n’as pas compris d’où vient l’écart : ça vient du salt que tu donnes à la fonction scrypt.

Le salt attendu est 0x13EB03436DD9374B554648237AEF473117FCB3D985FBC78B3C397BD3EAD9CFE6 soit les bytes suivant :

[19, 235, 3, 67, 109, 217, 55, 75, 85, 70, 72, 35, 122, 239, 71, 49, 23, 252, 179, 217, 133, 251, 199, 139, 60, 57, 123, 211, 234, 217, 207, 230]

Or toi tu donnes à scrypt le salt [49, 51, 69, 66, 48, 51, 52, 51, 54, 68, 68, 57, 51, 55, 52, 66, 53, 53, 52, 54, 52, 56, 50, 51, 55, 65, 69, 70, 52, 55, 51, 49, 49, 55, 70, 67, 66, 51, 68, 57, 56, 53, 70, 66, 67, 55, 56, 66, 51, 67, 51, 57, 55, 66, 68, 51, 69, 65, 68, 57, 67, 70, 69, 54].

Du coup tu trouves la seed FnUWPEVatIoBFKA4Yn9K938M+u4WWHqVvz7mXJBFCeg= au lieu de qGdvpbP9lJe7ZG4ZUSyu33KFeAEs/KkshAp9gEI4ReY=

Le salt donné à scrypt doit être :

salt: bytes = hashlib.sha256(f"dubp{self.password}".encode("utf-8")).digest()

Et non :

salt: bytes = hashlib.sha256(f"dubp{self.password}".encode("utf-8")).hexdigest().upper().encode("utf-8")

Et ce que tu comprends la différence ?

Voici un code qui devrait être juste :

self.password: str = mnemonic
self.salt: bytes = hashlib.sha256(f"dubp{self.password}".encode("utf-8")).digest()

self.seed: bytes = hashlib.scrypt(
    password: bytes = self.password.encode("utf-8"),
    salt: bytes = salt,
    n: int =scrypt_params.N,  # 4096
    r: int =scrypt_params.r,  # 16
    p: int=scrypt_params.p,  # 1
    dklen: int = 32,
)
1 Like

@vit c’est probablement mon exemple sur la RFC qui t’a induit en erreur, je viens d’y ajouter une précision :

WARNING: The salt must be the binary value of hash sha256. It must not be encoded in base16, we give here its representation in base16 only for practical readability reasons. 

Et j’ai aussi précisé la longueur de la seed :wink:

1 Like

Merci de prendre le temps d’essayer de reproduire Dubp-Mnemonic, si tu arrives à reproduire l’exemple dit le ici, qu’on puisse s’assurer qu’il ne reste pas d’autre zone d’ombre :slight_smile:

Avec ce code, le test passe ! J’ai les mêmes données que la RFC ! Hourra !

    self._password = mnemonic.encode("utf-8")  # type: bytes
    self._salt = hashlib.sha256(b"dubp" + self._password).digest()  # type: bytes
    self._seed = hashlib.scrypt(
        password=self._password,
        salt=self._salt,
        n=scrypt_params.N,  # 4096
        r=scrypt_params.r,  # 16
        p=scrypt_params.p,  # 1
        dklen=scrypt_params.seed_length,  # 32
    )  # type: bytes
    self._signing_key = SigningKey(self._seed)

mnemonic = (
    "tongue cute mail fossil great frozen same social weasel impact brush kind"
)

keypair = DUBPKeyPair(mnemonic)

assert keypair.password == mnemonic
assert keypair.salt == "13EB03436DD9374B554648237AEF473117FCB3D985FBC78B3C397BD3EAD9CFE6"
assert keypair.seed == "qGdvpbP9lJe7ZG4ZUSyu33KFeAEs/KkshAp9gEI4ReY="
assert keypair.pubkey == "732SSfuwjB7jkt9th1zerGhphs6nknaCBCTozxUcPWPU"
5 Likes

Félicitations @vit, on a donc reproduit Dupb-Mnemonic !

Avec en plus @Spencer qui a reproduit DEWIF, je pense qu’on a enfin des bases fiables pour une bonne sécurité des clients :smiley:

4 Likes

Suite à cette discussion, et en progressant sur les questions de sécurité, j’ai décidé de me créer des nouveaux trousseaux de clé ssh avec ed25519. J’utilisais jusqu’à maintenant la même clé protégée par passphrase sur plusieurs machines de niveau de sécurité différents. J’ai maintenant une clé par machine. Les clés sont stockées dans deux fichiers différents : un pour la clé privée au format openssh et un pour la clé publique. Quels sont les problèmes de cette façon de stocker les clés dans notre cas ?

1 Like

openssl ?

Non, en fait, juste au format texte avec un en-tête -----BEGIN OPENSSH PRIVATE KEY-----

Je vois 5 arguments, par ordre d’importance :

  1. On ne stocke pas que les clés. On stocke des données pratiques en plus pour l’utilisateur, comme la monnaie associée. Ça permet de ne pas se mélanger les pinceaux si on utilise plusieurs crypto monnaies libres différentes.

  2. Stocker le trousseau de la même manière que SSH peut inciter des utilisateurs à utiliser un même trousseau pour ssh et pour leur compte d’une crypto monnaie libre (et on a déjà quelqu’un que a demandé cela d’ailleurs). Or, c’est une mauvaise pratique, je ne souhaite pas l’incitée.

  3. Ce n’est pas pratique de s’éparpiller dans plusieurs fichiers, restons KISS, un seul fichier ça suffit.

  4. Le format de chiffrement des clés ssh est complexe, restont KISS.

  5. La clé publique ne doit pas être visible, pour donner le moins d’informations possible à un hacker.

1 Like