@mybucks.online/core 2.0.0 → 2.2.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 +16 -3
- package/index.js +16 -234
- package/package.json +3 -3
- package/src/credentials.js +137 -0
- package/src/random.js +58 -0
- package/src/token.js +120 -0
- package/test/index.test.js +46 -0
- package/test/random.test.js +84 -0
package/README.md
CHANGED
|
@@ -85,6 +85,19 @@ console.log("Network: ", network);
|
|
|
85
85
|
console.log("Legacy token: ", legacy); // true if token was in legacy format
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
### 4. Generate random credentials
|
|
89
|
+
|
|
90
|
+
`randomPassphrase` and `randomPIN` generate cryptographically random credentials that are guaranteed to pass the zxcvbn strength thresholds required by `generateHash` (`PASSPHRASE_MIN_ZXCVBN_SCORE = 3`, `PIN_MIN_ZXCVBN_SCORE = 1`).
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
import { randomPassphrase, randomPIN } from "@mybucks.online/core";
|
|
94
|
+
|
|
95
|
+
const passphrase = randomPassphrase(); // e.g. "Ax3!-bQ2#-mK7@-zP1$"
|
|
96
|
+
const pin = randomPIN(); // e.g. "k4r9w2"
|
|
97
|
+
|
|
98
|
+
const hash = await generateHash(passphrase, pin, showProgress);
|
|
99
|
+
```
|
|
100
|
+
|
|
88
101
|
## Changes (default vs legacy)
|
|
89
102
|
|
|
90
103
|
To make the wallet more secure and resilient against attacks, and to meet standards and follow best practices (e.g. NIST SP 800-132, OWASP, RFC 7914), we introduced a new version that is now the default. A **`legacy`** flag is available for backward compatibility with existing wallets and tokens.
|
|
@@ -99,7 +112,7 @@ To make the wallet more secure and resilient against attacks, and to meet standa
|
|
|
99
112
|
|
|
100
113
|
**Token generation (default)**
|
|
101
114
|
- Legacy encoded the transfer-link token by **concatenating** passphrase, pin and network with a delimiter, which is ambiguous for some inputs.
|
|
102
|
-
- The default uses **
|
|
115
|
+
- The default uses a **compact length-prefixed** payload (version byte + lengths + UTF-8 bytes) so there is no concatenation ambiguity and the URL fragment stays short.
|
|
103
116
|
- `parseToken` accepts both legacy and default token formats automatically and returns `[passphrase, pin, network, legacy]`, where `legacy` is `true` if the token was in legacy format.
|
|
104
117
|
|
|
105
118
|
Use `generateHash(passphrase, pin, cb, true)` or `generateToken(passphrase, pin, network, true)` only when you need to match existing legacy wallets or tokens.
|
|
@@ -119,6 +132,6 @@ Find the docs [here](https://docs.mybucks.online).
|
|
|
119
132
|
- https://app.mybucks.online
|
|
120
133
|
passphrase: **DemoAccount5&**
|
|
121
134
|
PIN: **112324**
|
|
122
|
-
- https://app.mybucks.online/#wallet=
|
|
123
|
-
- https://app.mybucks.online/#wallet=
|
|
135
|
+
- https://app.mybucks.online/#wallet=Db1zfXAg1EZW1vQWNjb3VudDUmBjExMjMyNAhvcHRpbWlzbQ==mlUEbO (default)
|
|
136
|
+
- https://app.mybucks.online/#wallet=VWnsSGRGVtb0FjY291bnQ1JgIxMTIzMjQCb3B0aW1pc20=_wNovT (legacy)
|
|
124
137
|
- https://codesandbox.io/p/sandbox/mybucks-online-key-generation-sandbox-lt53c3
|
package/index.js
CHANGED
|
@@ -1,234 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
r: 8, // block size parameter
|
|
18
|
-
p: 5, // parallelization parameter
|
|
19
|
-
keyLen: 64,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const HASH_OPTIONS = {
|
|
23
|
-
N: 131072, // CPU/memory cost parameter, 2^17, OWASP recommendation
|
|
24
|
-
r: 8, // block size parameter
|
|
25
|
-
p: 1, // parallelization parameter
|
|
26
|
-
keyLen: 64,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export const PASSPHRASE_MIN_LENGTH = 12;
|
|
30
|
-
export const PASSPHRASE_MAX_LENGTH = 128;
|
|
31
|
-
export const PIN_MIN_LENGTH = 6;
|
|
32
|
-
export const PIN_MAX_LENGTH = 16;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* This function computes the scrypt hash using provided passphrase and pin inputs.
|
|
36
|
-
* Passphrase and pin are validated by length (see PASSPHRASE_MIN/MAX_LENGTH, PIN_MIN/MAX_LENGTH) and zxcvbn; invalid or weak values are rejected (returns "").
|
|
37
|
-
*
|
|
38
|
-
* @param {string} passphrase - Length in [PASSPHRASE_MIN_LENGTH, PASSPHRASE_MAX_LENGTH], zxcvbn score >= 3
|
|
39
|
-
* @param {string} pin - Length in [PIN_MIN_LENGTH, PIN_MAX_LENGTH], zxcvbn score >= 1
|
|
40
|
-
* @param {*} cb a callback function designed to receive the progress updates during the scrypt hashing process.
|
|
41
|
-
* @param {boolean} legacy - when true, uses the legacy behavior (HASH_OPTIONS_LEGACY and the original salt generation using the last 4 characters of passphrase plus the full pin); when false, uses HASH_OPTIONS and a keccak256-based salt derived from ABI-encoding passphrase and pin
|
|
42
|
-
* @returns hash result as string format, or "" if passphrase/pin missing or too weak
|
|
43
|
-
*/
|
|
44
|
-
export async function generateHash(
|
|
45
|
-
passphrase,
|
|
46
|
-
pin,
|
|
47
|
-
cb = () => {},
|
|
48
|
-
legacy = false,
|
|
49
|
-
) {
|
|
50
|
-
if (!passphrase || !pin) {
|
|
51
|
-
return "";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const passphraseLen = passphrase.length;
|
|
55
|
-
if (
|
|
56
|
-
passphraseLen < PASSPHRASE_MIN_LENGTH ||
|
|
57
|
-
passphraseLen > PASSPHRASE_MAX_LENGTH
|
|
58
|
-
) {
|
|
59
|
-
return "";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const pinLen = pin.length;
|
|
63
|
-
if (pinLen < PIN_MIN_LENGTH || pinLen > PIN_MAX_LENGTH) {
|
|
64
|
-
return "";
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const passphraseResult = zxcvbn(passphrase);
|
|
68
|
-
if (passphraseResult.score < 3) {
|
|
69
|
-
return "";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const pinResult = zxcvbn(pin);
|
|
73
|
-
if (pinResult.score < 1) {
|
|
74
|
-
return "";
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const passwordBuffer = Buffer.from(passphrase);
|
|
78
|
-
let saltBuffer;
|
|
79
|
-
|
|
80
|
-
if (legacy) {
|
|
81
|
-
const legacySalt = `${passphrase.slice(-4)}${pin}`;
|
|
82
|
-
saltBuffer = Buffer.from(legacySalt);
|
|
83
|
-
} else {
|
|
84
|
-
const encoded = abi.encode(
|
|
85
|
-
["string", "string", "string"],
|
|
86
|
-
[KDF_DOMAIN_SEPARATOR, passphrase, pin],
|
|
87
|
-
);
|
|
88
|
-
const saltHash = ethers.keccak256(encoded);
|
|
89
|
-
saltBuffer = Buffer.from(saltHash.slice(2), "hex");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const options = legacy ? HASH_OPTIONS_LEGACY : HASH_OPTIONS;
|
|
93
|
-
|
|
94
|
-
const hashBuffer = await scrypt(
|
|
95
|
-
passwordBuffer,
|
|
96
|
-
saltBuffer,
|
|
97
|
-
options.N,
|
|
98
|
-
options.r,
|
|
99
|
-
options.p,
|
|
100
|
-
options.keyLen,
|
|
101
|
-
cb,
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
return Buffer.from(hashBuffer).toString("hex");
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* This function derives the EVM private key from a result of the scrypt hash.
|
|
109
|
-
* @param {*} hash scrypt hash result
|
|
110
|
-
* @returns private key as string format
|
|
111
|
-
*/
|
|
112
|
-
export function getEvmPrivateKey(hash) {
|
|
113
|
-
return ethers.keccak256(abi.encode(["string"], [hash]));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* This function returns the EVM wallet address from a result of the scrypt hash.
|
|
118
|
-
* @param {*} hash scrypt hash result
|
|
119
|
-
* @returns address as string format
|
|
120
|
-
*/
|
|
121
|
-
export function getEvmWalletAddress(hash) {
|
|
122
|
-
const privateKey = getEvmPrivateKey(hash);
|
|
123
|
-
const wallet = new ethers.Wallet(privateKey);
|
|
124
|
-
return wallet.address;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* This function returns the TRON wallet address from a result of the scrypt hash.
|
|
129
|
-
* @param {*} hash scrypt hash result
|
|
130
|
-
* @returns address as string format
|
|
131
|
-
*/
|
|
132
|
-
export function getTronWalletAddress(hash) {
|
|
133
|
-
const privateKey = getEvmPrivateKey(hash);
|
|
134
|
-
return TronWeb.address.fromPrivateKey(privateKey.slice(2));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const URL_DELIMITER = "\u0002";
|
|
138
|
-
const TOKEN_VERSION_ABI = 0x02;
|
|
139
|
-
|
|
140
|
-
const NETWORKS = [
|
|
141
|
-
"ethereum",
|
|
142
|
-
"polygon",
|
|
143
|
-
"arbitrum",
|
|
144
|
-
"optimism",
|
|
145
|
-
"bsc",
|
|
146
|
-
"avalanche",
|
|
147
|
-
"base",
|
|
148
|
-
"tron",
|
|
149
|
-
];
|
|
150
|
-
/**
|
|
151
|
-
* This function generates a transfer-link token by encoding passphrase, pin and network, and adding random padding.
|
|
152
|
-
* The transfer-link enables users to send their full ownership of a wallet account to another user for gifting or airdropping.
|
|
153
|
-
* Passphrase and PIN are validated by length (see PASSPHRASE_MIN/MAX_LENGTH, PIN_MIN/MAX_LENGTH) and zxcvbn; invalid or weak values are rejected (returns null).
|
|
154
|
-
* When legacy is false, payload is ABI-encoded to avoid concatenation ambiguity; when true, uses URL_DELIMITER concatenation.
|
|
155
|
-
*
|
|
156
|
-
* @param {string} passphrase - Length in [PASSPHRASE_MIN_LENGTH, PASSPHRASE_MAX_LENGTH], zxcvbn score >= 3
|
|
157
|
-
* @param {string} pin - Length in [PIN_MIN_LENGTH, PIN_MAX_LENGTH], zxcvbn score >= 1
|
|
158
|
-
* @param {string} network - ethereum | polygon | arbitrum | optimism | bsc | avalanche | base | tron
|
|
159
|
-
* @param {boolean} legacy - when true, use URL_DELIMITER concatenation; when false, use ABI encoding for the payload
|
|
160
|
-
* @returns A string formatted as a transfer-link token, which can be appended to `https://app.mybucks.online#wallet=`, or null if invalid/weak
|
|
161
|
-
*/
|
|
162
|
-
export function generateToken(passphrase, pin, network, legacy = false) {
|
|
163
|
-
if (!passphrase || !pin || !network) {
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
if (!NETWORKS.find((n) => n === network)) {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const passphraseLen = passphrase.length;
|
|
171
|
-
if (
|
|
172
|
-
passphraseLen < PASSPHRASE_MIN_LENGTH ||
|
|
173
|
-
passphraseLen > PASSPHRASE_MAX_LENGTH
|
|
174
|
-
) {
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const pinLen = pin.length;
|
|
179
|
-
if (pinLen < PIN_MIN_LENGTH || pinLen > PIN_MAX_LENGTH) {
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (zxcvbn(passphrase).score < 3) {
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
if (zxcvbn(pin).score < 1) {
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
let payloadBuffer;
|
|
191
|
-
if (legacy) {
|
|
192
|
-
payloadBuffer = Buffer.from(
|
|
193
|
-
passphrase + URL_DELIMITER + pin + URL_DELIMITER + network,
|
|
194
|
-
"utf-8",
|
|
195
|
-
);
|
|
196
|
-
} else {
|
|
197
|
-
const encoded = abi.encode(
|
|
198
|
-
["string", "string", "string"],
|
|
199
|
-
[passphrase, pin, network],
|
|
200
|
-
);
|
|
201
|
-
const encodedBuffer = Buffer.from(encoded.slice(2), "hex");
|
|
202
|
-
payloadBuffer = Buffer.concat([
|
|
203
|
-
Buffer.from([TOKEN_VERSION_ABI]),
|
|
204
|
-
encodedBuffer,
|
|
205
|
-
]);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const base64Encoded = payloadBuffer.toString("base64");
|
|
209
|
-
const padding = nanoid(12);
|
|
210
|
-
return padding.slice(0, 6) + base64Encoded + padding.slice(6);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* This function parses a transfer-link token generated by generateToken().
|
|
215
|
-
* Tokens with version byte 0x02 are ABI-decoded; otherwise payload is treated as legacy (UTF-8 + URL_DELIMITER).
|
|
216
|
-
* @param {string} token - token string returned by generateToken()
|
|
217
|
-
* @returns {[string, string, string, boolean]} [passphrase, pin, network, legacy] - legacy is true if token was in legacy format, false if ABI-encoded
|
|
218
|
-
*/
|
|
219
|
-
export function parseToken(token) {
|
|
220
|
-
const payload = token.slice(6, token.length - 6);
|
|
221
|
-
const decoded = Buffer.from(payload, "base64");
|
|
222
|
-
|
|
223
|
-
if (decoded[0] === TOKEN_VERSION_ABI) {
|
|
224
|
-
const hex = "0x" + decoded.subarray(1).toString("hex");
|
|
225
|
-
const [passphrase, pin, network] = abi.decode(
|
|
226
|
-
["string", "string", "string"],
|
|
227
|
-
hex,
|
|
228
|
-
);
|
|
229
|
-
return [passphrase, pin, network, false];
|
|
230
|
-
}
|
|
231
|
-
const str = decoded.toString("utf-8");
|
|
232
|
-
const [passphrase, pin, network] = str.split(URL_DELIMITER);
|
|
233
|
-
return [passphrase, pin, network, true];
|
|
234
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
PASSPHRASE_MIN_ZXCVBN_SCORE,
|
|
3
|
+
PIN_MIN_ZXCVBN_SCORE,
|
|
4
|
+
PASSPHRASE_MIN_LENGTH,
|
|
5
|
+
PASSPHRASE_MAX_LENGTH,
|
|
6
|
+
PIN_MIN_LENGTH,
|
|
7
|
+
PIN_MAX_LENGTH,
|
|
8
|
+
generateHash,
|
|
9
|
+
getEvmPrivateKey,
|
|
10
|
+
getEvmWalletAddress,
|
|
11
|
+
getTronWalletAddress,
|
|
12
|
+
} from "./src/credentials.js";
|
|
13
|
+
|
|
14
|
+
export { generateToken, parseToken } from "./src/token.js";
|
|
15
|
+
|
|
16
|
+
export { randomPassphrase, randomPIN } from "./src/random.js";
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mybucks.online/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Core module of Mybucks.online Crypto Wallet",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "node test/index.test.js && node test/legacy.test.js"
|
|
8
|
+
"test": "node test/index.test.js && node test/legacy.test.js && node test/random.test.js"
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"ethers": "^6.13.5",
|
|
39
39
|
"nanoid": "^5.0.9",
|
|
40
40
|
"scrypt-js": "^3.0.1",
|
|
41
|
-
"tronweb": "^6.
|
|
41
|
+
"tronweb": "^6.2.2",
|
|
42
42
|
"zxcvbn": "^4.4.2"
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Buffer } from "buffer";
|
|
2
|
+
import { ethers } from "ethers";
|
|
3
|
+
import scryptJS from "scrypt-js";
|
|
4
|
+
import { TronWeb } from "tronweb";
|
|
5
|
+
import zxcvbn from "zxcvbn";
|
|
6
|
+
|
|
7
|
+
const { scrypt } = scryptJS;
|
|
8
|
+
const abi = new ethers.AbiCoder();
|
|
9
|
+
|
|
10
|
+
// Domain separator for the default (non-legacy) KDF path to prevent
|
|
11
|
+
// cross-protocol and cross-version hash reuse.
|
|
12
|
+
const KDF_DOMAIN_SEPARATOR = "mybucks.online-core.generateHash.v2";
|
|
13
|
+
|
|
14
|
+
const HASH_OPTIONS_LEGACY = {
|
|
15
|
+
N: 32768, // CPU/memory cost parameter, 2^15
|
|
16
|
+
r: 8, // block size parameter
|
|
17
|
+
p: 5, // parallelization parameter
|
|
18
|
+
keyLen: 64,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const HASH_OPTIONS = {
|
|
22
|
+
N: 131072, // CPU/memory cost parameter, 2^17, OWASP recommendation
|
|
23
|
+
r: 8, // block size parameter
|
|
24
|
+
p: 1, // parallelization parameter
|
|
25
|
+
keyLen: 64,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const PASSPHRASE_MIN_ZXCVBN_SCORE = 3;
|
|
29
|
+
export const PIN_MIN_ZXCVBN_SCORE = 1;
|
|
30
|
+
|
|
31
|
+
export const PASSPHRASE_MIN_LENGTH = 12;
|
|
32
|
+
export const PASSPHRASE_MAX_LENGTH = 128;
|
|
33
|
+
export const PIN_MIN_LENGTH = 6;
|
|
34
|
+
export const PIN_MAX_LENGTH = 16;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* This function computes the scrypt hash using provided passphrase and pin inputs.
|
|
38
|
+
* Passphrase and pin are validated by length (see PASSPHRASE_MIN/MAX_LENGTH, PIN_MIN/MAX_LENGTH) and zxcvbn; invalid or weak values are rejected (returns "").
|
|
39
|
+
*
|
|
40
|
+
* @param {string} passphrase - Length in [PASSPHRASE_MIN_LENGTH, PASSPHRASE_MAX_LENGTH], zxcvbn score >= 3
|
|
41
|
+
* @param {string} pin - Length in [PIN_MIN_LENGTH, PIN_MAX_LENGTH], zxcvbn score >= 1
|
|
42
|
+
* @param {*} cb a callback function designed to receive the progress updates during the scrypt hashing process.
|
|
43
|
+
* @param {boolean} legacy - when true, uses the legacy behavior (HASH_OPTIONS_LEGACY and the original salt generation using the last 4 characters of passphrase plus the full pin); when false, uses HASH_OPTIONS and a keccak256-based salt derived from ABI-encoding passphrase and pin
|
|
44
|
+
* @returns hash result as string format, or "" if passphrase/pin missing or too weak
|
|
45
|
+
*/
|
|
46
|
+
export async function generateHash(
|
|
47
|
+
passphrase,
|
|
48
|
+
pin,
|
|
49
|
+
cb = () => {},
|
|
50
|
+
legacy = false,
|
|
51
|
+
) {
|
|
52
|
+
if (!passphrase || !pin) {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const passphraseLen = passphrase.length;
|
|
57
|
+
if (
|
|
58
|
+
passphraseLen < PASSPHRASE_MIN_LENGTH ||
|
|
59
|
+
passphraseLen > PASSPHRASE_MAX_LENGTH
|
|
60
|
+
) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pinLen = pin.length;
|
|
65
|
+
if (pinLen < PIN_MIN_LENGTH || pinLen > PIN_MAX_LENGTH) {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const passphraseResult = zxcvbn(passphrase);
|
|
70
|
+
if (passphraseResult.score < PASSPHRASE_MIN_ZXCVBN_SCORE) {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pinResult = zxcvbn(pin);
|
|
75
|
+
if (pinResult.score < PIN_MIN_ZXCVBN_SCORE) {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const passwordBuffer = Buffer.from(passphrase);
|
|
80
|
+
let saltBuffer;
|
|
81
|
+
|
|
82
|
+
if (legacy) {
|
|
83
|
+
const legacySalt = `${passphrase.slice(-4)}${pin}`;
|
|
84
|
+
saltBuffer = Buffer.from(legacySalt);
|
|
85
|
+
} else {
|
|
86
|
+
const encoded = abi.encode(
|
|
87
|
+
["string", "string", "string"],
|
|
88
|
+
[KDF_DOMAIN_SEPARATOR, passphrase, pin],
|
|
89
|
+
);
|
|
90
|
+
const saltHash = ethers.keccak256(encoded);
|
|
91
|
+
saltBuffer = Buffer.from(saltHash.slice(2), "hex");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const options = legacy ? HASH_OPTIONS_LEGACY : HASH_OPTIONS;
|
|
95
|
+
|
|
96
|
+
const hashBuffer = await scrypt(
|
|
97
|
+
passwordBuffer,
|
|
98
|
+
saltBuffer,
|
|
99
|
+
options.N,
|
|
100
|
+
options.r,
|
|
101
|
+
options.p,
|
|
102
|
+
options.keyLen,
|
|
103
|
+
cb,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return Buffer.from(hashBuffer).toString("hex");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* This function derives the EVM private key from a result of the scrypt hash.
|
|
111
|
+
* @param {*} hash scrypt hash result
|
|
112
|
+
* @returns private key as string format
|
|
113
|
+
*/
|
|
114
|
+
export function getEvmPrivateKey(hash) {
|
|
115
|
+
return ethers.keccak256(abi.encode(["string"], [hash]));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* This function returns the EVM wallet address from a result of the scrypt hash.
|
|
120
|
+
* @param {*} hash scrypt hash result
|
|
121
|
+
* @returns address as string format
|
|
122
|
+
*/
|
|
123
|
+
export function getEvmWalletAddress(hash) {
|
|
124
|
+
const privateKey = getEvmPrivateKey(hash);
|
|
125
|
+
const wallet = new ethers.Wallet(privateKey);
|
|
126
|
+
return wallet.address;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* This function returns the TRON wallet address from a result of the scrypt hash.
|
|
131
|
+
* @param {*} hash scrypt hash result
|
|
132
|
+
* @returns address as string format
|
|
133
|
+
*/
|
|
134
|
+
export function getTronWalletAddress(hash) {
|
|
135
|
+
const privateKey = getEvmPrivateKey(hash);
|
|
136
|
+
return TronWeb.address.fromPrivateKey(privateKey.slice(2));
|
|
137
|
+
}
|
package/src/random.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomInt } from "crypto";
|
|
2
|
+
import zxcvbn from "zxcvbn";
|
|
3
|
+
import {
|
|
4
|
+
PASSPHRASE_MIN_ZXCVBN_SCORE,
|
|
5
|
+
PIN_MIN_ZXCVBN_SCORE,
|
|
6
|
+
} from "./credentials.js";
|
|
7
|
+
|
|
8
|
+
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
9
|
+
const LOWER = "abcdefghijklmnopqrstuvwxyz";
|
|
10
|
+
const DIGITS = "0123456789";
|
|
11
|
+
const SYMBOLS = "`~!@#$%^&*()-_+={}[]\\|:;\"'<>,.?/";
|
|
12
|
+
|
|
13
|
+
function randomChar(charset) {
|
|
14
|
+
return charset[randomInt(charset.length)];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function generateSegment(length, charset) {
|
|
18
|
+
return Array.from({ length }, () => randomChar(charset)).join("");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generates a random passphrase in a UUID-inspired hyphen-separated block format (e.g. "xxxx-xxxx-xxxx-xxxx").
|
|
23
|
+
* Each block is composed of characters from uppercase, lowercase, digits, and symbols.
|
|
24
|
+
* Retries recursively until zxcvbn score >= 3 (required by generateHash).
|
|
25
|
+
*
|
|
26
|
+
* @param {number} blockLength - Number of characters per block (default: 4)
|
|
27
|
+
* @param {number} numBlocks - Number of blocks (default: 4)
|
|
28
|
+
* @returns {string} A randomly generated passphrase string
|
|
29
|
+
*/
|
|
30
|
+
export function randomPassphrase(blockLength = 4, numBlocks = 4) {
|
|
31
|
+
const segments = Array.from({ length: numBlocks }, () =>
|
|
32
|
+
generateSegment(blockLength, UPPER + LOWER + DIGITS + SYMBOLS),
|
|
33
|
+
);
|
|
34
|
+
const passphrase = segments.join("-");
|
|
35
|
+
|
|
36
|
+
if (zxcvbn(passphrase).score < PASSPHRASE_MIN_ZXCVBN_SCORE) {
|
|
37
|
+
return randomPassphrase(blockLength, numBlocks);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return passphrase;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generates a random PIN composed of digits and lowercase letters.
|
|
45
|
+
* Retries until the PIN passes zxcvbn score >= 1 (required by generateHash).
|
|
46
|
+
*
|
|
47
|
+
* @param {number} length - Number of characters in the PIN (default: 6)
|
|
48
|
+
* @returns {string} A randomly generated PIN string
|
|
49
|
+
*/
|
|
50
|
+
export function randomPIN(length = 6) {
|
|
51
|
+
const pin = generateSegment(length, DIGITS + LOWER);
|
|
52
|
+
|
|
53
|
+
if (zxcvbn(pin).score < PIN_MIN_ZXCVBN_SCORE) {
|
|
54
|
+
return randomPIN(length);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return pin;
|
|
58
|
+
}
|
package/src/token.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Buffer } from "buffer";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import zxcvbn from "zxcvbn";
|
|
4
|
+
import {
|
|
5
|
+
PASSPHRASE_MIN_LENGTH,
|
|
6
|
+
PASSPHRASE_MAX_LENGTH,
|
|
7
|
+
PIN_MIN_LENGTH,
|
|
8
|
+
PIN_MAX_LENGTH,
|
|
9
|
+
} from "./credentials.js";
|
|
10
|
+
|
|
11
|
+
const LEGACY_URL_DELIMITER = "\u0002";
|
|
12
|
+
// Version byte for the default (non-legacy) token format.
|
|
13
|
+
// Uses a compact length-prefixed encoding for passphrase, pin and network.
|
|
14
|
+
const TOKEN_VERSION_COMPACT = 0x02;
|
|
15
|
+
|
|
16
|
+
const NETWORKS = [
|
|
17
|
+
"ethereum",
|
|
18
|
+
"polygon",
|
|
19
|
+
"arbitrum",
|
|
20
|
+
"optimism",
|
|
21
|
+
"bsc",
|
|
22
|
+
"avalanche",
|
|
23
|
+
"base",
|
|
24
|
+
"tron",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This function generates a transfer-link token by encoding passphrase, pin and network, and adding random padding.
|
|
29
|
+
* The transfer-link enables users to send their full ownership of a wallet account to another user for gifting or airdropping.
|
|
30
|
+
* Passphrase and PIN are validated by length (see PASSPHRASE_MIN/MAX_LENGTH, PIN_MIN/MAX_LENGTH) and zxcvbn; invalid or weak values are rejected (returns null).
|
|
31
|
+
* When legacy is false, payload is compact length-prefixed (version 0x02) to avoid concatenation ambiguity and keep the URL fragment short; when true, uses LEGACY_URL_DELIMITER concatenation.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} passphrase - Length in [PASSPHRASE_MIN_LENGTH, PASSPHRASE_MAX_LENGTH], zxcvbn score >= 3
|
|
34
|
+
* @param {string} pin - Length in [PIN_MIN_LENGTH, PIN_MAX_LENGTH], zxcvbn score >= 1
|
|
35
|
+
* @param {string} network - ethereum | polygon | arbitrum | optimism | bsc | avalanche | base | tron
|
|
36
|
+
* @param {boolean} legacy - when true, use LEGACY_URL_DELIMITER concatenation; when false, use compact length-prefixed encoding for the payload
|
|
37
|
+
* @returns A string formatted as a transfer-link token, which can be appended to `https://app.mybucks.online#wallet=`, or null if invalid/weak
|
|
38
|
+
*/
|
|
39
|
+
export function generateToken(passphrase, pin, network, legacy = false) {
|
|
40
|
+
if (!passphrase || !pin || !network) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (!NETWORKS.find((n) => n === network)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const passphraseLen = passphrase.length;
|
|
48
|
+
if (
|
|
49
|
+
passphraseLen < PASSPHRASE_MIN_LENGTH ||
|
|
50
|
+
passphraseLen > PASSPHRASE_MAX_LENGTH
|
|
51
|
+
) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const pinLen = pin.length;
|
|
56
|
+
if (pinLen < PIN_MIN_LENGTH || pinLen > PIN_MAX_LENGTH) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (zxcvbn(passphrase).score < 3) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (zxcvbn(pin).score < 1) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let payloadBuffer;
|
|
68
|
+
if (legacy) {
|
|
69
|
+
payloadBuffer = Buffer.from(
|
|
70
|
+
passphrase + LEGACY_URL_DELIMITER + pin + LEGACY_URL_DELIMITER + network,
|
|
71
|
+
"utf-8",
|
|
72
|
+
);
|
|
73
|
+
} else {
|
|
74
|
+
// Default format: compact length-prefixed encoding.
|
|
75
|
+
const passphraseBytes = Buffer.from(passphrase, "utf-8");
|
|
76
|
+
const pinBytes = Buffer.from(pin, "utf-8");
|
|
77
|
+
const networkBytes = Buffer.from(network, "utf-8");
|
|
78
|
+
|
|
79
|
+
payloadBuffer = Buffer.concat([
|
|
80
|
+
Buffer.from([TOKEN_VERSION_COMPACT]),
|
|
81
|
+
Buffer.from([passphraseBytes.length]),
|
|
82
|
+
passphraseBytes,
|
|
83
|
+
Buffer.from([pinBytes.length]),
|
|
84
|
+
pinBytes,
|
|
85
|
+
Buffer.from([networkBytes.length]),
|
|
86
|
+
networkBytes,
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const base64Encoded = payloadBuffer.toString("base64");
|
|
91
|
+
const padding = nanoid(12);
|
|
92
|
+
return padding.slice(0, 6) + base64Encoded + padding.slice(6);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* This function parses a transfer-link token generated by generateToken().
|
|
97
|
+
* Tokens whose payload starts with 0x02 are decoded as compact length-prefixed; otherwise payload is treated as legacy (UTF-8 + LEGACY_URL_DELIMITER).
|
|
98
|
+
* @param {string} token - token string returned by generateToken()
|
|
99
|
+
* @returns {[string, string, string, boolean]} [passphrase, pin, network, legacy] - legacy is true if token was in legacy format, false if compact-encoded
|
|
100
|
+
*/
|
|
101
|
+
export function parseToken(token) {
|
|
102
|
+
const payload = token.slice(6, token.length - 6);
|
|
103
|
+
const decoded = Buffer.from(payload, "base64");
|
|
104
|
+
|
|
105
|
+
if (decoded[0] === TOKEN_VERSION_COMPACT) {
|
|
106
|
+
let i = 1;
|
|
107
|
+
const lenP = decoded[i++];
|
|
108
|
+
const passphrase = decoded.subarray(i, i + lenP).toString("utf-8");
|
|
109
|
+
i += lenP;
|
|
110
|
+
const lenI = decoded[i++];
|
|
111
|
+
const pin = decoded.subarray(i, i + lenI).toString("utf-8");
|
|
112
|
+
i += lenI;
|
|
113
|
+
const lenN = decoded[i++];
|
|
114
|
+
const network = decoded.subarray(i, i + lenN).toString("utf-8");
|
|
115
|
+
return [passphrase, pin, network, false];
|
|
116
|
+
}
|
|
117
|
+
const str = decoded.toString("utf-8");
|
|
118
|
+
const [passphrase, pin, network] = str.split(LEGACY_URL_DELIMITER);
|
|
119
|
+
return [passphrase, pin, network, true];
|
|
120
|
+
}
|
package/test/index.test.js
CHANGED
|
@@ -21,6 +21,7 @@ const DEMO_WALLET_EVM_ADDRESS = "0xC3Bb18Ed137577e5482fA7C6AEaca8b3F68Dafba";
|
|
|
21
21
|
const DEMO_WALLET_TRON_ADDRESS = "TTp8uDeig42XdefAoTGWTj61uNMYvEVnXR";
|
|
22
22
|
|
|
23
23
|
const DEMO_LEGACY_TOKEN = "VWnsSGRGVtb0FjY291bnQ1JgIxMTIzMjQCb3B0aW1pc20=_wNovT";
|
|
24
|
+
const DEMO_DEFAULT_TOKEN = "Db1zfXAg1EZW1vQWNjb3VudDUmBjExMjMyNAhvcHRpbWlzbQ==mlUEbO";
|
|
24
25
|
|
|
25
26
|
describe("generateHash (default)", () => {
|
|
26
27
|
test("should return empty string if passphrase or pin is blank", async () => {
|
|
@@ -282,6 +283,13 @@ describe("generateToken", () => {
|
|
|
282
283
|
);
|
|
283
284
|
}
|
|
284
285
|
});
|
|
286
|
+
|
|
287
|
+
test("should handle passphrase containing URL_DELIMITER when legacy=false", () => {
|
|
288
|
+
const delimiter = String.fromCharCode(2);
|
|
289
|
+
const trickyPassphrase = `Demo${delimiter}Account5&`;
|
|
290
|
+
const token = generateToken(trickyPassphrase, DEMO_PIN, DEMO_NETWORK, false);
|
|
291
|
+
assert.ok(token !== null);
|
|
292
|
+
});
|
|
285
293
|
});
|
|
286
294
|
|
|
287
295
|
describe("parseToken", () => {
|
|
@@ -310,6 +318,14 @@ describe("parseToken", () => {
|
|
|
310
318
|
assert.strictEqual(network, DEMO_NETWORK);
|
|
311
319
|
assert.strictEqual(legacy, true);
|
|
312
320
|
});
|
|
321
|
+
|
|
322
|
+
test("should parse DEMO_DEFAULT_TOKEN and return DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK and legacy=false", () => {
|
|
323
|
+
const [passphrase, pin, network, legacy] = parseToken(DEMO_DEFAULT_TOKEN);
|
|
324
|
+
assert.strictEqual(passphrase, DEMO_PASSPHRASE);
|
|
325
|
+
assert.strictEqual(pin, DEMO_PIN);
|
|
326
|
+
assert.strictEqual(network, DEMO_NETWORK);
|
|
327
|
+
assert.strictEqual(legacy, false);
|
|
328
|
+
});
|
|
313
329
|
});
|
|
314
330
|
|
|
315
331
|
describe("generateToken and parseToken", () => {
|
|
@@ -325,6 +341,20 @@ describe("generateToken and parseToken", () => {
|
|
|
325
341
|
assert.strictEqual(network, testNetwork);
|
|
326
342
|
});
|
|
327
343
|
|
|
344
|
+
test("should round-trip passphrase containing URL_DELIMITER when legacy=false", () => {
|
|
345
|
+
const delimiter = String.fromCharCode(2);
|
|
346
|
+
const testPassphrase = `My${delimiter}-1st-car-was-a-red-Ford-2005!`;
|
|
347
|
+
const testPin = "909011";
|
|
348
|
+
const testNetwork = "polygon";
|
|
349
|
+
const token = generateToken(testPassphrase, testPin, testNetwork, false);
|
|
350
|
+
|
|
351
|
+
const [passphrase, pin, network, legacy] = parseToken(token);
|
|
352
|
+
assert.strictEqual(passphrase, testPassphrase);
|
|
353
|
+
assert.strictEqual(pin, testPin);
|
|
354
|
+
assert.strictEqual(network, testNetwork);
|
|
355
|
+
assert.strictEqual(legacy, false);
|
|
356
|
+
});
|
|
357
|
+
|
|
328
358
|
test("should round-trip when legacy=true", () => {
|
|
329
359
|
const testPassphrase = "My-1st-car-was-a-red-Ford-2005!";
|
|
330
360
|
const testPin = "909011";
|
|
@@ -336,4 +366,20 @@ describe("generateToken and parseToken", () => {
|
|
|
336
366
|
assert.strictEqual(pin, testPin);
|
|
337
367
|
assert.strictEqual(network, testNetwork);
|
|
338
368
|
});
|
|
369
|
+
|
|
370
|
+
test("should not safely round-trip passphrase containing URL_DELIMITER when legacy=true", () => {
|
|
371
|
+
const delimiter = String.fromCharCode(2);
|
|
372
|
+
const testPassphrase = `My${delimiter}-1st-car-was-a-red-Ford-2005!`;
|
|
373
|
+
const testPin = "909011";
|
|
374
|
+
const testNetwork = "polygon";
|
|
375
|
+
const token = generateToken(testPassphrase, testPin, testNetwork, true);
|
|
376
|
+
|
|
377
|
+
const [passphrase, pin, network, legacy] = parseToken(token);
|
|
378
|
+
assert.notStrictEqual(
|
|
379
|
+
passphrase,
|
|
380
|
+
testPassphrase,
|
|
381
|
+
"legacy format cannot safely encode passphrase containing URL_DELIMITER",
|
|
382
|
+
);
|
|
383
|
+
assert.strictEqual(legacy, true);
|
|
384
|
+
});
|
|
339
385
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import zxcvbn from "zxcvbn";
|
|
4
|
+
import { randomPassphrase, randomPIN } from "../index.js";
|
|
5
|
+
import {
|
|
6
|
+
PASSPHRASE_MIN_ZXCVBN_SCORE,
|
|
7
|
+
PIN_MIN_ZXCVBN_SCORE,
|
|
8
|
+
} from "../src/credentials.js";
|
|
9
|
+
|
|
10
|
+
describe("randomPassphrase", () => {
|
|
11
|
+
test("should return a string", () => {
|
|
12
|
+
const passphrase = randomPassphrase();
|
|
13
|
+
assert.strictEqual(typeof passphrase, "string");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("should have correct total length", () => {
|
|
17
|
+
const passphrase = randomPassphrase(4, 4);
|
|
18
|
+
// blockLength * numBlocks + (numBlocks - 1) hyphens as separators
|
|
19
|
+
assert.strictEqual(passphrase.length, 4 * 4 + 3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("should respect custom blockLength and numBlocks", () => {
|
|
23
|
+
const passphrase = randomPassphrase(6, 3);
|
|
24
|
+
assert.strictEqual(passphrase.length, 6 * 3 + 2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("should have zxcvbn score >= 3", () => {
|
|
28
|
+
const passphrase = randomPassphrase();
|
|
29
|
+
assert.ok(
|
|
30
|
+
zxcvbn(passphrase).score >= PASSPHRASE_MIN_ZXCVBN_SCORE,
|
|
31
|
+
`passphrase zxcvbn score is below ${PASSPHRASE_MIN_ZXCVBN_SCORE}`,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("should return different values on each call", () => {
|
|
36
|
+
const results = Array.from({ length: 10 }, () => randomPassphrase());
|
|
37
|
+
results.forEach((p, i) => console.log(` passphrase[${i}]: ${p}`));
|
|
38
|
+
assert.strictEqual(
|
|
39
|
+
new Set(results).size,
|
|
40
|
+
results.length,
|
|
41
|
+
"randomPassphrase returned duplicate values",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("randomPIN", () => {
|
|
47
|
+
test("should return a string", () => {
|
|
48
|
+
const pin = randomPIN();
|
|
49
|
+
assert.strictEqual(typeof pin, "string");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("should default to length 6", () => {
|
|
53
|
+
const pin = randomPIN();
|
|
54
|
+
assert.strictEqual(pin.length, 6);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("should respect custom length", () => {
|
|
58
|
+
const pin = randomPIN(10);
|
|
59
|
+
assert.strictEqual(pin.length, 10);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("should only contain digits and lowercase letters", () => {
|
|
63
|
+
const pin = randomPIN(32);
|
|
64
|
+
assert.ok(/^[0-9a-z]+$/.test(pin), "PIN contains invalid characters");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("should have zxcvbn score >= 1", () => {
|
|
68
|
+
const pin = randomPIN();
|
|
69
|
+
assert.ok(
|
|
70
|
+
zxcvbn(pin).score >= PIN_MIN_ZXCVBN_SCORE,
|
|
71
|
+
`PIN zxcvbn score is below ${PIN_MIN_ZXCVBN_SCORE}`,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("should return different values on each call", () => {
|
|
76
|
+
const results = Array.from({ length: 10 }, () => randomPIN());
|
|
77
|
+
results.forEach((p, i) => console.log(` pin[${i}]: ${p}`));
|
|
78
|
+
assert.strictEqual(
|
|
79
|
+
new Set(results).size,
|
|
80
|
+
results.length,
|
|
81
|
+
"randomPIN returned duplicate values",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|