@mybucks.online/core 1.1.2 → 2.0.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
@@ -79,11 +79,31 @@ console.log("https://app.mybucks.online/#wallet=" + token);
79
79
 
80
80
  ```javascript
81
81
  import { parseToken } from "@mybucks.online/core";
82
- const [passphrase, pin, network] = parseToken(token);
82
+ const [passphrase, pin, network, legacy] = parseToken(token);
83
83
  console.log("Account credentials are: ", passphrase, pin);
84
84
  console.log("Network: ", network);
85
+ console.log("Legacy token: ", legacy); // true if token was in legacy format
85
86
  ```
86
87
 
88
+ ## Changes (default vs legacy)
89
+
90
+ 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.
91
+
92
+ **Scrypt parameters (default)**
93
+ - **N** is increased from 2^15 to **2^17** to raise the memory cost and make GPU/ASIC brute-force attacks much harder.
94
+ - **p** is reduced from 5 to **1** so hashing time stays the same or lower for users while resistance to brute-force is improved.
95
+
96
+ **Salt generation (default)**
97
+ - Legacy used only the **last 4 characters of the passphrase** plus the pin, which discarded most of the passphrase entropy.
98
+ - The default now derives the salt from the **full passphrase and pin** via a structured encoding and adds a **domain separator** so hashes are bound to this KDF and not reusable in other protocols or versions.
99
+
100
+ **Token generation (default)**
101
+ - 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 **ABI encoding** for the payload so there is no concatenation ambiguity.
103
+ - `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
+
105
+ Use `generateHash(passphrase, pin, cb, true)` or `generateToken(passphrase, pin, network, true)` only when you need to match existing legacy wallets or tokens.
106
+
87
107
  ## Test
88
108
  ```bash
89
109
  npm run test
package/index.js CHANGED
@@ -8,13 +8,24 @@ import zxcvbn from "zxcvbn";
8
8
  const { scrypt } = scryptJS;
9
9
  const abi = new ethers.AbiCoder();
10
10
 
11
- const HASH_OPTIONS = {
11
+ // Domain separator for the default (non-legacy) KDF path to prevent
12
+ // cross-protocol and cross-version hash reuse.
13
+ const KDF_DOMAIN_SEPARATOR = "mybucks.online-core.generateHash.v2";
14
+
15
+ const HASH_OPTIONS_LEGACY = {
12
16
  N: 32768, // CPU/memory cost parameter, 2^15
13
17
  r: 8, // block size parameter
14
18
  p: 5, // parallelization parameter
15
19
  keyLen: 64,
16
20
  };
17
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
+
18
29
  export const PASSPHRASE_MIN_LENGTH = 12;
19
30
  export const PASSPHRASE_MAX_LENGTH = 128;
20
31
  export const PIN_MIN_LENGTH = 6;
@@ -24,22 +35,31 @@ export const PIN_MAX_LENGTH = 16;
24
35
  * This function computes the scrypt hash using provided passphrase and pin inputs.
25
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 "").
26
37
  *
27
- * @param {*} passphrase - Length in [PASSPHRASE_MIN_LENGTH, PASSPHRASE_MAX_LENGTH], zxcvbn score >= 3
28
- * @param {*} pin - Length in [PIN_MIN_LENGTH, PIN_MAX_LENGTH], zxcvbn score >= 1
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
29
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
30
42
  * @returns hash result as string format, or "" if passphrase/pin missing or too weak
31
43
  */
32
- export async function generateHash(passphrase, pin, cb = () => {}) {
44
+ export async function generateHash(
45
+ passphrase,
46
+ pin,
47
+ cb = () => {},
48
+ legacy = false,
49
+ ) {
33
50
  if (!passphrase || !pin) {
34
51
  return "";
35
52
  }
36
53
 
37
- const passphraseLen = String(passphrase).length;
38
- if (passphraseLen < PASSPHRASE_MIN_LENGTH || passphraseLen > PASSPHRASE_MAX_LENGTH) {
54
+ const passphraseLen = passphrase.length;
55
+ if (
56
+ passphraseLen < PASSPHRASE_MIN_LENGTH ||
57
+ passphraseLen > PASSPHRASE_MAX_LENGTH
58
+ ) {
39
59
  return "";
40
60
  }
41
61
 
42
- const pinLen = String(pin).length;
62
+ const pinLen = pin.length;
43
63
  if (pinLen < PIN_MIN_LENGTH || pinLen > PIN_MAX_LENGTH) {
44
64
  return "";
45
65
  }
@@ -54,19 +74,31 @@ export async function generateHash(passphrase, pin, cb = () => {}) {
54
74
  return "";
55
75
  }
56
76
 
57
- const salt = `${passphrase.slice(-4)}${pin}`;
58
-
59
77
  const passwordBuffer = Buffer.from(passphrase);
60
- const saltBuffer = Buffer.from(salt);
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;
61
93
 
62
94
  const hashBuffer = await scrypt(
63
95
  passwordBuffer,
64
96
  saltBuffer,
65
- HASH_OPTIONS.N,
66
- HASH_OPTIONS.r,
67
- HASH_OPTIONS.p,
68
- HASH_OPTIONS.keyLen,
69
- cb
97
+ options.N,
98
+ options.r,
99
+ options.p,
100
+ options.keyLen,
101
+ cb,
70
102
  );
71
103
 
72
104
  return Buffer.from(hashBuffer).toString("hex");
@@ -103,6 +135,8 @@ export function getTronWalletAddress(hash) {
103
135
  }
104
136
 
105
137
  const URL_DELIMITER = "\u0002";
138
+ const TOKEN_VERSION_ABI = 0x02;
139
+
106
140
  const NETWORKS = [
107
141
  "ethereum",
108
142
  "polygon",
@@ -114,16 +148,18 @@ const NETWORKS = [
114
148
  "tron",
115
149
  ];
116
150
  /**
117
- * This function generates a transfer-link token by encoding passphrase and PIN by Base64 and adding random padding.
151
+ * This function generates a transfer-link token by encoding passphrase, pin and network, and adding random padding.
118
152
  * The transfer-link enables users to send their full ownership of a wallet account to another user for gifting or airdropping.
119
- * Passphrase and PIN are validated with zxcvbn; weak values are rejected (returns null).
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.
120
155
  *
121
- * @param {*} passphrase - Must have zxcvbn score >= 3
122
- * @param {*} PIN - Must have zxcvbn score >= 1
123
- * @param {*} network ethereum | polygon | arbitrum | optimism | bsc | avalanche | base | tron
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
124
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
125
161
  */
126
- export function generateToken(passphrase, pin, network) {
162
+ export function generateToken(passphrase, pin, network, legacy = false) {
127
163
  if (!passphrase || !pin || !network) {
128
164
  return null;
129
165
  }
@@ -131,6 +167,19 @@ export function generateToken(passphrase, pin, network) {
131
167
  return null;
132
168
  }
133
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
+
134
183
  if (zxcvbn(passphrase).score < 3) {
135
184
  return null;
136
185
  }
@@ -138,23 +187,48 @@ export function generateToken(passphrase, pin, network) {
138
187
  return null;
139
188
  }
140
189
 
141
- const merged = Buffer.from(
142
- passphrase + URL_DELIMITER + pin + URL_DELIMITER + network,
143
- "utf-8"
144
- );
145
- const base64Encoded = merged.toString("base64");
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");
146
209
  const padding = nanoid(12);
147
210
  return padding.slice(0, 6) + base64Encoded + padding.slice(6);
148
211
  }
149
212
 
150
213
  /**
151
214
  * This function parses a transfer-link token generated by generateToken().
152
- * @param {*} token
153
- * @returns an array of strings, including the passphrase, pin and network.
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
154
218
  */
155
219
  export function parseToken(token) {
156
220
  const payload = token.slice(6, token.length - 6);
157
- const base64Decoded = Buffer.from(payload, "base64").toString("utf-8");
158
- const [passphrase, pin, network] = base64Decoded.split(URL_DELIMITER);
159
- return [passphrase, pin, network];
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];
160
234
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@mybucks.online/core",
3
- "version": "1.1.2",
3
+ "version": "2.0.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"
8
+ "test": "node test/index.test.js && node test/legacy.test.js"
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
@@ -14,16 +14,15 @@ const DEMO_PIN = "112324";
14
14
  const DEMO_NETWORK = "optimism";
15
15
 
16
16
  const DEMO_HASH =
17
- "af9a22d75f8f69d33fe8fc294e8f413219d9c75374dec07fda2e4a66868599609887a10e04981e17356d2c07432fc89c11089172fdf91c0015b9a4beef11e447";
17
+ "70198936dedf67b784a0a7271fca9e562467754eb012a8c9f3a97aaf4e2be725b0995ebfc9112a18395214b3357368f7e2812a23d013c2b42dec0701bc44dd68";
18
18
  const DEMO_PRIVATE_KEY =
19
- "0x71743de900c63ed741263a2a4513c1b1829e80bd9f18d5d3a593e651b914cb3b";
20
- const DEMO_WALLET_EVM_ADDRESS = "0x347CEB6Bf002Ee1819009bA07d8dCAA95Efe6465";
21
- const DEMO_WALLET_TRON_ADDRESS = "TEkjnbpr2cTgRFgmrbv2Gb7GdgupZ5Sh3A";
19
+ "0x942d4f1a0e4bc3b525db81eacba59131ceef498e401142b821613e20539b89e7";
20
+ const DEMO_WALLET_EVM_ADDRESS = "0xC3Bb18Ed137577e5482fA7C6AEaca8b3F68Dafba";
21
+ const DEMO_WALLET_TRON_ADDRESS = "TTp8uDeig42XdefAoTGWTj61uNMYvEVnXR";
22
22
 
23
- const DEMO_TRANSFER_TOKEN =
24
- "VWnsSGRGVtb0FjY291bnQ1JgIxMTIzMjQCb3B0aW1pc20=_wNovT";
23
+ const DEMO_LEGACY_TOKEN = "VWnsSGRGVtb0FjY291bnQ1JgIxMTIzMjQCb3B0aW1pc20=_wNovT";
25
24
 
26
- describe("generateHash", () => {
25
+ describe("generateHash (default)", () => {
27
26
  test("should return empty string if passphrase or pin is blank", async () => {
28
27
  const hash = await generateHash("", "");
29
28
  assert.strictEqual(hash, "");
@@ -63,35 +62,80 @@ describe("generateHash", () => {
63
62
  }
64
63
  });
65
64
 
66
- test("should return scrypt hash result", async () => {
65
+ test("should return empty string if passphrase length is out of range", async () => {
66
+ const tooShort = "Abc!23xy"; // strong-ish but below min length
67
+ const tooLong = "Str0ng-and-L0ng-Passphrase-!".repeat(6); // strong but above max length
68
+
69
+ const hashShort = await generateHash(tooShort, DEMO_PIN);
70
+ const hashLong = await generateHash(tooLong, DEMO_PIN);
71
+
72
+ assert.strictEqual(hashShort, "");
73
+ assert.strictEqual(hashLong, "");
74
+ });
75
+
76
+ test("should return empty string if pin length is out of range", async () => {
77
+ const tooShort = "12aB!"; // strong-ish but below min length
78
+ const tooLong = "9aB!9aB!9aB!9aB!9"; // strong but above max length (> 16 chars)
79
+
80
+ const hashShort = await generateHash(DEMO_PASSPHRASE, tooShort);
81
+ const hashLong = await generateHash(DEMO_PASSPHRASE, tooLong);
82
+
83
+ assert.strictEqual(hashShort, "");
84
+ assert.strictEqual(hashLong, "");
85
+ });
86
+
87
+ test("should return valid scrypt hash result", async () => {
67
88
  const hash = await generateHash(DEMO_PASSPHRASE, DEMO_PIN);
68
89
  assert.strictEqual(hash, DEMO_HASH);
69
90
  });
91
+
92
+ test("should return same hash for same passphrase and pin (deterministic)", async () => {
93
+ const hash1 = await generateHash(DEMO_PASSPHRASE, DEMO_PIN);
94
+ const hash2 = await generateHash(DEMO_PASSPHRASE, DEMO_PIN);
95
+ assert.strictEqual(hash1, hash2);
96
+ });
97
+
98
+ test("should generate different results for same naive concatenation", async () => {
99
+ // These two pairs share the same concatenated string
100
+ // passphrase1 + pin1 === passphrase2 + pin2
101
+ const passphrase1 = "My-1st-car-was-a-red-Ford-2005!";
102
+ const pin1 = "909011";
103
+ const passphrase2 = "My-1st-car-was-a-red-Ford-";
104
+ const pin2 = "2005!909011";
105
+
106
+ const hash1 = await generateHash(passphrase1, pin1);
107
+ const hash2 = await generateHash(passphrase2, pin2);
108
+
109
+ assert.notStrictEqual(hash1, "");
110
+ assert.notStrictEqual(hash2, "");
111
+ assert.notStrictEqual(
112
+ hash1,
113
+ hash2,
114
+ "hashes must differ even when naive concatenation matches"
115
+ );
116
+ });
70
117
  });
71
118
 
72
- describe("getEvmPrivateKey", () => {
73
- test("should return 256bit private key", async () => {
119
+ describe("getEvmPrivateKey (default)", () => {
120
+ test("should return 256bit private key from default hash", async () => {
74
121
  const hash = await generateHash(DEMO_PASSPHRASE, DEMO_PIN);
75
122
  const privateKey = getEvmPrivateKey(hash);
76
-
77
123
  assert.strictEqual(privateKey, DEMO_PRIVATE_KEY);
78
124
  });
79
125
  });
80
126
 
81
- describe("getEvmWalletAddress", () => {
82
- test("should return a valid wallet address", async () => {
127
+ describe("getEvmWalletAddress (default)", () => {
128
+ test("should return a valid wallet address from default hash", async () => {
83
129
  const hash = await generateHash(DEMO_PASSPHRASE, DEMO_PIN);
84
130
  const address = getEvmWalletAddress(hash);
85
-
86
131
  assert.strictEqual(address, DEMO_WALLET_EVM_ADDRESS);
87
132
  });
88
133
  });
89
134
 
90
- describe("getTronWalletAddress", () => {
91
- test("should return a valid wallet address", async () => {
135
+ describe("getTronWalletAddress (default)", () => {
136
+ test("should return a valid TRON wallet address from default hash", async () => {
92
137
  const hash = await generateHash(DEMO_PASSPHRASE, DEMO_PIN);
93
138
  const address = getTronWalletAddress(hash);
94
-
95
139
  assert.strictEqual(address, DEMO_WALLET_TRON_ADDRESS);
96
140
  });
97
141
  });
@@ -105,6 +149,20 @@ describe("generateToken", () => {
105
149
  assert.strictEqual(generateToken("passphrase", "123456", "invalid"), null);
106
150
  });
107
151
 
152
+ test("should return null if passphrase length is out of range", () => {
153
+ const tooShort = "Abc!23xy"; // strong-ish but below min length
154
+ const tooLong = "Str0ng-and-L0ng-Passphrase-!".repeat(6); // strong but above max length
155
+ assert.strictEqual(generateToken(tooShort, DEMO_PIN, DEMO_NETWORK), null);
156
+ assert.strictEqual(generateToken(tooLong, DEMO_PIN, DEMO_NETWORK), null);
157
+ });
158
+
159
+ test("should return null if pin length is out of range", () => {
160
+ const tooShort = "12aB!"; // strong-ish but below min length
161
+ const tooLong = "9aB!9aB!9aB!9aB!9"; // strong but above max length (> 16 chars)
162
+ assert.strictEqual(generateToken(DEMO_PASSPHRASE, tooShort, DEMO_NETWORK), null);
163
+ assert.strictEqual(generateToken(DEMO_PASSPHRASE, tooLong, DEMO_NETWORK), null);
164
+ });
165
+
108
166
  test("should return null for weak passphrase (zxcvbn score < 3)", () => {
109
167
  const weakPassphrases = [
110
168
  "password",
@@ -141,68 +199,137 @@ describe("generateToken", () => {
141
199
  }
142
200
  });
143
201
 
144
- test("should return valid token", async () => {
145
- const token = generateToken(DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK);
202
+ test("should return valid token with padding (legacy=false)", () => {
203
+ const token = generateToken(DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK, false);
204
+ assert.ok(token !== null);
205
+ assert.ok(token.length >= 6 + 6, "token has 6-char prefix and suffix padding");
206
+ });
146
207
 
147
- // The first and last 6 characters serve as random padding.
148
- assert.strictEqual(
149
- token.slice(6, token.length - 6),
150
- DEMO_TRANSFER_TOKEN.slice(6, DEMO_TRANSFER_TOKEN.length - 6)
151
- );
208
+ test("should return valid token with padding (legacy=true)", () => {
209
+ const token = generateToken(DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK, true);
210
+ assert.ok(token !== null);
211
+ assert.ok(token.length >= 6 + 6, "token has 6-char prefix and suffix padding");
152
212
  });
153
213
 
154
- test("should return valid token for all networks", async () => {
155
- assert.notStrictEqual(
156
- generateToken(DEMO_PASSPHRASE, DEMO_PIN, "ethereum"),
157
- null
158
- );
159
- assert.notStrictEqual(
160
- generateToken(DEMO_PASSPHRASE, DEMO_PIN, "polygon"),
161
- null
162
- );
163
- assert.notStrictEqual(
164
- generateToken(DEMO_PASSPHRASE, DEMO_PIN, "arbitrum"),
165
- null
166
- );
167
- assert.notStrictEqual(
168
- generateToken(DEMO_PASSPHRASE, DEMO_PIN, "optimism"),
169
- null
170
- );
171
- assert.notStrictEqual(
172
- generateToken(DEMO_PASSPHRASE, DEMO_PIN, "bsc"),
173
- null
174
- );
175
- assert.notStrictEqual(
176
- generateToken(DEMO_PASSPHRASE, DEMO_PIN, "avalanche"),
177
- null
178
- );
179
- assert.notStrictEqual(
180
- generateToken(DEMO_PASSPHRASE, DEMO_PIN, "base"),
181
- null
214
+ test("should return different token for same inputs when legacy true vs false", () => {
215
+ const tokenLegacy = generateToken(DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK, true);
216
+ const tokenAbi = generateToken(DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK, false);
217
+ const payloadLegacy = tokenLegacy.slice(6, tokenLegacy.length - 6);
218
+ const payloadAbi = tokenAbi.slice(6, tokenAbi.length - 6);
219
+ assert.notStrictEqual(payloadLegacy, payloadAbi, "payloads must differ (legacy vs ABI encoding)");
220
+ });
221
+
222
+ test("should generate different results for same naive concatenation (legacy=false)", () => {
223
+ const network = "polygon";
224
+ const passphrase1 = "My-1st-car-was-a-red-Ford-2005!";
225
+ const pin1 = "909011";
226
+ const passphrase2 = "My-1st-car-was-a-red-Ford-";
227
+ const pin2 = "2005!909011";
228
+ assert.strictEqual(
229
+ passphrase1 + pin1 + network,
230
+ passphrase2 + pin2 + network,
231
+ "same naive concatenation"
182
232
  );
233
+
234
+ const token1 = generateToken(passphrase1, pin1, network, false);
235
+ const token2 = generateToken(passphrase2, pin2, network, false);
236
+
237
+ assert.ok(token1 !== null);
238
+ assert.ok(token2 !== null);
239
+ const payload1 = token1.slice(6, token1.length - 6);
240
+ const payload2 = token2.slice(6, token2.length - 6);
183
241
  assert.notStrictEqual(
184
- generateToken(DEMO_PASSPHRASE, DEMO_PIN, "tron"),
185
- null
242
+ payload1,
243
+ payload2,
244
+ "payloads must differ even when naive concatenation matches"
186
245
  );
187
246
  });
247
+
248
+ test("should return valid token for all networks (legacy=false)", () => {
249
+ const networks = [
250
+ "ethereum",
251
+ "polygon",
252
+ "arbitrum",
253
+ "optimism",
254
+ "bsc",
255
+ "avalanche",
256
+ "base",
257
+ "tron",
258
+ ];
259
+ for (const network of networks) {
260
+ assert.notStrictEqual(
261
+ generateToken(DEMO_PASSPHRASE, DEMO_PIN, network, false),
262
+ null
263
+ );
264
+ }
265
+ });
266
+
267
+ test("should return valid token for all networks (legacy=true)", () => {
268
+ const networks = [
269
+ "ethereum",
270
+ "polygon",
271
+ "arbitrum",
272
+ "optimism",
273
+ "bsc",
274
+ "avalanche",
275
+ "base",
276
+ "tron",
277
+ ];
278
+ for (const network of networks) {
279
+ assert.notStrictEqual(
280
+ generateToken(DEMO_PASSPHRASE, DEMO_PIN, network, true),
281
+ null
282
+ );
283
+ }
284
+ });
188
285
  });
189
286
 
190
287
  describe("parseToken", () => {
191
- test("should return array of passphrase, pin, and network", () => {
192
- const [passphrase, pin, network] = parseToken(DEMO_TRANSFER_TOKEN);
288
+ test("should return [passphrase, pin, network, legacy] for token generated with legacy=false", () => {
289
+ const token = generateToken(DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK, false);
290
+ const [passphrase, pin, network, legacy] = parseToken(token);
291
+ assert.strictEqual(passphrase, DEMO_PASSPHRASE);
292
+ assert.strictEqual(pin, DEMO_PIN);
293
+ assert.strictEqual(network, DEMO_NETWORK);
294
+ assert.strictEqual(legacy, false);
295
+ });
193
296
 
297
+ test("should return [passphrase, pin, network, legacy] for token generated with legacy=true", () => {
298
+ const token = generateToken(DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK, true);
299
+ const [passphrase, pin, network, legacy] = parseToken(token);
194
300
  assert.strictEqual(passphrase, DEMO_PASSPHRASE);
195
301
  assert.strictEqual(pin, DEMO_PIN);
196
302
  assert.strictEqual(network, DEMO_NETWORK);
303
+ assert.strictEqual(legacy, true);
304
+ });
305
+
306
+ test("should parse DEMO_LEGACY_TOKEN and return DEMO_PASSPHRASE, DEMO_PIN, DEMO_NETWORK and legacy=true", () => {
307
+ const [passphrase, pin, network, legacy] = parseToken(DEMO_LEGACY_TOKEN);
308
+ assert.strictEqual(passphrase, DEMO_PASSPHRASE);
309
+ assert.strictEqual(pin, DEMO_PIN);
310
+ assert.strictEqual(network, DEMO_NETWORK);
311
+ assert.strictEqual(legacy, true);
197
312
  });
198
313
  });
199
314
 
200
315
  describe("generateToken and parseToken", () => {
201
- test("should be compatible and return correct result", () => {
316
+ test("should round-trip when legacy=false", () => {
317
+ const testPassphrase = "My-1st-car-was-a-red-Ford-2005!";
318
+ const testPin = "909011";
319
+ const testNetwork = "polygon";
320
+ const token = generateToken(testPassphrase, testPin, testNetwork, false);
321
+
322
+ const [passphrase, pin, network] = parseToken(token);
323
+ assert.strictEqual(passphrase, testPassphrase);
324
+ assert.strictEqual(pin, testPin);
325
+ assert.strictEqual(network, testNetwork);
326
+ });
327
+
328
+ test("should round-trip when legacy=true", () => {
202
329
  const testPassphrase = "My-1st-car-was-a-red-Ford-2005!";
203
330
  const testPin = "909011";
204
331
  const testNetwork = "polygon";
205
- const token = generateToken(testPassphrase, testPin, testNetwork);
332
+ const token = generateToken(testPassphrase, testPin, testNetwork, true);
206
333
 
207
334
  const [passphrase, pin, network] = parseToken(token);
208
335
  assert.strictEqual(passphrase, testPassphrase);
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ generateHash,
5
+ getEvmPrivateKey,
6
+ getEvmWalletAddress,
7
+ getTronWalletAddress,
8
+ } from "../index.js";
9
+
10
+ const DEMO_PASSPHRASE = "DemoAccount5&";
11
+ const DEMO_PIN = "112324";
12
+
13
+ const DEMO_HASH =
14
+ "af9a22d75f8f69d33fe8fc294e8f413219d9c75374dec07fda2e4a66868599609887a10e04981e17356d2c07432fc89c11089172fdf91c0015b9a4beef11e447";
15
+ const DEMO_PRIVATE_KEY =
16
+ "0x71743de900c63ed741263a2a4513c1b1829e80bd9f18d5d3a593e651b914cb3b";
17
+ const DEMO_WALLET_EVM_ADDRESS = "0x347CEB6Bf002Ee1819009bA07d8dCAA95Efe6465";
18
+ const DEMO_WALLET_TRON_ADDRESS = "TEkjnbpr2cTgRFgmrbv2Gb7GdgupZ5Sh3A";
19
+
20
+ describe("generateHash (legacy)", () => {
21
+ test("should return empty string if passphrase or pin is blank", async () => {
22
+ const hash = await generateHash("", "", () => {}, true);
23
+ assert.strictEqual(hash, "");
24
+ });
25
+
26
+ test("should return empty string for weak passphrase (zxcvbn score < 3)", async () => {
27
+ const weakPassphrases = [
28
+ "password",
29
+ "asdfasdf",
30
+ "123123123",
31
+ "P@ssw0rd",
32
+ "London123",
33
+ "oxford2024",
34
+ "John19821012",
35
+ "asdfASDFaSdf",
36
+ "qwerqwerqwer",
37
+ "1234567890",
38
+ "Julia18921012",
39
+ ];
40
+ for (const passphrase of weakPassphrases) {
41
+ const hash = await generateHash(passphrase, DEMO_PIN, () => {}, true);
42
+ assert.strictEqual(hash, "");
43
+ }
44
+ });
45
+
46
+ test("should return empty string for weak pin (zxcvbn score < 1)", async () => {
47
+ const weakPins = [
48
+ "111111",
49
+ "111111111111111",
50
+ "12341234",
51
+ "aaaaaaaaaa",
52
+ "asdfasdf",
53
+ ];
54
+ for (const pin of weakPins) {
55
+ const hash = await generateHash(DEMO_PASSPHRASE, pin, () => {}, true);
56
+ assert.strictEqual(hash, "", `Expected "" for weak pin "${pin}"`);
57
+ }
58
+ });
59
+
60
+ test("should return scrypt hash result", async () => {
61
+ const hash = await generateHash(DEMO_PASSPHRASE, DEMO_PIN, () => {}, true);
62
+ assert.strictEqual(hash, DEMO_HASH);
63
+ });
64
+ });
65
+
66
+ describe("getEvmPrivateKey (legacy)", () => {
67
+ test("should return 256bit private key", async () => {
68
+ const hash = await generateHash(DEMO_PASSPHRASE, DEMO_PIN, () => {}, true);
69
+ const privateKey = getEvmPrivateKey(hash);
70
+
71
+ assert.strictEqual(privateKey, DEMO_PRIVATE_KEY);
72
+ });
73
+ });
74
+
75
+ describe("getEvmWalletAddress (legacy)", () => {
76
+ test("should return a valid wallet address", async () => {
77
+ const hash = await generateHash(DEMO_PASSPHRASE, DEMO_PIN, () => {}, true);
78
+ const address = getEvmWalletAddress(hash);
79
+
80
+ assert.strictEqual(address, DEMO_WALLET_EVM_ADDRESS);
81
+ });
82
+ });
83
+
84
+ describe("getTronWalletAddress (legacy)", () => {
85
+ test("should return a valid wallet address", async () => {
86
+ const hash = await generateHash(DEMO_PASSPHRASE, DEMO_PIN, () => {}, true);
87
+ const address = getTronWalletAddress(hash);
88
+
89
+ assert.strictEqual(address, DEMO_WALLET_TRON_ADDRESS);
90
+ });
91
+ });