pow-equix-wasm 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,10 +6,12 @@
6
6
  [![npm](https://img.shields.io/npm/v/pow-equix-wasm?label=npm)](https://www.npmjs.com/package/pow-equix-wasm)
7
7
 
8
8
  Preuve de travail **Equi-X**, celle que Tor utilise contre les dénis de service :
9
- un seul module de 45 Kio résout la preuve dans le navigateur, sur plusieurs
10
- cœurs, et la vérifie côté serveur en quelques centaines de microsecondes. Là où
11
- WebAssembly est désactivé, le même module traduit en JavaScript pur prend le
12
- relais, et le mode dégradé est signalé.
9
+ un seul module de 60 Kio résout la preuve dans le navigateur, sur plusieurs
10
+ cœurs, et la vérifie côté serveur en quelques centaines de microsecondes. Chaque
11
+ programme HashX est compilé à la volée en WebAssembly, ce qui ramène l’écart
12
+ avec du code natif d’environ × 20 à environ × 1,6. La mémoire se règle, de
13
+ 1,8 Mio (Equi-X) à 63 Mio par fil. Là où WebAssembly est désactivé, le même
14
+ module traduit en JavaScript pur prend le relais, et le mode dégradé est signalé.
13
15
 
14
16
  - **Démo en ligne et calibrage :** <https://contribulibre.github.io/pow-equix-wasm/>
15
17
  - **Code source :** <https://github.com/ContribuLibre/pow-equix-wasm>
@@ -17,38 +19,116 @@ relais, et le mode dégradé est signalé.
17
19
 
18
20
  > *English summary: Equi-X proof of work (from Tor's Arti project) as a single
19
21
  > WebAssembly module, with a pure-JavaScript fallback (wasm2js) where
20
- > WebAssembly is disabled. It solves in the browser across Web Workers
21
- > (cancellable, with progress and remaining-time estimate) and verifies
22
- > server-side in ~0.25 ms per part. Reproducible build. LGPL-3.0.*
22
+ > WebAssembly is disabled. Each HashX program is compiled to a tiny WebAssembly
23
+ > module on the fly ( 35 ms per attempt in Chromium vs ≈ 21 ms for native
24
+ > compiled code). Memory is tunable: Equihash(n, 3) over HashX, n = 60 (exactly
25
+ > Equi-X, the default) to n = 80 (63 MiB per thread). It solves in the browser
26
+ > across Web Workers (cancellable, with progress and remaining-time estimate)
27
+ > and verifies server-side in ~0.2 ms per part whatever n. Reproducible build.
28
+ > LGPL-3.0.*
23
29
 
24
30
  ## Pourquoi Equi-X
25
31
 
26
32
  Une preuve de travail n’a de sens que si l’attaquant ne peut pas la calculer
27
33
  beaucoup plus vite que la personne honnête, et si le serveur la vérifie pour
28
- presque rien. Ordres de grandeur pour une preuve réglée sur **une minute dans un
29
- navigateur, sur un cœur** :
34
+ presque rien.
30
35
 
31
- | | SHA-256 (hashcash) | Argon2id (64 Mio par essai) | **Equi-X** |
36
+ Le coût d’**un essai** est propre à chaque algorithme ; la difficulté règle
37
+ ensuite le **nombre d’essais** à trouver. Ordres de grandeur pour une preuve
38
+ réglée sur **une minute dans un navigateur, sur un cœur** :
39
+
40
+ | | SHA-256 (hashcash) | Argon2id (64 Mio par essai) | **Equi-X** (n = 60) |
32
41
  |---|---|---|---|
33
- | Un essai, navigateur | ≈ 0,1 à 10 µs | ≈ 0,3 à 1 s | **≈ 0,4 s** (mesuré) |
34
- | Mémoire pour résoudre | < 1 Kio | 64 Mio par essai en cours | **≈ 2 Mio** par essai en cours (mesuré) |
35
- | La même preuve sur matériel optimisé | carte graphique : ≈ 10 ms ; puce de minage : ≈ 1 µs | 10 à 30 s : la mémoire freine cartes graphiques et puces | **≈ 3 s** en code natif compilé (mesuré) ; programmes aléatoires conçus pour ne pas avantager les cartes graphiques |
36
- | Avantage du matériel optimisé | × 10³ à × 10 | × 2 à × 5 | **≈ × 20** |
37
- | Vérifier une preuve | 1 empreinte : 1 µs, négligeable | 1 essai complet : ≈ 0,1 à 1 s **et** 64 Mio par vérification | **≈ 0,25 ms par part** en WebAssembly, 0,2 ms en natif (mesuré) ; pas de mémoire à réserver |
38
-
39
- - **SHA-256** : les cartes graphiques et, pire, les puces de minage Bitcoin la
40
- calculent des milliers à des millions de fois plus vite qu’un navigateur. Des
41
- outils existent pour résoudre ces défis en masse.
42
- - **Argon2id** : la mémoire exigée égalise bien le matériel, mais vérifier coûte
43
- autant qu’un essai : chaque fausse preuve envoyée au serveur lui coûte des
44
- dizaines de mégaoctets et des centaines de millisecondes.
45
- - **Equi-X** combine les deux qualités : un nouveau programme HashX tiré au sort
46
- à chaque essai et environ 2 Mio de mémoire, mais une vérification qui ne
47
- refait qu’une poignée d’évaluations.
48
-
49
- Les chiffres « mesurés » viennent de ce dépôt (portable x86-64 récent) ; les
42
+ | Un essai dans le navigateur | ≈ 0,1 à 10 µs | ≈ 0,3 à 1 s | **≈ 35 ms** (mesuré, programmes compilés) |
43
+ | Essais à réaliser pour 1 min | 10⁷ à 10⁹ | 60 à 200 | **≈ 1 700** |
44
+ | Mémoire pendant le calcul | < 1 Kio | 64 Mio par essai en cours | **≈ 3 Mio** par essai en cours (mesuré), réglable jusqu’à 64 Mio |
45
+ | La même preuve sur matériel optimisé | carte graphique : 10 ms ; puce de minage : ≈ 1 µs | 10 à 30 s : la mémoire freine cartes graphiques et puces | **≈ 37 s** en code natif compilé (mesuré) |
46
+ | Avantage du matériel optimisé | × 10³ à × 10⁷ | × 2 à × 5 | **≈ × 1,6** 20 quand le navigateur interprète HashX) |
47
+ | Vérifier une preuve : temps | 1 empreinte, ≈ 1 µs | **1 essai complet, ≈ 0,1 à 1 s** | **≈ 0,2 ms par part**, en WebAssembly comme en natif, quel que soit n (mesuré) |
48
+ | Vérifier une preuve : mémoire | négligeable | **64 Mio par vérification** | négligeable |
49
+
50
+ Les chiffres « mesurés » viennent de ce dépôt (voir [Mesures](#mesures)) ; les
50
51
  autres sont des ordres de grandeur publics, à affiner avec la page de démo.
51
52
 
53
+ - **SHA-256 est écarté** : cartes graphiques et puces de minage Bitcoin la
54
+ calculent des milliers à des millions de fois plus vite qu’un navigateur, et
55
+ des outils existent pour résoudre ces défis en masse.
56
+ - **Argon2id est écarté à cause de sa vérification**, qui coûte autant qu’un
57
+ essai. Chaque preuve reçue, même fausse, oblige le serveur à refaire un calcul
58
+ complet : un attaquant qui envoie **100 fausses preuves par seconde**, sans
59
+ rien calculer lui-même, occupe 30 à 100 cœurs et 6,4 Gio de mémoire du
60
+ serveur. La preuve de travail, censée protéger le serveur, devient alors le
61
+ moyen le plus simple de le saturer (déni de service).
62
+ - **Equi-X** garde l’égalisation du matériel sans ce défaut : un nouveau
63
+ programme HashX tiré au sort à chaque essai et environ 2 Mio de mémoire pour
64
+ résoudre, mais une vérification qui ne refait qu’une poignée d’évaluations.
65
+ Les mêmes 100 fausses preuves par seconde coûtent environ 2 % d’un cœur.
66
+
67
+ ### Compiler HashX dans le navigateur
68
+
69
+ L’avantage d’un attaquant natif sur Equi-X venait surtout de la compilation :
70
+ en natif, chaque programme HashX (512 instructions tirées au sort par défi) est
71
+ compilé en code machine, alors que le module WebAssembly l’interprétait :
72
+ ≈ 21 ms par essai contre ≈ 430 ms. Désormais, pour chaque défi, le chargeur
73
+ JavaScript génère un petit module WebAssembly (≈ 9 Ko) qui évalue le programme
74
+ sur les 2¹⁶ indices et écrit la table des valeurs directement dans la mémoire
75
+ du solveur, qu’il importe ; le solveur Rust cherche ensuite les collisions.
76
+
77
+ - La liste d’instructions vient d’une copie de la crate `hashx` qui expose le
78
+ programme généré (`crates/hashx`) : c’est le programme de hashx, vérifié
79
+ entrée par entrée contre la crate publiée.
80
+ - WebAssembly n’a pas de multiplication 64 × 64 → 128 bits : la moitié haute
81
+ est reconstituée à partir de quatre produits 32 × 32 (et corrigée pour la
82
+ version signée). Le branchement unique de HashX devient une boucle par cible.
83
+ - Coût mesuré dans Chromium par essai : génération 1,5 ms, compilation et
84
+ instanciation 0,9 ms, remplissage de la table 24 ms (contre ≈ 420 ms
85
+ interprété), recherche 8 ms. La compilation est donc rentable dès le premier
86
+ essai, et activée par défaut (`compilation: 'auto'`).
87
+ - Aucun `eval` : la politique `script-src 'self' 'wasm-unsafe-eval'` suffit.
88
+ - Le moteur JavaScript (sans WebAssembly) reste interprété : y compiler
89
+ demanderait `eval`, que ces navigateurs refusent justement.
90
+
91
+ Résultat : ≈ 35 ms par essai dans Chromium (≈ 50 ms sous Bun) contre ≈ 21 ms
92
+ en natif compilé, soit un écart d’environ **× 1,6** au lieu de × 20.
93
+
94
+ ### Et plus de mémoire ?
95
+
96
+ Le paramètre `n` choisit un membre de la famille Equihash(n, k = 3) sur HashX
97
+ dont Equi-X est le cas n = 60. Chaque pas de 4 double la liste de valeurs à
98
+ calculer et à trier, donc la mémoire et le temps d’un essai ; la vérification,
99
+ elle, reste de huit évaluations HashX, ≈ 0,2 ms par part quel que soit n.
100
+
101
+ | n | Mémoire de travail par fil | Un essai, Chromium (compilé) | Un essai, natif compilé | Écart |
102
+ |---|---|---|---|---|
103
+ | 60 (Equi-X) | 1,8 Mio | 34 ms | 21 ms | × 1,6 |
104
+ | 64 | 3,8 Mio | 66 à 74 ms | 48 ms | × 1,4 à 1,5 |
105
+ | 68 | 7,6 Mio | 155 ms | 88 ms | × 1,8 |
106
+ | 72 | 15 Mio | 271 à 288 ms | 177 ms | × 1,5 à 1,6 |
107
+ | 76 | 30 Mio | 557 à 569 ms | 356 ms | × 1,6 |
108
+ | 80 | 63 Mio | 1,24 à 1,30 s | 0,72 s | × 1,7 à 1,8 |
109
+
110
+ Ce que montrent ces mesures :
111
+
112
+ - **La mémoire ne réduit pas l’écart avec un attaquant natif** : il reste
113
+ entre × 1,4 et × 1,8 à tous les n, parce que natif et navigateur font le même
114
+ travail, deux fois plus grand à chaque pas. Il augmente même un peu vers
115
+ n = 80 : la recherche, limitée par les accès mémoire, y est deux fois plus
116
+ lente en WebAssembly (511 ms contre 249 ms). C’est la compilation, pas la
117
+ mémoire, qui a réduit l’écart de × 20 à × 1,6.
118
+ - **La mémoire renchérit le parallélisme massif** (cartes graphiques, puces
119
+ dédiées) : chaque essai en cours immobilise n Mio et une bande passante
120
+ mémoire proportionnelle. L’expérience d’Equihash montre toutefois que cela
121
+ ne suffit pas à l’empêcher : des puces spécialisées existent pour Equihash à
122
+ 144 Mio.
123
+ - **Elle coûte cher aux appareils modestes** : à n = 80, un téléphone qui
124
+ calcule sur huit cœurs mobilise ≈ 0,5 Gio, et un essai y dure plusieurs
125
+ secondes. Pour une même durée totale, il faut diviser l’effort d’autant.
126
+
127
+ n = 60 reste donc le réglage par défaut ; un n plus grand se justifie pour un
128
+ public d’ordinateurs de bureau, contre un attaquant qui paralléliserait sur du
129
+ matériel pauvre en mémoire. `nPourMemoire(mio)` donne le plus grand n qui tient
130
+ dans un budget par fil.
131
+
52
132
  ## Protocole
53
133
 
54
134
  Le défi d’un essai est `graine ‖ compteur` (compteur u32 petit-boutiste). La
@@ -57,14 +137,95 @@ un octet nul, puis l’empreinte SHA-256 de la requête à protéger
57
137
  (`construireGraine`). Une preuve calculée pour une requête ne vaut donc rien pour
58
138
  une autre.
59
139
 
60
- - Equi-X trouve en moyenne deux solutions par défi. Une solution est retenue si
61
- `blake2b-256(défi ‖ solution)`, lu en u32 gros-boutiste, multiplié par
62
- l’**effort**, ne dépasse pas 2³² − 1 : c’est la règle d’effort de Tor (`hs_pow`).
63
- - Une preuve réunit **`nombre` parts** de 20 octets (`compteur solution`), aux
64
- compteurs strictement croissants. Plusieurs petites preuves plutôt qu’une
65
- grande rendent l’attente régulière (écart type relatif en `1/√nombre`).
140
+ - Un défi a en moyenne deux solutions, quel que soit n. Une solution est
141
+ retenue si `blake2b-256(graine HashX ‖ solution)`, lu en u32 gros-boutiste,
142
+ multiplié par l’**effort**, ne dépasse pas 2³² − 1 : c’est la règle d’effort
143
+ de Tor (`hs_pow`). Pour n = 60, la graine HashX est le défi lui-même.
144
+ - Une preuve réunit **`nombre` parts** aux compteurs strictement croissants,
145
+ sous la forme compacte décrite plus bas : ≈ 17 octets par part pour n = 60,
146
+ 22 pour n = 80. Plusieurs petites preuves plutôt qu’une grande rendent
147
+ l’attente régulière (écart type relatif en `1/√nombre`).
66
148
  - Probabilité qu’un essai aboutisse : environ `1 − e^(−2/effort)`, soit 86 %
67
149
  pour l’effort 1, 62 % pour 2, 12 % pour 16 (`probabiliteEssai`).
150
+ - Le vérificateur doit employer les mêmes effort, nombre **et n** que le
151
+ solveur : une preuve n’est valable que pour son n.
152
+
153
+ ### Règles d’Equihash(n, 3) sur HashX
154
+
155
+ Avec `c = n / 4` et `N = 2^(c+1)` indices (n ∈ {60, 64, 68, 72, 76, 80}) :
156
+
157
+ - **Programme** : HashX (hashx 0.9.1) tiré de la graine HashX, qui vaut le défi
158
+ pour n = 60 (exactement Equi-X), et `défi ‖ "pow-equix/equihash-k3-n" ‖ n`
159
+ (23 octets ASCII puis n sur un octet) au-delà, pour que chaque n tire ses
160
+ propres programmes. Si la graine ne donne aucun programme valide (quelques
161
+ graines sur plusieurs milliers), le défi n’a pas de solution.
162
+ - **Valeur** d’un indice `i < N` : `V(i) = HashX(i)`, sur le premier mot de
163
+ 64 bits de la sortie pour n ≤ 64 ; pour n > 64, `mot0 + 2⁶⁴ × (mot1 mod 2^(n−64))`
164
+ (deux premiers mots petit-boutistes de la sortie étendue de HashX).
165
+ - **Solution** : 8 indices `i₀ … i₇`, tous inférieurs à N, tels que, modulo 2ⁿ,
166
+ - chaque paire `V(i₂ⱼ) + V(i₂ⱼ₊₁)` s’annule sur ses c bits de poids faible,
167
+ - chaque quadruplet `V(i₀) + … + V(i₃)` et `V(i₄) + … + V(i₇)` sur 2c bits,
168
+ - la somme des huit sur les n bits.
169
+ - **Ordre canonique** : à chaque nœud de l’arbre (paires, quadruplets, racine),
170
+ la moitié gauche, lue de son dernier élément vers le premier, ne dépasse pas
171
+ la moitié droite lue de la même façon (ordre lexicographique, égalité
172
+ permise) : c’est la règle d’Equi-X, qui rend chaque solution unique.
173
+ - **Solution rangée** : les 8 indices sur b = n/4 + 1 bits chacun, bout à bout
174
+ dans un flux de bits petit-boutiste (indice 0 dans les bits de poids faible
175
+ du premier octet), soit exactement b octets. Pour n = 60 (b = 16), c’est
176
+ octet pour octet la forme d’Equi-X (8 × u16 petit-boutistes) ; 21 octets pour
177
+ n = 80. La règle d’effort porte sur cette forme rangée.
178
+ - **Solveur** : celui d’Equi-X, généralisé (`crates/pow-equix/src/solveur.rs`) :
179
+ 2^(c−7) seaux de 336 places par couche (256 éléments en moyenne), table
180
+ temporaire de 128 seaux de 12 places, mêmes règles d’abandon quand un seau
181
+ déborde et même ordre de parcours, au plus 8 solutions par défi. Pour n = 60,
182
+ il donne exactement les solutions de la crate `equix` d’Arti, dans le même
183
+ ordre : vérifié sur 2 000 défis, et les deux vérifications s’accordent sur
184
+ toutes les altérations essayées (tests Rust).
185
+
186
+ ### Forme d’une preuve
187
+
188
+ ```text
189
+ preuve = pour chaque part : écart (LEB128 non signé, 1 à 5 octets) ‖ solution rangée (n/4 + 1 octets)
190
+ ```
191
+
192
+ - Le premier écart est le compteur lui-même, les suivants `compteur −
193
+ précédent − 1` : la stricte croissance des compteurs est implicite, et les
194
+ compteurs d’une preuve (en général sous 128) ne coûtent qu’un octet chacun.
195
+ - **Une seule forme d’octets par preuve.** Le décodeur refuse tout LEB128 non
196
+ canonique (octet final nul dans un encodage de plus d’un octet, plus de
197
+ 5 octets, valeur au-delà de 2³² − 1), un compteur cumulé au-delà de
198
+ 2³² − 1, un nombre de parts différent de `nombre` et tout octet en trop ou
199
+ manquant. Sans cette règle, un attaquant pourrait réencoder une preuve
200
+ acceptée (zéros de tête dans un écart…) et la faire passer pour nouvelle
201
+ auprès d’une détection de rejeu fondée sur l’empreinte de la preuve.
202
+ - Taille maximale : `nombre × (5 + n/4 + 1)` octets (`tailleMaxPreuve(n,
203
+ nombre)`) ; un serveur refuse une entrée plus longue avant tout calcul.
204
+ - Tailles mesurées pour 4 parts, compteurs sous 128 : 68 octets pour n = 60
205
+ (effort 1 comme effort 16), 72 pour n = 64, 80 pour n = 72, 88 pour n = 80,
206
+ contre 80 et 144 octets avec les parts à compteur u32 d’avant.
207
+ - **Ce qui est standard, et ce qui ne l’est pas.** À n = 60, la solution est
208
+ exactement la forme standard d’Equi-X : 16 octets, 8 × u16 petit-boutistes,
209
+ telle que la produisent et la lisent Tor et la crate `equix`. L’enveloppe
210
+ (écarts de compteur en LEB128, plusieurs parts) et les n supérieurs à 60
211
+ sont propres à ce paquet : ni Tor ni une autre implémentation ne lisent
212
+ cette enveloppe. Il n’y a qu’un format ; si un format aligné sur un autre
213
+ protocole devait s’ajouter, ce serait comme une option explicite du
214
+ protocole, convenue entre solveur et vérificateur, jamais devinée à la
215
+ lecture des octets : sinon une même preuve aurait deux encodages, et la
216
+ détection de rejeu se contournerait.
217
+ - **Rupture avec la 0.2** : ses preuves (parts de 20 octets, `compteur u32 ‖
218
+ solution`) ne sont plus acceptées, et `VERSION_FORMAT` vaut 2, si bien qu’un
219
+ chargeur 0.2 refuse ce module. La solution d’Equi-X, elle, est inchangée :
220
+ à n = 60, seuls le compteur et l’enveloppe changent.
221
+
222
+ Pourquoi deux solutions par défi à tout n : chaque étage réunit N² / 2 paires
223
+ dont la somme s’annule sur c bits avec une probabilité 2^−c, soit ≈ N paires
224
+ gardées par étage ; le dernier étage exige 2c bits, soit N² / 2 × 2^−2c = 2
225
+ solutions attendues. Mesuré en natif : 2,07 solutions par défi pour n = 60
226
+ (400 défis), 1,99 pour 64, 2,07 pour 68, 1,98 pour 72 (400 défis chacun),
227
+ 1,98 pour 76 (120 défis), 1,92 pour 80 (60 défis). Les probabilités par essai
228
+ de `probabiliteEssai` valent donc pour tout n.
68
229
 
69
230
  ## Utilisation
70
231
 
@@ -80,49 +241,156 @@ const graine = await construireGraine('mon-service/1', JSON.stringify(requete))
80
241
  const annulation = new AbortController()
81
242
  const { parts } = await resoudre({
82
243
  octets, graine, effort: 1, nombre: 4,
244
+ n: 60, // mémoire : 60 (Equi-X, par défaut) à 80 ; le serveur vérifie avec le même n
245
+ compilation: 'auto', // par défaut : programmes HashX compilés en WebAssembly ; 'jamais' : interprète
83
246
  signal: annulation.signal,
84
- onProgression: ({ moteur, essais, parts, dureeMs, restantEstimeMs, memoireOctets }) => { /* … */ },
247
+ onProgression: ({ moteur, compilation, n, essais, parts, dureeMs, restantEstimeMs, memoireOctets }) => { /* … */ },
85
248
  })
86
249
  envoyer({ ...requete, preuve: hexadecimal(parts) })
87
250
  ```
88
251
 
89
- `resoudre` répartit les compteurs entre des Web Workers créés depuis un Blob
90
- (aucun fichier supplémentaire à publier) : par défaut un par cœur annoncé, huit
91
- au plus, et jamais plus que d’essais attendus (`filsConseilles`). Si les Web
92
- Workers sont refusés, le calcul continue sur le fil courant. `fils: 0` force ce
93
- mode. La progression donne le moteur, les essais, les parts trouvées, le temps
94
- écoulé, une estimation du temps restant mesurée sur l’appareil et la mémoire.
252
+ `resoudre` confie les compteurs à des Web Workers créés depuis un Blob (aucun
253
+ fichier supplémentaire à publier) : le fil principal les distribue un par un,
254
+ chaque résultat valant demande du suivant, si bien que des fils peuvent
255
+ s’ajouter en cours de calcul sans trou ni doublon. Par défaut, un fil par cœur
256
+ annoncé, huit au plus, et jamais plus que d’essais attendus (`filsConseilles`).
257
+ Si les Web Workers sont refusés, le calcul continue sur le fil courant.
258
+ `fils: 0` force ce mode. La progression donne le moteur, le mode (compilé ou
259
+ interprété), n, les Web Workers actifs (`filsActifs`), les essais, les parts
260
+ trouvées, le temps écoulé, une estimation du temps restant mesurée sur
261
+ l’appareil et la mémoire réelle des modules. L’annulation, la progression et
262
+ les Web Workers fonctionnent dans les deux modes ; si la compilation d’un
263
+ programme échoue, l’interprète termine l’essai et prend le relais
264
+ (`resultat.compilation` vaut alors `false`).
265
+
266
+ #### Nombre de fils adaptatif (recommandé pour le web grand public)
267
+
268
+ Sur un téléphone, trop de Web Workers × la mémoire de chacun peut faire tuer
269
+ l’onglet, sans erreur que la page puisse rattraper, et
270
+ `navigator.deviceMemory` n’existe que dans Chromium. `fils` accepte donc aussi
271
+ une **politique** : une fonction appelée au départ puis après chaque essai
272
+ terminé, avec `{ essaisTermines, dureePremierEssaiMs, dureeMoyenneEssaiMs,
273
+ filsActifs, n, execution }`, qui renvoie le nombre de fils voulu. Seule une
274
+ hausse est appliquée (aucun fil n’est arrêté en plein essai), jamais au-delà
275
+ des essais restants attendus ; si la création d’un fil supplémentaire échoue
276
+ (exception, mémoire, Web Worker en erreur avant tout résultat), le calcul
277
+ continue avec les fils existants, le compteur confié est redistribué, et plus
278
+ aucun fil n’est créé.
279
+
280
+ ```ts
281
+ import { filsAdaptatifs, resoudre } from 'pow-equix-wasm'
282
+
283
+ const { parts, fils } = await resoudre({
284
+ octets, graine, effort: 1, nombre: 4,
285
+ fils: filsAdaptatifs(), // 1 fil, puis jusqu’à 8 si l’appareil semble costaud
286
+ chargerJs: () => import('pow-equix-wasm/js'),
287
+ onProgression: ({ filsActifs }) => { /* 1 → 2 → 4… */ },
288
+ })
289
+ // `fils` : le plus grand nombre de Web Workers en service à la fois.
290
+ ```
291
+
292
+ `filsAdaptatifs(options?)` décide ainsi (seuils dans `SEUILS_FILS_ADAPTATIFS`,
293
+ chacun surchargeable par les options, comme `memoireAppareilGo`, `coeurs` et
294
+ `ecranPx` pour simuler un profil) :
295
+
296
+ | Situation | Fils |
297
+ |---|---|
298
+ | `navigator.deviceMemory` connu | d’emblée `floor(Go × 1024 × partMemoire / Mio par fil)` (`partMemoire` = 1/32), au moins 1 |
299
+ | sinon, avant le premier essai | 1 |
300
+ | r > 2,5 (lent) ou écran < 1 280 px physiques | 1 |
301
+ | r < 1,3 (rapide) et écran ≥ 1 920 px physiques | jusqu’à 8 |
302
+ | entre les deux | 2 si r > 2, sinon 4 (écran inconnu : compté comme moyen) |
303
+
304
+ r est la durée moyenne de calcul d’un essai divisée par la durée de référence
305
+ pour ce n et cette exécution (`msParEssai`). Chaque Web Worker mesure lui-même
306
+ cette durée, du début de l’essai à la fin de la recherche (compilation du
307
+ programme HashX comprise), sans l’instanciation du module ni l’attente des
308
+ messages ; le premier essai de chaque fil, ralenti par la mise en température
309
+ du JIT, est écarté de la moyenne dès qu’un essai suivant est connu. Sur une
310
+ machine déjà chargée, r augmente et la politique monte moins haut : c’est voulu ; l’écran est son plus grand côté
311
+ × `devicePixelRatio`. Toujours au plus 8 et le nombre de cœurs. Le défaut
312
+ reste le nombre fixe de `filsConseilles`, pour la compatibilité.
313
+ `travailleurEquix` est la fabrique de Web Worker employée par défaut, à
314
+ réutiliser pour les envelopper (`creerTravailleur`).
315
+
316
+ Politique de sécurité du contenu : `script-src 'self' 'wasm-unsafe-eval'`
317
+ suffit (aucun `eval`) ; les Web Workers viennent d’un Blob, donc `worker-src blob:`
318
+ (ou `script-src … blob:`) est nécessaire pour calculer sur plusieurs cœurs.
319
+
320
+ #### Choisir la mémoire
321
+
322
+ | Fonction | Rôle |
323
+ |---|---|
324
+ | `N_EQUIX`, `N_VALIDES` | 60, et les n acceptés : 60, 64, 68, 72, 76, 80 |
325
+ | `memoirePourN(n)` | mémoire de travail du solveur par fil, en octets (le module ajoute ≈ 1,3 Mio) |
326
+ | `nPourMemoire(mio)` | plus grand n dont la mémoire de travail tient dans `mio` Mio (60 au minimum) |
327
+ | `tailleSolution(n)` | octets d’une solution rangée : n/4 + 1 (16 pour n = 60, 21 pour n = 80) |
328
+ | `tailleMaxPreuve(n, nombre)` | taille maximale d’une preuve, `nombre × (5 + n/4 + 1)` : refuser plus long avant tout calcul |
329
+ | `taillePreuve(compteurs, n)` | taille exacte de la preuve pour ces compteurs |
330
+ | `encoderPreuve(parts)` | assemble `{ compteur, solution }[]` en preuve (le module décode et vérifie) |
331
+ | `ModuleEquix.compteurs(preuve, nombre, n)` | compteurs d’une preuve, décodée sans être vérifiée, ou `null` si sa forme n’est pas la forme unique |
332
+ | `msParEssai(execution, n)` | durée de référence d’un essai (`wasmCompile`, `wasm`, `js`, `jsSansJit`) |
333
+ | `estimerDuree({ effort, nombre, execution, fils, n })` | durée probable d’une preuve, avant de calculer |
334
+ | `executionPrevue(options)` | exécution que `resoudre` emploiera avec ces options |
335
+
336
+ ```ts
337
+ import { estimerDuree, executionPrevue, nPourMemoire, resoudre } from 'pow-equix-wasm'
338
+
339
+ const n = nPourMemoire(16) // 72 : 15 Mio de travail par fil
340
+ const execution = executionPrevue({ octets }) ?? 'jsSansJit'
341
+ // Un essai coûte ≈ 8 fois plus qu’à n = 60 : réduire l’effort d’autant pour la même attente.
342
+ const attente = estimerDuree({ effort: 1, nombre: 4, execution, n })
343
+ const { parts } = await resoudre({ octets, graine, effort: 1, nombre: 4, n })
344
+ ```
95
345
 
96
346
  ### Sans WebAssembly : repli en JavaScript et mode dégradé
97
347
 
98
348
  `pow-equix-wasm/js` fournit le même module traduit en JavaScript pur par wasm2js
99
- (binaryen) : mêmes preuves, octet pour octet, mais bien plus lent. Il ne pèse
100
- qu’environ 120 Ko une fois minifié, et n’a à être chargé que s’il sert :
349
+ (binaryen) : mêmes preuves, octet pour octet, pour tous les n, mais bien plus
350
+ lent (il interprète toujours HashX : compiler demanderait `eval`). Il pèse
351
+ ≈ 620 Ko bruts, ≈ 150 Ko minifié (37 Ko compressé) : il ne doit être
352
+ téléchargé que s’il sert. Façon recommandée : le confier à `resoudre` par
353
+ `chargerJs`, un chargeur que le paquet n’appelle que si WebAssembly est
354
+ indisponible **ou échoue** (compilation ou instanciation refusées, par exemple
355
+ par une politique sans `'wasm-unsafe-eval'`, mémoire insuffisante, Web Worker
356
+ en échec avant tout résultat). Le calcul reprend alors avec le moteur
357
+ JavaScript ; une annulation, elle, ne déclenche jamais le repli.
101
358
 
102
359
  ```ts
103
360
  import { estimerDuree, ralentissement, resoudre, webAssemblyDisponible } from 'pow-equix-wasm'
104
361
 
105
- const wasm = webAssemblyDisponible()
106
- const js = wasm ? undefined : (await import('pow-equix-wasm/js')).creerExportsEquixJs
107
- if (!wasm) {
362
+ if (!webAssemblyDisponible()) {
108
363
  // Sans WebAssembly, le JIT est en général coupé aussi : hypothèse prudente.
109
364
  const attente = estimerDuree({ effort: 1, nombre: 4, execution: 'jsSansJit' })
110
365
  if (attente > 5_000) avertir(`Active WebAssembly pour valider environ ${Math.round(ralentissement('jsSansJit'))} fois plus vite.`)
111
366
  }
112
- const resultat = await resoudre({ octets, js, graine, effort: 1, nombre: 4, onProgression })
113
- // resultat.moteur vaut 'js' en mode dégradé.
367
+ const resultat = await resoudre({
368
+ octets, graine, effort: 1, nombre: 4, onProgression,
369
+ chargerJs: () => import('pow-equix-wasm/js'), // téléchargé seulement en cas de besoin
370
+ })
371
+ // resultat.moteur vaut 'js' en mode dégradé, et resultat.repli en dit la raison :
372
+ // { raison: 'indisponible' } ou { raison: 'echec', message } (aussi dans la progression).
114
373
  ```
115
374
 
375
+ Le bundle principal (`dist/index.js`) n’importe jamais statiquement le moteur
376
+ JavaScript ni le module en base64 (un test le vérifie) : c’est le `import()`
377
+ dynamique de `chargerJs` qui en fait un morceau à part pour le bundler. Qui
378
+ l’importe déjà lui-même peut passer la fabrique par `js: creerExportsEquixJs`.
379
+ Côté client, `ModuleEquix.charger({ octets, chargerJs })` donne de même un
380
+ module pour vérifier, WebAssembly d’abord.
381
+
116
382
  `moteur: 'wasm' | 'js' | 'auto'` impose ou laisse choisir le moteur ;
117
383
  `moteurRetenu` dit à l’avance lequel servira. `ModuleEquix.instancier(octets)`
118
384
  et `ModuleEquix.depuisJs(creerExportsEquixJs)` donnent chacun un module qui
119
- résout et vérifie.
385
+ résout (`essayer`, interprété, et `essayerCompile`, compilé quand c’est
386
+ possible) et vérifie (`verifier(graine, parts, effort, nombre, n = 60)`).
120
387
 
121
- | Un cœur, portable x86-64 | Un essai | Ralentissement |
388
+ | Un cœur, portable x86-64, n = 60 | Un essai | Ralentissement |
122
389
  |---|---|---|
123
- | WebAssembly (Chromium, Bun, Node) | ≈ 410 ms | × 1 |
124
- | JavaScript avec JIT | ≈ 3,8 s (Chromium), 1,5 s (Node) | × 4 à × 9 |
125
- | JavaScript sans JIT (Node `--jitless`) | 79 s | ≈ × 190 |
390
+ | WebAssembly, programmes HashX compilés (par défaut) | ≈ 35 ms (Chromium), 50 ms (Bun) | × 1 |
391
+ | WebAssembly, programmes HashX interprétés | ≈ 430 ms (Chromium, Bun) | × 12 |
392
+ | JavaScript avec JIT | ≈ 3,8 s (Chromium), 1,5 s (Node, Bun) | × 40 à × 110 |
393
+ | JavaScript sans JIT (Node `--jitless`) | ≈ 76 s | ≈ × 2 000 |
126
394
 
127
395
  Désactiver WebAssembly s’accompagne presque toujours d’un JavaScript sans JIT
128
396
  (Tor Browser en mode renforcé, mode Isolement d’iOS) : prévoir un avertissement
@@ -132,20 +400,59 @@ et une difficulté réaliste pour ces personnes.
132
400
 
133
401
  ```ts
134
402
  import { readFile } from 'node:fs/promises'
135
- import { ModuleEquix, construireGraine, depuisHexadecimal } from 'pow-equix-wasm'
403
+ import { ModuleEquix, construireGraine, depuisHexadecimal, tailleMaxPreuve } from 'pow-equix-wasm'
136
404
 
137
405
  const module = await ModuleEquix.instancier(await readFile(new URL(import.meta.resolve('pow-equix-wasm/equix.wasm'))))
138
406
  const graine = await construireGraine('mon-service/1', JSON.stringify(requeteSansPreuve))
139
- const valide = module.verifier(graine, depuisHexadecimal(preuve) ?? new Uint8Array(), 1, 4)
407
+ const octets = depuisHexadecimal(preuve) ?? new Uint8Array()
408
+ // Refuser une entrée trop longue avant tout calcul, puis vérifier avec les mêmes effort, nombre et n que le solveur.
409
+ const valide = octets.length <= tailleMaxPreuve(60, 4) && module.verifier(graine, octets, 1, 4, 60)
410
+ // Détection de rejeu : l’empreinte de `octets` suffit, une preuve n’ayant qu’une forme d’octets.
140
411
  ```
141
412
 
413
+ La vérification coûte ≈ 0,2 ms par part quel que soit n : le programme HashX
414
+ du défi, puis huit évaluations interprétées et les contrôles de l’arbre.
415
+
142
416
  Une page qui ne peut rien télécharger (ouverte en `file://`, script unique)
143
417
  intègre le module en base64 avec `octetsEquix()` de `pow-equix-wasm/octets`.
144
418
 
145
419
  Ce qui reste à la charge de l’appelant, et que le paquet ne fait pas :
146
- refuser les preuves rejouées, vérifier la fraîcheur de la requête (horodatage
420
+ refuser les preuves rejouées (l’empreinte des octets de la preuve y suffit,
421
+ chaque preuve n’ayant qu’une forme ; ou ses compteurs, `module.compteurs`), vérifier la fraîcheur de la requête (horodatage
147
422
  dans le contenu haché) et limiter le débit des vérifications.
148
423
 
424
+ ## Mesures
425
+
426
+ Portable AMD Ryzen 7 4700U (x86-64), session de bureau active, un cœur par
427
+ essai ; Chromium 153 sans interface, Bun 1.4.0, Rust 1.93.1 en natif
428
+ (`--release`). Quelques essais par ligne aux grands n : compter ± 10 %.
429
+ `bun run mesure`, `bun run mesure:navigateur` et
430
+ `cargo run --release -p pow-equix [--features compilateur] --example mesure`
431
+ les reproduisent.
432
+
433
+ | n | Mémoire de travail | Mémoire réelle du module | Preuve de 4 parts | Essai WebAssembly interprété (Chromium / Bun) | Essai WebAssembly compilé (Chromium / Bun) | Natif compilé | Natif interprété | Chromium compilé / natif compilé | Vérification d’une part |
434
+ |---|---|---|---|---|---|---|---|---|---|
435
+ | 60 | 1,8 Mio | 3,1 Mio | 68 o | 442 / 427 ms | 34 / 49 ms | 21 ms | 490 ms | × 1,6 | 0,18 ms |
436
+ | 64 | 3,8 Mio | 4,9 Mio | 72 o | 847 / 752 ms | 66 à 74 / 86 ms | 48 ms | 990 ms | × 1,4 à 1,5 | 0,16 ms |
437
+ | 68 | 7,6 Mio | 8,9 Mio | 76 o | 1,9 / 1,8 s | 155 / 158 ms | 88 ms | 1,9 s | × 1,8 | 0,16 ms |
438
+ | 72 | 15 Mio | 16,5 Mio | 80 o | 3,4 / 3,6 s | 271 à 288 / 287 ms | 177 ms | 3,9 s | × 1,5 à 1,6 | 0,18 ms |
439
+ | 76 | 30 Mio | 31,6 Mio | 84 o | 6,8 / 6,7 s | 557 à 569 / 595 ms | 356 ms | 8,0 s | × 1,6 | 0,16 ms |
440
+ | 80 | 63 Mio | 64,5 Mio | 88 o | 14,7 / 13,9 s | 1,24 à 1,30 / 1,23 s | 721 ms | 16,4 s | × 1,7 à 1,8 | 0,16 ms |
441
+
442
+ - Avant la compilation, l’écart entre le navigateur et un attaquant natif
443
+ était de ≈ × 20 (430 ms contre 21 ms à n = 60) ; il est maintenant de
444
+ × 1,4 à × 1,8 à tous les n.
445
+ - Détail d’un essai compilé à n = 60 dans Chromium : préparation (programme
446
+ HashX, en Rust) 0,3 ms, génération du module 1,5 ms, compilation et
447
+ instanciation 0,9 ms, remplissage de la table 24 ms, recherche 8 ms. En
448
+ natif : table 15 ms, recherche 6 ms. À n = 80 : remplissage 766 ms et
449
+ recherche 511 ms dans Chromium, contre 472 ms et 249 ms en natif.
450
+ - Appeler le module compilé par tranches (2 048 éléments par appel) plutôt
451
+ qu’en une fois ne change rien sous V8, et gagne ≈ 20 % sous Bun
452
+ (JavaScriptCore) : c’est le réglage retenu (`TRANCHE_REMPLISSAGE`).
453
+ - La mémoire réelle du module est celle de sa mémoire linéaire après un essai :
454
+ mémoire de travail du solveur plus ≈ 1,3 Mio (pile, tas, tampon).
455
+
149
456
  ## Construction reproductible
150
457
 
151
458
  Rien de construit n’est versionné : `dist/` et `site/` sont produits par la CI.
@@ -162,8 +469,10 @@ Rien de construit n’est versionné : `dist/` et `site/` sont produits par la C
162
469
 
163
470
  ```sh
164
471
  bun install
165
- bun run check # tests Rust et TS, construction, démo, reproductibilité
166
- bun run demo # construit puis sert la démo sur http://127.0.0.1:4600/
472
+ bun run check # tests Rust et TS, construction, démo, reproductibilité
473
+ bun run demo # construit puis sert la démo sur http://127.0.0.1:4600/
474
+ bun run mesure # temps d’un essai par n, interprété et compilé, sous Bun
475
+ bun run mesure:navigateur # la même chose dans Chromium sans interface
167
476
  ```
168
477
 
169
478
  Il faut Rust (via rustup, qui installe seul la version figée et la cible
@@ -179,17 +488,23 @@ Il faut Rust (via rustup, qui installe seul la version figée et la cible
179
488
 
180
489
  ## Organisation
181
490
 
182
- - `crates/pow-equix` : défi, résolution et vérification en Rust ; réutilisable en
183
- natif (option `compilateur` pour générer du code machine HashX, par exemple
184
- dans une application Tauri).
491
+ - `crates/pow-equix` : défi, solveur et vérificateur Equihash(n, 3) sur HashX
492
+ en Rust ; réutilisable en natif (option `compilateur` pour générer du code
493
+ machine HashX, par exemple dans une application Tauri).
494
+ - `crates/hashx` : copie de la crate `hashx` 0.9.1 d’Arti (paquet `pow-hashx`),
495
+ identique à l’original sauf `src/expose.rs`, qui expose le programme
496
+ généré, et deux ajustements de visibilité (voir son `Cargo.toml`).
185
497
  - `crates/pow-equix-wasm` : interface C minimale vers WebAssembly, sans
186
498
  wasm-bindgen.
187
499
  - `src/index.ts` : chargeur, deux moteurs, Web Workers, annulation,
188
- progression, estimations, vérification.
189
- - `demo/` : page de démo et de calibrage.
500
+ progression, estimations, vérification ; `src/compilation.ts` : génération
501
+ des modules WebAssembly qui évaluent les programmes HashX.
502
+ - `demo/` : page de démo et de calibrage ; `scripts/mesurer.ts` : mesures
503
+ sous Bun et dans Chromium.
190
504
 
191
505
  ## Licence
192
506
 
193
- LGPL-3.0 (voir `LICENSE` et `COPYING`). Le module intègre les crates `equix` et
194
- `hashx` du projet [Arti](https://gitlab.torproject.org/tpo/core/arti) de Tor,
195
- elles-mêmes sous LGPL-3.0, d’après les algorithmes de tevador.
507
+ LGPL-3.0 (voir `LICENSE` et `COPYING`). Le module intègre une copie de la crate
508
+ `hashx` du projet [Arti](https://gitlab.torproject.org/tpo/core/arti) de Tor
509
+ (`crates/hashx`), elle-même sous LGPL-3.0, et son solveur reprend celui de la
510
+ crate `equix`, d’après les algorithmes de tevador.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Compilation des programmes HashX en WebAssembly, dans le navigateur.
3
+ *
4
+ * En natif, HashX compile chaque programme en code machine ; le module Equi-X,
5
+ * lui, l’interprète. Pour réduire cet écart, le chargeur génère ici, pour chaque
6
+ * défi, un petit module WebAssembly qui évalue le programme sur tous les indices
7
+ * et écrit la table des valeurs directement dans la mémoire du solveur, qu’il
8
+ * importe. Le solveur Rust cherche ensuite les collisions dans cette table.
9
+ *
10
+ * Le module généré n’exporte qu’une fonction, `remplir(debut, fin)`, qui écrit
11
+ * les valeurs des indices `debut` à `fin − 1` :
12
+ *
13
+ * - registres initiaux : SipHash 2-4 en mode compteur (clé du programme) ;
14
+ * - programme : instructions 64 bits de HashX ; la moitié haute des
15
+ * multiplications 64 × 64 est reconstituée à partir de quatre produits
16
+ * 32 × 32, WebAssembly n’ayant pas de multiplication 128 bits ; le
17
+ * branchement unique de HashX devient une boucle par cible ;
18
+ * - sortie : condensation des registres (un tour SipHash sur chaque moitié),
19
+ * premier mot à l’adresse basse, bits hauts du second (n > 64) à l’adresse haute.
20
+ *
21
+ * Aucun `eval` : seule la compilation WebAssembly est demandée, que la politique
22
+ * `script-src 'self' 'wasm-unsafe-eval'` autorise.
23
+ *
24
+ * `genererModuleHashx` ne référence rien d’extérieur : sa source est transmise
25
+ * telle quelle aux Web Workers (`toString`), comme le moteur JavaScript.
26
+ */
27
+ /** Taille de l’en-tête écrit par `preparer`, avant le programme encodé. */
28
+ export declare const TAILLE_ENTETE_PROGRAMME = 48;
29
+ /** Taille du programme encodé : 512 instructions de 8 octets (voir crates/hashx/src/expose.rs). */
30
+ export declare const TAILLE_PROGRAMME: number;
31
+ /** Description complète d’un essai préparé : en-tête puis programme. */
32
+ export declare const TAILLE_DESCRIPTION: number;
33
+ /**
34
+ * Octets du module WebAssembly qui remplit la table d’un essai, d’après sa
35
+ * description (en-tête et programme écrits par `preparer`).
36
+ */
37
+ export declare function genererModuleHashx(description: Uint8Array): Uint8Array<ArrayBuffer>;
@@ -1,11 +1,11 @@
1
1
  {
2
- "version": "0.2.1",
2
+ "version": "0.4.0",
3
3
  "rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)",
4
- "octets": 44968,
5
- "sha256": "f6c72e8e6b48386c23b6d1259c1739fef0711b11ffa692bcaed3e40a5a41c840",
4
+ "octets": 63422,
5
+ "sha256": "fbbbea72a066d83ef95f0de6fab2d43eb9df5fee02058ec3f19230fe2a5d9a36",
6
6
  "js": {
7
7
  "binaryen": "132.0.0",
8
- "octets": 497901,
9
- "sha256": "a4da12ce262d3c8c86b344fd568bab7e5a63d1a51ea35758606d4f8007f3475b"
8
+ "octets": 618388,
9
+ "sha256": "5849e93354b14f493e25b94d4ec91b7d35ea0e079f8c85f08154853d7565b821"
10
10
  }
11
11
  }