pow-equix-wasm 0.2.0 → 0.3.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 +323 -61
- package/dist/compilation.d.ts +37 -0
- package/dist/empreinte.json +5 -5
- package/dist/equix-js.js +12694 -9158
- package/dist/equix.wasm +0 -0
- package/dist/index.d.ts +193 -29
- package/dist/index.js +572 -48
- package/dist/octets.js +2 -2
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
[](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
|
|
10
|
-
cœurs, et la vérifie côté serveur en quelques centaines de microsecondes.
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
21
|
-
> (
|
|
22
|
-
>
|
|
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.
|
|
29
|
-
navigateur, sur un cœur** :
|
|
34
|
+
presque rien.
|
|
30
35
|
|
|
31
|
-
|
|
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
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
-
|
|
61
|
-
`blake2b-256(
|
|
62
|
-
l’**effort**, ne dépasse pas 2³² − 1 : c’est la règle d’effort
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,8 +241,10 @@ 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
|
```
|
|
@@ -90,39 +253,91 @@ envoyer({ ...requete, preuve: hexadecimal(parts) })
|
|
|
90
253
|
(aucun fichier supplémentaire à publier) : par défaut un par cœur annoncé, huit
|
|
91
254
|
au plus, et jamais plus que d’essais attendus (`filsConseilles`). Si les Web
|
|
92
255
|
Workers sont refusés, le calcul continue sur le fil courant. `fils: 0` force ce
|
|
93
|
-
mode. La progression donne le moteur,
|
|
94
|
-
écoulé, une estimation du temps restant
|
|
256
|
+
mode. La progression donne le moteur, le mode (compilé ou interprété), n, les
|
|
257
|
+
essais, les parts trouvées, le temps écoulé, une estimation du temps restant
|
|
258
|
+
mesurée sur l’appareil et la mémoire réelle des modules. L’annulation, la
|
|
259
|
+
progression et les Web Workers fonctionnent dans les deux modes ; si la
|
|
260
|
+
compilation d’un programme échoue, l’interprète termine l’essai et prend le
|
|
261
|
+
relais (`resultat.compilation` vaut alors `false`).
|
|
262
|
+
|
|
263
|
+
Politique de sécurité du contenu : `script-src 'self' 'wasm-unsafe-eval'`
|
|
264
|
+
suffit (aucun `eval`) ; les Web Workers viennent d’un Blob, donc `worker-src blob:`
|
|
265
|
+
(ou `script-src … blob:`) est nécessaire pour calculer sur plusieurs cœurs.
|
|
266
|
+
|
|
267
|
+
#### Choisir la mémoire
|
|
268
|
+
|
|
269
|
+
| Fonction | Rôle |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `N_EQUIX`, `N_VALIDES` | 60, et les n acceptés : 60, 64, 68, 72, 76, 80 |
|
|
272
|
+
| `memoirePourN(n)` | mémoire de travail du solveur par fil, en octets (le module ajoute ≈ 1,3 Mio) |
|
|
273
|
+
| `nPourMemoire(mio)` | plus grand n dont la mémoire de travail tient dans `mio` Mio (60 au minimum) |
|
|
274
|
+
| `tailleSolution(n)` | octets d’une solution rangée : n/4 + 1 (16 pour n = 60, 21 pour n = 80) |
|
|
275
|
+
| `tailleMaxPreuve(n, nombre)` | taille maximale d’une preuve, `nombre × (5 + n/4 + 1)` : refuser plus long avant tout calcul |
|
|
276
|
+
| `taillePreuve(compteurs, n)` | taille exacte de la preuve pour ces compteurs |
|
|
277
|
+
| `encoderPreuve(parts)` | assemble `{ compteur, solution }[]` en preuve (le module décode et vérifie) |
|
|
278
|
+
| `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 |
|
|
279
|
+
| `msParEssai(execution, n)` | durée de référence d’un essai (`wasmCompile`, `wasm`, `js`, `jsSansJit`) |
|
|
280
|
+
| `estimerDuree({ effort, nombre, execution, fils, n })` | durée probable d’une preuve, avant de calculer |
|
|
281
|
+
| `executionPrevue(options)` | exécution que `resoudre` emploiera avec ces options |
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { estimerDuree, executionPrevue, nPourMemoire, resoudre } from 'pow-equix-wasm'
|
|
285
|
+
|
|
286
|
+
const n = nPourMemoire(16) // 72 : 15 Mio de travail par fil
|
|
287
|
+
const execution = executionPrevue({ octets }) ?? 'jsSansJit'
|
|
288
|
+
// Un essai coûte ≈ 8 fois plus qu’à n = 60 : réduire l’effort d’autant pour la même attente.
|
|
289
|
+
const attente = estimerDuree({ effort: 1, nombre: 4, execution, n })
|
|
290
|
+
const { parts } = await resoudre({ octets, graine, effort: 1, nombre: 4, n })
|
|
291
|
+
```
|
|
95
292
|
|
|
96
293
|
### Sans WebAssembly : repli en JavaScript et mode dégradé
|
|
97
294
|
|
|
98
295
|
`pow-equix-wasm/js` fournit le même module traduit en JavaScript pur par wasm2js
|
|
99
|
-
(binaryen) : mêmes preuves, octet pour octet,
|
|
100
|
-
|
|
296
|
+
(binaryen) : mêmes preuves, octet pour octet, pour tous les n, mais bien plus
|
|
297
|
+
lent (il interprète toujours HashX : compiler demanderait `eval`). Il pèse
|
|
298
|
+
≈ 620 Ko bruts, ≈ 150 Ko minifié (37 Ko compressé) : il ne doit être
|
|
299
|
+
téléchargé que s’il sert. Façon recommandée : le confier à `resoudre` par
|
|
300
|
+
`chargerJs`, un chargeur que le paquet n’appelle que si WebAssembly est
|
|
301
|
+
indisponible **ou échoue** (compilation ou instanciation refusées, par exemple
|
|
302
|
+
par une politique sans `'wasm-unsafe-eval'`, mémoire insuffisante, Web Worker
|
|
303
|
+
en échec avant tout résultat). Le calcul reprend alors avec le moteur
|
|
304
|
+
JavaScript ; une annulation, elle, ne déclenche jamais le repli.
|
|
101
305
|
|
|
102
306
|
```ts
|
|
103
307
|
import { estimerDuree, ralentissement, resoudre, webAssemblyDisponible } from 'pow-equix-wasm'
|
|
104
308
|
|
|
105
|
-
|
|
106
|
-
const js = wasm ? undefined : (await import('pow-equix-wasm/js')).creerExportsEquixJs
|
|
107
|
-
if (!wasm) {
|
|
309
|
+
if (!webAssemblyDisponible()) {
|
|
108
310
|
// Sans WebAssembly, le JIT est en général coupé aussi : hypothèse prudente.
|
|
109
311
|
const attente = estimerDuree({ effort: 1, nombre: 4, execution: 'jsSansJit' })
|
|
110
312
|
if (attente > 5_000) avertir(`Active WebAssembly pour valider environ ${Math.round(ralentissement('jsSansJit'))} fois plus vite.`)
|
|
111
313
|
}
|
|
112
|
-
const resultat = await resoudre({
|
|
113
|
-
|
|
314
|
+
const resultat = await resoudre({
|
|
315
|
+
octets, graine, effort: 1, nombre: 4, onProgression,
|
|
316
|
+
chargerJs: () => import('pow-equix-wasm/js'), // téléchargé seulement en cas de besoin
|
|
317
|
+
})
|
|
318
|
+
// resultat.moteur vaut 'js' en mode dégradé, et resultat.repli en dit la raison :
|
|
319
|
+
// { raison: 'indisponible' } ou { raison: 'echec', message } (aussi dans la progression).
|
|
114
320
|
```
|
|
115
321
|
|
|
322
|
+
Le bundle principal (`dist/index.js`) n’importe jamais statiquement le moteur
|
|
323
|
+
JavaScript ni le module en base64 (un test le vérifie) : c’est le `import()`
|
|
324
|
+
dynamique de `chargerJs` qui en fait un morceau à part pour le bundler. Qui
|
|
325
|
+
l’importe déjà lui-même peut passer la fabrique par `js: creerExportsEquixJs`.
|
|
326
|
+
Côté client, `ModuleEquix.charger({ octets, chargerJs })` donne de même un
|
|
327
|
+
module pour vérifier, WebAssembly d’abord.
|
|
328
|
+
|
|
116
329
|
`moteur: 'wasm' | 'js' | 'auto'` impose ou laisse choisir le moteur ;
|
|
117
330
|
`moteurRetenu` dit à l’avance lequel servira. `ModuleEquix.instancier(octets)`
|
|
118
331
|
et `ModuleEquix.depuisJs(creerExportsEquixJs)` donnent chacun un module qui
|
|
119
|
-
résout et
|
|
332
|
+
résout (`essayer`, interprété, et `essayerCompile`, compilé quand c’est
|
|
333
|
+
possible) et vérifie (`verifier(graine, parts, effort, nombre, n = 60)`).
|
|
120
334
|
|
|
121
|
-
| Un cœur, portable x86-64 | Un essai | Ralentissement |
|
|
335
|
+
| Un cœur, portable x86-64, n = 60 | Un essai | Ralentissement |
|
|
122
336
|
|---|---|---|
|
|
123
|
-
| WebAssembly (
|
|
124
|
-
|
|
|
125
|
-
| JavaScript
|
|
337
|
+
| WebAssembly, programmes HashX compilés (par défaut) | ≈ 35 ms (Chromium), 50 ms (Bun) | × 1 |
|
|
338
|
+
| WebAssembly, programmes HashX interprétés | ≈ 430 ms (Chromium, Bun) | ≈ × 12 |
|
|
339
|
+
| JavaScript avec JIT | ≈ 3,8 s (Chromium), 1,5 s (Node, Bun) | × 40 à × 110 |
|
|
340
|
+
| JavaScript sans JIT (Node `--jitless`) | ≈ 76 s | ≈ × 2 000 |
|
|
126
341
|
|
|
127
342
|
Désactiver WebAssembly s’accompagne presque toujours d’un JavaScript sans JIT
|
|
128
343
|
(Tor Browser en mode renforcé, mode Isolement d’iOS) : prévoir un avertissement
|
|
@@ -132,20 +347,59 @@ et une difficulté réaliste pour ces personnes.
|
|
|
132
347
|
|
|
133
348
|
```ts
|
|
134
349
|
import { readFile } from 'node:fs/promises'
|
|
135
|
-
import { ModuleEquix, construireGraine, depuisHexadecimal } from 'pow-equix-wasm'
|
|
350
|
+
import { ModuleEquix, construireGraine, depuisHexadecimal, tailleMaxPreuve } from 'pow-equix-wasm'
|
|
136
351
|
|
|
137
352
|
const module = await ModuleEquix.instancier(await readFile(new URL(import.meta.resolve('pow-equix-wasm/equix.wasm'))))
|
|
138
353
|
const graine = await construireGraine('mon-service/1', JSON.stringify(requeteSansPreuve))
|
|
139
|
-
const
|
|
354
|
+
const octets = depuisHexadecimal(preuve) ?? new Uint8Array()
|
|
355
|
+
// Refuser une entrée trop longue avant tout calcul, puis vérifier avec les mêmes effort, nombre et n que le solveur.
|
|
356
|
+
const valide = octets.length <= tailleMaxPreuve(60, 4) && module.verifier(graine, octets, 1, 4, 60)
|
|
357
|
+
// Détection de rejeu : l’empreinte de `octets` suffit, une preuve n’ayant qu’une forme d’octets.
|
|
140
358
|
```
|
|
141
359
|
|
|
360
|
+
La vérification coûte ≈ 0,2 ms par part quel que soit n : le programme HashX
|
|
361
|
+
du défi, puis huit évaluations interprétées et les contrôles de l’arbre.
|
|
362
|
+
|
|
142
363
|
Une page qui ne peut rien télécharger (ouverte en `file://`, script unique)
|
|
143
364
|
intègre le module en base64 avec `octetsEquix()` de `pow-equix-wasm/octets`.
|
|
144
365
|
|
|
145
366
|
Ce qui reste à la charge de l’appelant, et que le paquet ne fait pas :
|
|
146
|
-
refuser les preuves rejouées
|
|
367
|
+
refuser les preuves rejouées (l’empreinte des octets de la preuve y suffit,
|
|
368
|
+
chaque preuve n’ayant qu’une forme ; ou ses compteurs, `module.compteurs`), vérifier la fraîcheur de la requête (horodatage
|
|
147
369
|
dans le contenu haché) et limiter le débit des vérifications.
|
|
148
370
|
|
|
371
|
+
## Mesures
|
|
372
|
+
|
|
373
|
+
Portable AMD Ryzen 7 4700U (x86-64), session de bureau active, un cœur par
|
|
374
|
+
essai ; Chromium 153 sans interface, Bun 1.4.0, Rust 1.93.1 en natif
|
|
375
|
+
(`--release`). Quelques essais par ligne aux grands n : compter ± 10 %.
|
|
376
|
+
`bun run mesure`, `bun run mesure:navigateur` et
|
|
377
|
+
`cargo run --release -p pow-equix [--features compilateur] --example mesure`
|
|
378
|
+
les reproduisent.
|
|
379
|
+
|
|
380
|
+
| 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 |
|
|
381
|
+
|---|---|---|---|---|---|---|---|---|---|
|
|
382
|
+
| 60 | 1,8 Mio | 3,1 Mio | 68 o | 442 / 427 ms | 34 / 49 ms | 21 ms | 490 ms | × 1,6 | 0,18 ms |
|
|
383
|
+
| 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 |
|
|
384
|
+
| 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 |
|
|
385
|
+
| 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 |
|
|
386
|
+
| 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 |
|
|
387
|
+
| 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 |
|
|
388
|
+
|
|
389
|
+
- Avant la compilation, l’écart entre le navigateur et un attaquant natif
|
|
390
|
+
était de ≈ × 20 (430 ms contre 21 ms à n = 60) ; il est maintenant de
|
|
391
|
+
× 1,4 à × 1,8 à tous les n.
|
|
392
|
+
- Détail d’un essai compilé à n = 60 dans Chromium : préparation (programme
|
|
393
|
+
HashX, en Rust) 0,3 ms, génération du module 1,5 ms, compilation et
|
|
394
|
+
instanciation 0,9 ms, remplissage de la table 24 ms, recherche 8 ms. En
|
|
395
|
+
natif : table 15 ms, recherche 6 ms. À n = 80 : remplissage 766 ms et
|
|
396
|
+
recherche 511 ms dans Chromium, contre 472 ms et 249 ms en natif.
|
|
397
|
+
- Appeler le module compilé par tranches (2 048 éléments par appel) plutôt
|
|
398
|
+
qu’en une fois ne change rien sous V8, et gagne ≈ 20 % sous Bun
|
|
399
|
+
(JavaScriptCore) : c’est le réglage retenu (`TRANCHE_REMPLISSAGE`).
|
|
400
|
+
- La mémoire réelle du module est celle de sa mémoire linéaire après un essai :
|
|
401
|
+
mémoire de travail du solveur plus ≈ 1,3 Mio (pile, tas, tampon).
|
|
402
|
+
|
|
149
403
|
## Construction reproductible
|
|
150
404
|
|
|
151
405
|
Rien de construit n’est versionné : `dist/` et `site/` sont produits par la CI.
|
|
@@ -162,8 +416,10 @@ Rien de construit n’est versionné : `dist/` et `site/` sont produits par la C
|
|
|
162
416
|
|
|
163
417
|
```sh
|
|
164
418
|
bun install
|
|
165
|
-
bun run check
|
|
166
|
-
bun run demo
|
|
419
|
+
bun run check # tests Rust et TS, construction, démo, reproductibilité
|
|
420
|
+
bun run demo # construit puis sert la démo sur http://127.0.0.1:4600/
|
|
421
|
+
bun run mesure # temps d’un essai par n, interprété et compilé, sous Bun
|
|
422
|
+
bun run mesure:navigateur # la même chose dans Chromium sans interface
|
|
167
423
|
```
|
|
168
424
|
|
|
169
425
|
Il faut Rust (via rustup, qui installe seul la version figée et la cible
|
|
@@ -179,17 +435,23 @@ Il faut Rust (via rustup, qui installe seul la version figée et la cible
|
|
|
179
435
|
|
|
180
436
|
## Organisation
|
|
181
437
|
|
|
182
|
-
- `crates/pow-equix` : défi,
|
|
183
|
-
natif (option `compilateur` pour générer du code
|
|
184
|
-
dans une application Tauri).
|
|
438
|
+
- `crates/pow-equix` : défi, solveur et vérificateur Equihash(n, 3) sur HashX
|
|
439
|
+
en Rust ; réutilisable en natif (option `compilateur` pour générer du code
|
|
440
|
+
machine HashX, par exemple dans une application Tauri).
|
|
441
|
+
- `crates/hashx` : copie de la crate `hashx` 0.9.1 d’Arti (paquet `pow-hashx`),
|
|
442
|
+
identique à l’original sauf `src/expose.rs`, qui expose le programme
|
|
443
|
+
généré, et deux ajustements de visibilité (voir son `Cargo.toml`).
|
|
185
444
|
- `crates/pow-equix-wasm` : interface C minimale vers WebAssembly, sans
|
|
186
445
|
wasm-bindgen.
|
|
187
446
|
- `src/index.ts` : chargeur, deux moteurs, Web Workers, annulation,
|
|
188
|
-
progression, estimations, vérification.
|
|
189
|
-
|
|
447
|
+
progression, estimations, vérification ; `src/compilation.ts` : génération
|
|
448
|
+
des modules WebAssembly qui évaluent les programmes HashX.
|
|
449
|
+
- `demo/` : page de démo et de calibrage ; `scripts/mesurer.ts` : mesures
|
|
450
|
+
sous Bun et dans Chromium.
|
|
190
451
|
|
|
191
452
|
## Licence
|
|
192
453
|
|
|
193
|
-
LGPL-3.0 (voir `LICENSE` et `COPYING`). Le module intègre
|
|
194
|
-
`hashx` du projet [Arti](https://gitlab.torproject.org/tpo/core/arti) de Tor
|
|
195
|
-
|
|
454
|
+
LGPL-3.0 (voir `LICENSE` et `COPYING`). Le module intègre une copie de la crate
|
|
455
|
+
`hashx` du projet [Arti](https://gitlab.torproject.org/tpo/core/arti) de Tor
|
|
456
|
+
(`crates/hashx`), elle-même sous LGPL-3.0, et son solveur reprend celui de la
|
|
457
|
+
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>;
|
package/dist/empreinte.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.3.0",
|
|
3
3
|
"rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)",
|
|
4
|
-
"octets":
|
|
5
|
-
"sha256": "
|
|
4
|
+
"octets": 63422,
|
|
5
|
+
"sha256": "f91b4278f5046fd61e0f01fa961060c8d763620c9e5bc67f6c121b90bd51806a",
|
|
6
6
|
"js": {
|
|
7
7
|
"binaryen": "132.0.0",
|
|
8
|
-
"octets":
|
|
9
|
-
"sha256": "
|
|
8
|
+
"octets": 618386,
|
|
9
|
+
"sha256": "fcc607c4e96af8e81297ab7204ff2219bed70cdc95cb3dadded13c3e9c9185e2"
|
|
10
10
|
}
|
|
11
11
|
}
|