diff --git a/.gitignore b/.gitignore
index 7227d6ef0fb440eddc6643cc6e38a76cafe55755..f8edd6e05d25e9969f82ca5b2462078886b49d95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 *generated*
 node_modules/
 .idea/
+vendors/
diff --git a/build.js b/build.js
new file mode 100644
index 0000000000000000000000000000000000000000..3eed99b8997d198b97fe698e3a0f883a008c414e
--- /dev/null
+++ b/build.js
@@ -0,0 +1,14 @@
+const fs = require("fs");
+try{fs.rmdirSync("public/vendors");} catch (e) {console.error(e);}
+try{fs.mkdirSync("public/vendors");} catch (e) {console.error(e);}
+try{fs.copyFileSync("node_modules/tweetnacl/nacl-fast.js","public/vendors/nacl.js");} catch (e) {console.error(e);}
+try{fs.copyFileSync("node_modules/scrypt-async-modern/src/index.js","public/vendors/scrypt.js");} catch (e) {console.error(e);}
+try{fs.writeFileSync("public/vendors/nacl.js",(fs.readFileSync("public/vendors/nacl.js","utf8"))
+			.replace("(function(nacl) {","var nacl = {};")
+			.replace("})(typeof module !== 'undefined' && module.exports ? module.exports : (self.nacl = self.nacl || {}));","export {nacl};")
+		,"utf8");} catch (e) {console.error(e);}
+// let js = fs.readFileSync("node_modules/jparticles/production/jparticles.js","utf8");
+// js += fs.readFileSync("node_modules/jparticles/production/particle.js","utf8");
+// js += fs.readFileSync("main.js","utf8");
+// fs.writeFileSync("main.js",js,"utf8");
+// fs.writeFileSync("index.html",(fs.readFileSync("index.html","utf8")).replace(/(.*node_modules.*)/g,''),"utf8");
diff --git a/package.json b/package.json
index 1ad60e1018dde291d0d0c3756a98e6d525bf4673..bac24a1ce948e71775790d6fb7acf1295077a039 100644
--- a/package.json
+++ b/package.json
@@ -4,12 +4,20 @@
   "description": "Ǧ'Perdu l'accès à mon compte, Ǧ'spère le retrouver",
   "main": "public/index.html",
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "postinstall": "node build.js",
+    "test": "echo open public/test.html in your browser"
   },
   "repository": {
     "type": "git",
     "url": "https://git.duniter.org/clients/Gsper.git"
   },
   "author": "[1000i100] Millicent Billette <git@1000i100.fr> (https://1forma-tic.fr/)",
-  "license": "AGPL-3.0"
+  "license": "AGPL-3.0",
+  "dependencies": {
+    "scrypt-async-modern": "^3.0.6",
+    "tweetnacl": "^1.0.0"
+  },
+  "devDependencies": {
+    "jasmine-core": "^3.1.0"
+  }
 }
diff --git a/public/crypto.js b/public/crypto.js
new file mode 100644
index 0000000000000000000000000000000000000000..c483ce12ef622d501be66b193729573ba6693a8d
--- /dev/null
+++ b/public/crypto.js
@@ -0,0 +1,91 @@
+export {b58, saltPass2seed, seed2keyPair,idSecPass2rawAll, raw2b58, idSecPass2cleanKeys}
+import {nacl} from "./vendors/nacl.js";
+
+async function idSecPass2rawAll(idSec,pass) {
+	const rawSeed = await saltPass2seed(idSec,pass);
+	const keyPair = seed2keyPair(rawSeed);
+	return {
+		seed:rawSeed,
+		publicKey:keyPair.publicKey,
+		secretKey:keyPair.secretKey
+	}
+}
+function raw2b58(raws){
+	const res = {};
+	for(let r in raws) res[r] = b58.encode(raws[r]);
+	return res;
+}
+async function idSecPass2cleanKeys(idSec,pass){
+	const raw = await idSecPass2rawAll(idSec,pass);
+	return Object.assign(raw2b58(raw),{idSec,password:pass});
+}
+function seed2keyPair(seed){
+	return nacl.sign.keyPair.fromSeed(seed);
+}
+import scrypt from "./vendors/scrypt.js";
+async function saltPass2seed(idSec,pass) {
+	const options = {
+		logN: 12,
+		r: 16,
+		p: 1,
+		//dkLen: 32,
+		encoding: 'binary'
+	};
+	return await scrypt(pass.normalize('NFKC'), idSec.normalize('NFKC'), options);
+}
+//inspired by bs58 and base-x module
+const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+const b58 = basex(ALPHABET);
+function basex (ALPHABET) {
+	const ALPHABET_MAP = {};
+	const BASE = ALPHABET.length;
+	const LEADER = ALPHABET.charAt(0);
+	// pre-compute lookup table
+	for (let z = 0; z < ALPHABET.length; z++) {
+		let x = ALPHABET.charAt(z);
+		if (ALPHABET_MAP[x] !== undefined) throw new TypeError(x + ' is ambiguous');
+		ALPHABET_MAP[x] = z;
+	}
+	function encode (source) {
+		if (source.length === 0) return '';
+		const digits = [0];
+		for (let i = 0; i < source.length; ++i) {
+			let carry = source[i];
+			for (let j = 0; j < digits.length; ++j) {
+				carry += digits[j] << 8;
+				digits[j] = carry % BASE;
+				carry = (carry / BASE) | 0;
+			}
+			while (carry > 0) { digits.push(carry % BASE); carry = (carry / BASE) | 0; }
+		}
+		let string = '';
+		for (let k = 0; source[k] === 0 && k < source.length - 1; ++k) string += LEADER; // deal with leading zeros
+		for (let q = digits.length - 1; q >= 0; --q) string += ALPHABET[digits[q]]; // convert digits to a string
+		return string;
+	}
+
+	function decodeUnsafe (string) {
+		if (typeof string !== 'string') throw new TypeError('Expected String');
+		if (string.length === 0) return new Uint8Array(0);
+		const bytes = [0];
+		for (let i = 0; i < string.length; i++) {
+			const value = ALPHABET_MAP[string[i]];
+			if (value === undefined) return ;
+			let carry = value;
+			for (let j = 0; j < bytes.length; ++j) {
+				carry += bytes[j] * BASE;
+				bytes[j] = carry & 0xff;
+				carry >>= 8;
+			}
+			while (carry > 0) { bytes.push(carry & 0xff); carry >>= 8; }
+		}
+		for (let k = 0; string[k] === LEADER && k < string.length - 1; ++k)  bytes.push(0); // deal with leading zeros
+		return new Uint8Array(bytes.reverse());
+	}
+	function decode (string) {
+		const buffer = decodeUnsafe(string);
+		if (buffer) return buffer;
+		throw new Error('Non-base' + BASE + ' character')
+	}
+	return { encode, decodeUnsafe, decode }
+}
diff --git a/public/crypto.test.js b/public/crypto.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..95830da1620bc8051ffa7ca679d84e06d8feca1f
--- /dev/null
+++ b/public/crypto.test.js
@@ -0,0 +1,33 @@
+const idSec = "a";
+const mdp = "b";
+//base58
+const pubKey = "AoxVA41dGL2s4ogMNdbCw3FFYjFo5FPK36LuiW1tjGbG";
+const secretKey = "3ZsmZhnRv137dS1s7Q3jFGKLTDyhkwguPHfnWBxzDCTTHKWGnYw9zBk3gcCUJCc72TEUuyzM7cqpo7c5LYhs1Qtv";
+const seed = "9eADqX8V6VcPdJCHCVYiE1Vnift9nFNrvr9aTaXA5RJc";
+
+import * as app from "./crypto.js";
+describe('crypto', () => {
+	it('b58 should decode/encode well', () => {
+		expect(app.b58.encode(app.b58.decode(pubKey))).toEqual(pubKey);
+	});
+	it('saltPass2seed should convert salt & password to seed with scrypt', async () => {
+		expect(app.b58.encode(await app.saltPass2seed(idSec,mdp))).toEqual(seed);
+	});
+	it('seed2keyPair should generate public and private key nacl/sodium way.', async () => {
+		const rawSeed = app.b58.decode(seed);
+		const rawKeyPair = app.seed2keyPair(rawSeed);
+		expect(app.b58.encode(rawKeyPair.publicKey)).toEqual(pubKey);
+		expect(app.b58.encode(rawKeyPair.secretKey)).toEqual(secretKey);
+	});
+	it('idSecPass2cleanKeys should output clean base58 keys and seed', async () => {
+		const res = await app.idSecPass2cleanKeys(idSec,mdp);
+		expect(res.publicKey).toEqual(pubKey);
+		expect(res.secretKey).toEqual(secretKey);
+		expect(res.seed).toEqual(seed);
+		expect(res.idSec).toEqual(idSec);
+		expect(res.password).toEqual(mdp);
+	});
+
+});
+
+
diff --git a/public/index.html b/public/index.html
index bbf0665aace84fc9ab0dd2403a0b5444e9dc40a5..1c0ea73b4d71a99b568eda5d488e6329b09ec716 100644
--- a/public/index.html
+++ b/public/index.html
@@ -13,50 +13,7 @@
     <meta property="og:url" content="https://gsper.duniter.io/" />
     <meta property="og:image" content="https://gsper.duniter.io/img/gsper-logo.png" />
     <meta property="og:description" content="Ǧ'Perdu l'accès à mon compte, Ǧ'spère le retrouver ! ǦéDéClé à tester !" />
-    <style>
-        * {font-family: sans-serif; box-sizing: border-box; margin: 0; padding: 0}
-        h1{font-weight: normal; width: 100%; text-align: center; line-height: 70px;}
-        input[type="text"],textarea{border: 0; padding: 5px;}
-        legend {margin-left: 10px; padding: 0 5px}
-        em{font-size: 70%}
-        .pasEncore{display: none}
-        fieldset, #info {margin: 10px;}
-        header, #lists, header fieldset{
-            display: flex;
-            flex-wrap: wrap;
-        }
-        header fieldset {flex: 8; margin-top: 0; display: block;}
-        header #info {flex: 2}
-        header fieldset label, header fieldset input { padding: 5px;line-height: 60px; font-size: 16px;}
-        header fieldset label {white-space: nowrap; flex: 1;}
-        header fieldset input {flex: 99; width: calc(100% - 130px);}
-        #lists fieldset {flex: 1}
-        textarea {height: 100%; width: 100%;}
-        button {width: 100%; height: 32px}
-        header {height: 180px;position: fixed;top:0;left:0;right: 0;}
-        #lists{position: fixed;top:180px;left:0;right: 0;bottom: 0;}
-
-        h1{padding-left: 3em; margin-top: 10px;}
-        .logo {height: 3em; position: absolute; margin: -0.6em -2.2em;}
-        .g1june {height: 1.2em; vertical-align: text-top;}
-
-        a {color: #666; text-decoration-style: dotted; text-decoration-color: #ccc;}
-        a:hover {color: #666; text-decoration-style: solid; text-decoration-color: #ccc;}
-
-        #resultat {
-            position: fixed;
-            z-index: 2;
-            top: 50%;
-            left: 50%;
-            width: 1000px;
-            margin-left: -500px;
-            height: 200px;
-            margin-top: -100px;
-            background-color: #efe;
-            padding: 20px;
-            border: 2px groove grey;
-        }
-    </style>
+    <link href="style.css" rel="stylesheet"/>
 </head>
 <body>
 <header>
@@ -105,7 +62,6 @@ password
         </style>
     </a>
 </footer>
-<script src="https://paperwallet.duniter.io/lib/duniter_tools.js"></script>
-<script src="main.js"></script>
+<script type="module" src="main.js"></script>
 </body>
 </html>
diff --git a/public/main.js b/public/main.js
index 4742f2f35b6d4bf2e6bcef98668a8fb547bac7fb..4ca9724a884b2dbeee4ae0aac0f9ed33d0ac7fa3 100644
--- a/public/main.js
+++ b/public/main.js
@@ -1,4 +1,5 @@
 const cores = navigator.hardwareConcurrency;
+import * as crypto from "./crypto.js";
 
 addEventsListeners(document.querySelectorAll("#salt, #pass"),"change keyup",updateEstimate);
 updateEstimate();
@@ -8,18 +9,19 @@ function updateEstimate(){
 	document.getElementById("combi").innerHTML = idSecList.length*passList.length;
 	document.getElementById("temps").innerHTML = (idSecList.length*passList.length)+"s";
 }
-document.getElementById("compute").addEventListener("click",e=>{
+document.getElementById("compute").addEventListener("click", async e=>{
 	const pub = document.getElementById("pubkey").value.trim();
-	const pubRaw = Base58.decode(pub);
+	const pubRaw = crypto.b58.decode(pub);
 	const idSecList = multiLineString2cleanArray(document.getElementById("salt").value);
 	const passList = multiLineString2cleanArray(document.getElementById("pass").value);
+	const combi = idSecList.length*passList.length;
 	updateEstimate();
 	document.getElementById("percent").innerHTML = "0%";
-
+	let count = 0;
 	for(let idSec of idSecList){
 		for (let pass of passList){
-			const seed = generate_seed_from_scrypt(idSec,pass)
-			if(pub === short_publickey_from_seed(seed)){
+			const keys = await crypto.idSecPass2cleanKeys(idSec,pass);
+			if(pub === keys.publicKey){
 				const resArea = document.getElementById("resultat");
 				resArea.classList.remove("pasEncore");
 				resArea.innerHTML = `<h2>Ǧ'trouvé ! ǦéLéClé !</h2><br/>
@@ -27,43 +29,22 @@ document.getElementById("compute").addEventListener("click",e=>{
 				    Mot de passe : ${pass}<br/>
 				    -- Avancé --<br/>
 				    Clef publique : ${pub}<br/>
-				    Clef privée : ${privatekey_from_seed(seed)}<br/>
-				    Graine : ${Base58.encode(seed)}<br/>
+				    Clef privée : ${keys.secretKey}<br/>
+				    Graine : ${keys.seed}<br/>
 				`;
 				document.getElementById("percent").innerHTML = "Trouvé :)";
 				return;
 			}
+			count++;
+			document.getElementById("percent").innerHTML = `${Math.round(10000*count/combi)/100}%`;
 		}
 	}
 	document.getElementById("percent").innerHTML = "Fini sans résultats";
 });
-
-
-async function main(){
-	console.log(short_publickey_from_seed(generate_seed_from_scrypt("a","b")));
-}
-main();
-
-async function crypt(password, salt){
-	return new Promise( (resolve ,reject) => {
-		const options = {
-			logN: 12,
-			r: 16,
-			p: 1,
-			//dkLen: 32,
-			encoding: 'binary'
-		};
-		scrypt(password, salt, options, (res)=>{
-			resolve(res);
-		});
-	});
-
-}
 function addEventsListeners(triggerNodes,events,functions){
 	if(!triggerNodes.length) triggerNodes = [triggerNodes];
 	if(typeof events !== "object") events = events.split(" ");
 	if(typeof functions !== "object") functions = [functions];
-	console.log(triggerNodes[0].addEventListener, events, functions);
 	for(let n of triggerNodes) events.forEach(e=> functions.forEach(f=>n.addEventListener(e,f)));
 }
 function multiLineString2cleanArray(rawStr){ return rawStr.split("\n").map(str=>str.trim()).filter(str=>str !== ""); }
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..ddcd11b3ee112f5b3bf5fa538f11b0acdd0bc24f
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,42 @@
+* {font-family: sans-serif; box-sizing: border-box; margin: 0; padding: 0}
+h1{font-weight: normal; width: 100%; text-align: center; line-height: 70px;}
+input[type="text"],textarea{border: 0; padding: 5px;}
+legend {margin-left: 10px; padding: 0 5px}
+em{font-size: 70%}
+.pasEncore{display: none}
+fieldset, #info {margin: 10px;}
+header, #lists, header fieldset{
+    display: flex;
+    flex-wrap: wrap;
+}
+header fieldset {flex: 8; margin-top: 0; display: block;}
+header #info {flex: 2}
+header fieldset label, header fieldset input { padding: 5px;line-height: 60px; font-size: 16px;}
+header fieldset label {white-space: nowrap; flex: 1;}
+header fieldset input {flex: 99; width: calc(100% - 130px);}
+#lists fieldset {flex: 1}
+textarea {height: 100%; width: 100%;}
+button {width: 100%; height: 32px}
+header {height: 180px;position: fixed;top:0;left:0;right: 0;}
+#lists{position: fixed;top:180px;left:0;right: 0;bottom: 0;}
+
+h1{padding-left: 3em; margin-top: 10px;}
+.logo {height: 3em; position: absolute; margin: -0.6em -2.2em;}
+.g1june {height: 1.2em; vertical-align: text-top;}
+
+a {color: #666; text-decoration-style: dotted; text-decoration-color: #ccc;}
+a:hover {color: #666; text-decoration-style: solid; text-decoration-color: #ccc;}
+
+#resultat {
+    position: fixed;
+    z-index: 2;
+    top: 50%;
+    left: 50%;
+    width: 1000px;
+    margin-left: -500px;
+    height: 200px;
+    margin-top: -100px;
+    background-color: #efe;
+    padding: 20px;
+    border: 2px groove grey;
+}
diff --git a/public/test.html b/public/test.html
new file mode 100644
index 0000000000000000000000000000000000000000..bec82767c065dda5a3fa8c7664bdb663851a76f0
--- /dev/null
+++ b/public/test.html
@@ -0,0 +1,9 @@
+<meta charset="utf-8" />
+<link rel="shortcut icon" type="image/png" href="../node_modules/jasmine-core/images/jasmine_favicon.png">
+<link rel="stylesheet" type="text/css" href="../node_modules/jasmine-core/lib/jasmine-core/jasmine.css">
+
+<script type="text/javascript" src="../node_modules/jasmine-core/lib/jasmine-core/jasmine.js"></script>
+<script type="text/javascript" src="../node_modules/jasmine-core/lib/jasmine-core/jasmine-html.js"></script>
+<script type="text/javascript" src="../node_modules/jasmine-core/lib/jasmine-core/boot.js"></script>
+
+<script type="module" src="crypto.test.js"></script>