- Il s’agit d’une expérience consistant à générer directement en Rust les deux nombres premiers d’environ 1024 bits nécessaires à une clé RSA de 2048 bits, en implémentant tout soi-même, de la génération aléatoire aux opérations sur grands entiers, sans dépendance externe
- Une simple trial division suffisait en 16 bits avec environ 40 ms, mais même en 64 bits elle demandait encore 6,4 secondes après optimisation, ce qui la rendait difficilement extensible à 1024 bits
- Le test de Fermat est rapide, mais il peut laisser passer des pseudoprimes ; le jugement final repose donc sur un test de Miller-Rabin avec k=10
- Pour dépasser les limites des types entiers de base, un BigInt a été créé à la main, puis son stockage est passé d’un tableau de booléens à un tableau d’octets, puis à une structure en chunks
u64, ce qui a fait passer le temps de génération d’un premier de 1024 bits d’environ 32 minutes à 60–90 secondes - L’implémentation finale combine un BigInt en chunks
u64, une division rapide, une trial division sur de petits nombres premiers, l’incrémentation des candidats par+2et une exécution parallèle sur 16 threads, pour trouver en moyenne un nombre premier de 1024 bits en environ 40 ms, sans pour autant constituer une bibliothèque cryptographique validée
Générer soi-même un nombre premier de 1024 bits pour RSA
- L’objectif était de générer soi-même un nombre premier utilisable pour la génération de clés RSA
- Une clé RSA de 2048 bits étant produite à partir du produit de deux nombres premiers, il faut donc deux premiers d’environ 1024 bits chacun
- Le défi s’est donc naturellement resserré sur la génération de nombres premiers de 1024 bits
- L’expérience imposait trois contraintes
- écrire le code depuis zéro, sans dépendances externes
- utiliser un ordinateur portable doté d’un CPU AMD Ryzen 7 et de 16 Go de RAM, sans matériel externe ni cloud
- générer un nombre premier dans un “temps raisonnable”
- Le langage choisi a été Rust, que l’auteur apprenait récemment
- il est jugé assez proche du bas niveau pour manipuler ces concepts, tout en restant assez haut niveau pour rendre les fragments de code compréhensibles
Les limites de la trial division révélées en 16 bits puis en 64 bits
- Le flux de base consiste à générer en boucle un entier aléatoire de N bits, puis à s’arrêter dès qu’il passe le test de primalité
- Les nombres aléatoires sont produits en lisant directement
/dev/urandomsous Linux, plutôt que d’utiliser la craterandde Rust/dev/urandomest un pseudo-fichier donnant accès au CSPRNG du noyau Linux- le noyau collecte de l’entropie depuis l’environnement utilisateur et réensemence périodiquement un chiffrement de flot déterministe basé sur ChaCha20
- Pour les entiers aléatoires de 16 bits, le premier bit et le dernier bit sont forcés à
1- le dernier bit à
1garantit que le nombre est impair - le premier bit à
1garantit l’utilisation de toute la plage de bits visée
- le dernier bit à
- En 16 bits, une simple trial division de
3àsqrt(num)suffisait pour trouver un nombre premier en environ 40 ms- un exemple d’exécution affichait
Prime found: 44809, avec un temps total d’environ 0,038 seconde
- un exemple d’exécution affichait
- En passant à 64 bits, la trial division naïve montait à environ 30 secondes
- l’approche a ensuite été améliorée en ne testant que les candidats de la forme
6k±1, puis en divisant d’abord par une liste de petits nombres premiers - après optimisation, le temps de génération d’un premier de 64 bits est descendu à environ 6,414 secondes
- l’approche a ensuite été améliorée en ne testant que les candidats de la forme
- Le fait d’être encore à 6 secondes en 64 bits a clairement montré que cette méthode ne permettrait pas d’atteindre la génération de nombres premiers de 1024 bits
Passage à un test probabiliste de primalité
- Parmi les algorithmes déterministes, APR-CL et ECPP ont été examinés, mais ils se sont révélés trop complexes mathématiquement et trop peu documentés de façon accessible pour être raisonnablement implémentés ici
- Après consultation du code source d’OpenSSL et des recommandations du NIST, il est apparu que les tests probabilistes de primalité sont largement utilisés en pratique, y compris pour RSA
- L’algorithme a donc évolué : au lieu de “prouver” qu’un nombre est premier, il s’agit de le classer comme probable prime avec un certain niveau de précision
-
Test de Fermat
- Le petit théorème de Fermat exploite la relation suivante : si
pest premier et quean’est pas divisible parp, alorsa^(p-1) = 1 mod p - Une exponentiation directe provoquant des dépassements de capacité en
u128, une exponentiation modulaire a été implémentée pow()prend un exposant de typeu32, et élever unu128à un exposant plus grand peut provoquer un overflow- la multiplication elle-même peut dépasser la capacité de
u128, si bien qu’une solution temporaire a consisté à stocker des entiers 64 bits dans unu128 - le test de Fermat est rapide, mais les Fermat pseudoprimes peuvent faire classer à tort des composés comme premiers
- même si ces composés sont rares, ils le sont suffisamment peu pour que le test de Fermat seul soit jugé insuffisamment fiable
- Le petit théorème de Fermat exploite la relation suivante : si
-
Test de Miller-Rabin
- Miller-Rabin repose sur le même principe que Fermat, mais constitue un algorithme probabiliste plus robuste pour tester la primalité
- l’implémentation commence par décomposer
n-1 = 2^s × d, puis vérifie plusieurs conditions a^d = 1 mod n- ou bien, pour un certain
0 <= r < s,a^(2^r × d) = n - 1 mod n - dans les essais en 128 bits, un nombre premier était trouvé en environ 0,042 seconde, soit à peu près comme avec Fermat
- la borne d’erreur au pire de Miller-Rabin est de
4^-k, et en moyenne plutôt de l’ordre de8^-kpour les grandsn - pour
k=10, la probabilité d’erreur moyenne calculée était de0.000000000931323% - cela est comparé à la probabilité d’obtenir 30 fois de suite pile en lançant une pièce, soit
2^-30 - pour un usage réellement cryptographique, il faut toutefois être plus prudent dans le choix aléatoire des bases et face à des conditions adversariales
Construire son propre BigInt
- Les types entiers natifs de Rust ne permettant pas de manipuler confortablement des nombres bien au-delà de 64 bits, il a fallu implémenter un entier à précision arbitraire (BigInt)
- La contrainte interdisant l’usage d’une crate bigint externe, ce BigInt a lui aussi été écrit à la main
-
Tentative 1 : tableau de chiffres
- au départ, une représentation sous forme de tableau de chiffres décimaux a été essayée
- l’addition et la multiplication pouvaient être codées comme un calcul à la main, mais l’implémentation de la division s’est révélée bloquante
-
Tentative 2 : tableau binaire basé sur des booléens
- la deuxième approche stockait le nombre comme un tableau de 0 et de 1
BigIntutilisait un tableau[bool; 2048]- comme le produit de deux entiers de 1024 bits peut nécessiter jusqu’à 2048 bits, un espace de 2048 bits était réservé
- l’addition et la soustraction étaient implémentées comme un full adder
- la multiplication utilisait une approche shift-and-add exploitant la nature binaire
- la division était implémentée via une division longue binaire
- cette version a permis de trouver un premier de 1024 bits pour la première fois, mais le temps d’exécution était d’environ 32 minutes et 44,90 secondes
- l’objectif était donc atteint sur le plan technique, mais pas au regard de la contrainte de “temps raisonnable”
-
Tentative 3 : chunks d’octets
- il est apparu que chaque
booldu tableau de booléens occupait 1 octet, et non 1 bit [bool; 2048]utilisait donc 2048 octets, et non 2048 bits- la représentation a ensuite été remplacée par un tableau de 256 octets pour stocker 2048 bits
- l’addition, la soustraction et la multiplication continuaient de fonctionner sans grand changement, et la division a été adaptée pour traiter les chunks d’octets comme une liste de bits
- avec cette approche, le temps de génération d’un premier de 1024 bits est descendu à 4 minutes 43 secondes
- il est apparu que chaque
-
Tentative 4 : chunks
u64- l’approche en octets revenait en pratique à un BigInt basé sur des chiffres de grande base
- l’étape suivante a consisté à stocker 2048 bits dans 32 chunks
u64 - chaque chunk se comporte comme un “chiffre”
u128est utilisé pour contenir le résultat du produit de deux chunksu64- avec cette structure, un entier de 1024 bits peut être représenté par 16 chunks
u64au lieu de 309 chiffres décimaux - le temps de génération d’un premier de 1024 bits a été ramené à 60 à 90 secondes
Optimisation des goulets d’étranglement
- De simples benchmarks ont mis en évidence un écart net entre l’implémentation binaire et celle en chunks
u64a + beta - b: 5537.35ns → 123.57nsa * b: 1292283.14ns → 842.32nsa / beta % b: 733446.76ns → 44440.12nsa < beta > b: 2506.02ns → 58.91ns
- Les optimisations se sont ensuite concentrées surtout sur la division, la multiplication, les opérations internes à Miller-Rabin et la logique de génération des candidats
-
Division
- le principal goulet d’étranglement était la division
- même avec la structure en chunks
u64, l’ancienne division effectuait toujours une division longue bit par bit - en s’appuyant sur l’algorithme décrit à la page 598 du Handbook of Applied Cryptography, une division longue fondée sur la base de représentation a été implémentée
- l’idée consiste à estimer le “chiffre” courant du quotient à partir des 3 premiers “chiffres” du dividende et des 2 premiers “chiffres” du diviseur
- cette implémentation a permis d’économiser environ 40 000 ns par division
- lorsqu’un diviseur tient dans un seul chunk
u64, un traitement spécial utiliseu128pour effectuer une division longue plus directe - ce cas revient souvent dans Miller-Rabin
-
Multiplication
- la multiplication a été rendue environ deux fois plus rapide en réorganisant les boucles pour supprimer un BigInt temporaire destiné au stockage des résultats intermédiaires
- le nombre de chunks effectivement occupés est calculé afin de ne boucler que sur les chunks non nuls
- comme BigInt stocke la plupart du temps des nombres d’au plus 1024 bits, la moitié des 2048 bits réservés est souvent vide
- des méthodes comme Karatsuba ou la multiplication basée sur FFT ont aussi été envisagées, mais elles ont été jugées trop complexes à implémenter manuellement, et la multiplication actuelle a été jugée suffisamment rapide
-
Optimisations internes à Miller-Rabin
- dans Miller-Rabin, l’accent a été mis sur la réduction des opérations coûteuses
x = mod_exp(x, 2, n)a été remplacé parx = (x * x) % n- le premier
mod_exp()a été remplacé par une version inline simplifiée pour réduire l’overhead des appels de fonction num.is_even()a été ajouté pour éviter le calcul% 2lors du test de paritéd / 2a été remplacé pard >>= 1+= 1et-= 1sont gérés spécialement viaincrease()etdecrease()- en particulier,
is_even()etd >>= 1ont chacun apporté un gain d’environ 70 000 ns - dans le benchmark final, la version optimisée en chunks
u64a nettement accéléré a * b: 842.32ns → 295.04nsa / beta % b: 44440.12ns → 831.77nsa / 2: 75121.58ns → 60.89nsa % 2 == 0: 78400.87ns → 21.65nsa - 1: 103.15ns → 67.54ns
Générateur final de nombres premiers de 1024 bits
- La fonction finale commence par lire un entier aléatoire de 1024 bits depuis
/dev/urandom- le bit de poids fort est activé pour garantir une taille de 1024 bits
- le bit de poids faible est activé pour garantir un nombre impair
- Ensuite, au lieu de relire un nouvel aléa à chaque essai, elle ajoute
2au candidat pour passer au nombre impair suivantincrease_by_2()n’effectue généralement qu’une addition sur un seul chunku64
- Avant Miller-Rabin, une trial division est effectuée à l’aide d’une liste de petits nombres premiers
- dans le code final, les 1000 premiers petits nombres premiers sont utilisés
- comme ces petits nombres premiers tiennent dans un seul chunk
u64, le traitement spécial rapide de division sur un seul chunk peut être exploité
- Le problème se prête à une forme embarrassingly parallel, sans mémoire partagée ni synchronisation entre threads
- 16 threads CPU cherchent chacun un nombre premier, et la valeur renvoyée par le thread qui termine en premier est retenue
- Un exemple d’exécution final a enregistré un temps écoulé d’environ 0,086 seconde
- l’utilisation CPU affichée était de 690 %
- La moyenne sur 100 exécutions était de
0.04109 ± 0.00307seconde- en moyenne, un nombre premier de 1024 bits est trouvé en environ 40 ms
- un appel individuel à
prime_1024bit()peut varier, en raison de l’aléa, d’environ 8 ms à environ 800 ms - l’exécution parallèle atténue cette variabilité en retenant le résultat le plus rapide
Code et limites
- L’ensemble du code et le dépôt sont disponibles sur github
- Des liens de discussion sont disponibles sur hackernews et reddit
- Cette implémentation ne doit pas être considérée comme réellement sûre du point de vue cryptographique ; son objectif relève davantage de l’apprentissage et de l’expérimentation d’implémentation que de la création d’une bibliothèque de chiffrement
1 commentaires
Avis de Hacker News
Quelques cryptomonnaies ont utilisé la recherche de grands nombres premiers comme partie de leur fonction de preuve de travail, et il y a environ 8 ans, on pouvait gagner pas mal d’argent simplement avec une implémentation très rapide de test de primalité
J’ai été pendant un temps l’auteur et mainteneur du logiciel de minage de riecoin ; je ne sais pas trop pourquoi, sans doute simplement parce que j’aimais les nombres premiers
Cet article omet l’optimisation numéro un pour les tests de primalité rapides : la multiplication de Montgomery : https://en.m.wikipedia.org/wiki/Montgomery_modular_multiplic...
C’est la base des implémentations pratiques et rapides de l’exponentiation modulaire
Niall Emmart, qui était à l’époque dans le milieu académique et qui, à ma connaissance, est maintenant chez Nvidia, a publié CGBN, une bibliothèque de grands entiers sur GPU vraiment incroyablement rapide : https://github.com/NVlabs/CGBN
C’est encore l’implémentation d’exponentiation modulaire par lots la plus rapide que je connaisse, et, pour me permettre un instant d’enthousiasme de geek, elle est à couper le souffle
Un jour, il faudra que j’écrive comment cela nous a permis de dominer pendant environ 5 ans la production d’une petite cryptomonnaie. Et Python intègre une exponentiation modulaire assez correcte avec la forme à trois arguments de
pow(x, y, m), qui calculex^y % mAvec ça, si vous voulez l’implémenter vous-même, il est très facile de faire un test de primalité de Fermat ou de Miller-Rabin, et c’est assez amusant. Si vous ne voulez pas l’écrire vous-même,
mpz_probab_prime()de la bibliothèque gmp est très bien aussi. gmp est évidemment plus rapide, mais quand on joue avec de grands nombres premiers, il est difficile de battre le plaisir d’un test de Fermat en deux lignesC’est étroitement lié à l’exponentiation modulaire par lots, mais avec la différence que cela fonctionne sur des courbes elliptiques plutôt que modulo des nombres premiers. J’y vois une continuation du travail sur CGBN
Il a fait une bonne présentation l’an dernier au séminaire déjeuner de cryptographie de Stanford, et les diapositives ainsi que l’enregistrement sont en ligne
https://cbr.stanford.edu/seminarTalks/slides_20230526_niall_...
https://www.youtube.com/watch?v=KAWlySN7Hm8
J’aimerais savoir si c’était simplement l’idée vague que la crypto utilise bien des nombres premiers d’une façon ou d’une autre, sans savoir quand ni pourquoi, ou s’il y avait une raison plus profonde
pow(x,e,mod)que je suis passé de Perl à PythonAvec une borne maximale donnée pour les nombres, il est simple de rendre Miller-Rabin pratiquement déterministe
Il suffit de choisir des bases dont il est prouvé qu’elles éliminent tous les pseudopremiers dans cette plage
La liste ne devient pas très longue non plus. Miller-Rabin est vraiment puissant
Je n’ai pas trouvé la réponse en ligne
Une seule ligne d’assembleur inline simplifie la multiplication de grands entiers « à l’école primaire » : https://github.com/jcalvinowens/toy-rsa/blob/master/bfi.c#L4...
Si je pouvais revenir en arrière et ne changer qu’une chose dans le langage C, j’y ajouterais la notion de multiplication étendue. C’est dommage que Rust ne l’ait pas non plus. Le support matériel existe partout. Le Cortex M0 ne fait même pas la division, mais il a la multiplication étendue
Ce code vient d’une très vieille implémentation jouet de RSA, très laide, que j’avais écrite : https://github.com/jcalvinowens/toy-rsa
La raison pour laquelle je pouvais m’en tirer avec seulement le test de Fermat, c’est que l’algorithme ne fonctionne pas si les nombres premiers ne sont pas réellement premiers. Le test de Fermat est rapide, et un seul chiffrement/déchiffrement élimine la probabilité extrêmement faible que l’un des deux soit un menteur de Fermat
Cela dit, je ne sais pas si l’on peut prouver qu’il n’existe pas de paire de clés RSA capable de chiffrer/déchiffrer correctement un message avec des valeurs P/Q non premières. Dans une vraie implémentation, ce n’est évidemment pas la méthode canonique, mais je n’ai jamais trouvé la réponse
C23 a ajouté le type
_BitInt(N); par exemple, on peut utiliser_BitInt(1024)comme type de 128 octetsLe support des compilateurs reste toutefois limité. Avec Clang, pour autoriser un N supérieur à 128, on peut passer le drapeau
-fexperimental-max-bitint-width=N. Si N est supérieur à 128 et que l’on divise un_BitInt(N), le compilateur plante purement et simplement, mais+,-et*fonctionnent comme attenduIl existe la primitive
@mulWithOverflow, qui renvoie le résultat avec le bit de dépassement, et les entiers vont jusqu’à(u|i)65535Selon ce que l’on fait, on peut détecter le dépassement puis promouvoir vers un type plus grand, ou bien promouvoir d’abord puis tronquer éventuellement
Il prend aussi en charge la multiplication saturante via l’opérateur distinct
*|, et la multiplication avec wraparound via*%. On peut les utiliser quand on a besoin de ces sémantiques. Les autres dépassements relèvent d’un comportement indéfini soumis aux vérifications de sûreté, ce qui provoque un panic dans les modes de compilation Debug et ReleaseSafepetqsont des nombres de Carmichael premiers entre eux, RSA peut toujours chiffrer et déchiffrer correctement les messagesEn revanche,
p*qaura des facteurs premiers plus petits, ce qui facilite la factorisation et réduit donc la sécuritéJe me demande combien de temps cela a pris
Pendant un projet de recherche en licence, j’ai travaillé sur la multiplication de grands entiers, et ça a pris presque deux semestres. J’ai implémenté Karatsuba, Toom-Cook, une FFT complexe, quelques NTT et Schonhage-Strassen
Les nombres premiers, c’est presque de la magie mathématique. Pour les personnes intéressées, A Friendly Introduction to Number Theory de Silverman est un excellent livre de maths
Au passage, le lien sur la page est
4025051au lieu de40250519Bon article. Moi aussi, j’ai récemment écrit un peu de code de grands entiers pour une première version de [0], et je me souviens à quel point il est frustrant de transformer les explications de haut niveau d’articles de maths en opérations concrètes
J’ai toutefois une petite objection
Si l’on utilise toute la plage de
u64, les nombres sont en base 2^64, pas en base2^64-1. Chaque mot a une valeur de 0 à2^64-1, comme chaque chiffre décimal va de 0 à 9[0] https://github.com/LegionMammal978/bigfoot-sim
Comme dans la dernière optimisation, si l’on incrémente le nombre de 2 en cas d’échec au lieu de générer un nouveau nombre aléatoire, cela affaiblit légèrement la sécurité
Comme les nombres premiers ne sont pas répartis uniformément, cela introduit un biais vers les nombres premiers situés juste après de grands écarts entre nombres premiers
C’est un compromis entre vitesse d’exécution et caractère aléatoire des nombres premiers ; j’ai privilégié la vitesse en considérant que 16 threads partant chacun d’un nombre aléatoire et faisant la course pour trouver un nombre premier ajoutaient assez d’aléa
Si l’on veut plus d’aléa que de vitesse, remplacer
+=2par un appel àrng()est une modification facileBon article, bien écrit
L’auteur voulait sans doute dire base 256, et non
base-255Quelques nombres de 1 à 2 Ko tiennent largement dans le cache L1, et même si ce n’était pas le cas, il y a plus d’un mégaoctet de cache L2 avec un temps d’accès d’environ 3 ns
L’article disait qu’il avait probablement attendu des lectures/écritures en RAM à cause de défauts de cache L1, mais il ne revient pas ensuite sur ce point
Par ailleurs, comme il ne s’agit ici que de génération de nombres premiers, on évite la plupart des pièges de RSA, et
urandomdevrait être sûr. Si le code fonctionne correctement, il n’y a pas tant de choses qui peuvent très mal tournerRSA comporte quelques problèmes liés aux nombres premiers faibles qu’il faut éviter, mais je ne sais pas s’ils sont assez fréquents pour poser réellement problème ici
Ça me rappelle un projet de première année d’université, il y a quelques dizaines d’années
Mon binôme et ami, qui est plus tard devenu major de promotion, avait eu l’idée et implémenté les maths essentielles : créer un chiffrement RSA 4096 bits
Je me souviens à quel point la génération des nombres premiers était lente dans l’implémentation finale. Sur une station de travail PA-RISC, la génération prenait environ 20 minutes
Mon ami, passionné de maths, a continué à optimiser le code après la fin du projet, et je me souviens l’avoir vu lire des articles sur les tests de primalité et l’implémentation de l’arithmétique des grands entiers
Par exemple, dans la multiplication composée, ignorer la multiplication et renvoyer 0 quand l’un des nombres valait 0 a apporté une énorme amélioration
Sinon, il faut attendre longtemps, ou sacrifier une sécurité capable de tenir dans le temps
Je comprends pourquoi on force le bit de poids faible à 1. Les nombres pairs ne sont jamais premiers, bien sûr, sauf 2
Mais je ne comprends pas pourquoi on force aussi le bit de poids fort à 1. Je ne m’y connais pas spécialement en nombres premiers ni en crypto, mais ça ressemble à un abandon inutile d’1 bit d’entropie. Qu’est-ce qui m’échappe ?
Les encodages d’octets à longueur variable peuvent poser des problèmes dans les échanges de données entre logiciels différents si la spécification n’est pas très claire et bien testée
Il suffit de regarder les problèmes que provoquent les zéros de tête dans les clés publiques de serveur avec le DHE basé sur RSA
Si le premier chiffre est 0, ce n’est pas un nombre à deux chiffres
J’ajouterais que, dans RSA, on multiplie deux nombres premiers. Si l’un fait 1024 bits, l’autre peut faire environ 200 bits, si je me souviens bien, tout en atteignant le nombre de bits d’entropie requis pour la clé
Donc mettre les deux nombres premiers à 1024 bits donne aussi un peu de marge
C’est probablement plus sûr que de se demander si un nombre premier de 1020 bits suffit alors que quelqu’un a demandé un nombre premier de 1024 bits. C’est comme le fait qu’on ne considère généralement pas
00042comme un nombre à 5 chiffresTechniquement, le choix optimal peut varier selon l’usage exact, mais la méthode décrite dans l’article semble être un choix par défaut plus sûr