@mybucks.online/core 2.1.0 → 2.2.1

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
@@ -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.
package/index.js CHANGED
@@ -1,245 +1,16 @@
1
- import { Buffer } from "buffer";
2
- import { ethers } from "ethers";
3
- import scryptJS from "scrypt-js";
4
- import { nanoid } from "nanoid";
5
- import { TronWeb } from "tronweb";
6
- import zxcvbn from "zxcvbn";
7
-
8
- const { scrypt } = scryptJS;
9
- const abi = new ethers.AbiCoder();
10
-
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 = {
16
- N: 32768, // CPU/memory cost parameter, 2^15
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
- // Version byte for the default (non-legacy) token format.
139
- // Uses a compact length-prefixed encoding for passphrase, pin and network.
140
- const TOKEN_VERSION_COMPACT = 0x02;
141
-
142
- const NETWORKS = [
143
- "ethereum",
144
- "polygon",
145
- "arbitrum",
146
- "optimism",
147
- "bsc",
148
- "avalanche",
149
- "base",
150
- "tron",
151
- ];
152
- /**
153
- * This function generates a transfer-link token by encoding passphrase, pin and network, and adding random padding.
154
- * The transfer-link enables users to send their full ownership of a wallet account to another user for gifting or airdropping.
155
- * 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).
156
- * When legacy is false, payload is compact length-prefixed (version 0x02) to avoid concatenation ambiguity and keep the URL fragment short; when true, uses URL_DELIMITER concatenation.
157
- *
158
- * @param {string} passphrase - Length in [PASSPHRASE_MIN_LENGTH, PASSPHRASE_MAX_LENGTH], zxcvbn score >= 3
159
- * @param {string} pin - Length in [PIN_MIN_LENGTH, PIN_MAX_LENGTH], zxcvbn score >= 1
160
- * @param {string} network - ethereum | polygon | arbitrum | optimism | bsc | avalanche | base | tron
161
- * @param {boolean} legacy - when true, use URL_DELIMITER concatenation; when false, use compact length-prefixed encoding for the payload
162
- * @returns A string formatted as a transfer-link token, which can be appended to `https://app.mybucks.online#wallet=`, or null if invalid/weak
163
- */
164
- export function generateToken(passphrase, pin, network, legacy = false) {
165
- if (!passphrase || !pin || !network) {
166
- return null;
167
- }
168
- if (!NETWORKS.find((n) => n === network)) {
169
- return null;
170
- }
171
-
172
- const passphraseLen = passphrase.length;
173
- if (
174
- passphraseLen < PASSPHRASE_MIN_LENGTH ||
175
- passphraseLen > PASSPHRASE_MAX_LENGTH
176
- ) {
177
- return null;
178
- }
179
-
180
- const pinLen = pin.length;
181
- if (pinLen < PIN_MIN_LENGTH || pinLen > PIN_MAX_LENGTH) {
182
- return null;
183
- }
184
-
185
- if (zxcvbn(passphrase).score < 3) {
186
- return null;
187
- }
188
- if (zxcvbn(pin).score < 1) {
189
- return null;
190
- }
191
-
192
- let payloadBuffer;
193
- if (legacy) {
194
- payloadBuffer = Buffer.from(
195
- passphrase + URL_DELIMITER + pin + URL_DELIMITER + network,
196
- "utf-8",
197
- );
198
- } else {
199
- // Default format: compact length-prefixed encoding.
200
- const passphraseBytes = Buffer.from(passphrase, "utf-8");
201
- const pinBytes = Buffer.from(pin, "utf-8");
202
- const networkBytes = Buffer.from(network, "utf-8");
203
-
204
- payloadBuffer = Buffer.concat([
205
- Buffer.from([TOKEN_VERSION_COMPACT]),
206
- Buffer.from([passphraseBytes.length]),
207
- passphraseBytes,
208
- Buffer.from([pinBytes.length]),
209
- pinBytes,
210
- Buffer.from([networkBytes.length]),
211
- networkBytes,
212
- ]);
213
- }
214
-
215
- const base64Encoded = payloadBuffer.toString("base64");
216
- const padding = nanoid(12);
217
- return padding.slice(0, 6) + base64Encoded + padding.slice(6);
218
- }
219
-
220
- /**
221
- * This function parses a transfer-link token generated by generateToken().
222
- * Tokens whose payload starts with 0x02 are decoded as compact length-prefixed; otherwise payload is treated as legacy (UTF-8 + URL_DELIMITER).
223
- * @param {string} token - token string returned by generateToken()
224
- * @returns {[string, string, string, boolean]} [passphrase, pin, network, legacy] - legacy is true if token was in legacy format, false if compact-encoded
225
- */
226
- export function parseToken(token) {
227
- const payload = token.slice(6, token.length - 6);
228
- const decoded = Buffer.from(payload, "base64");
229
-
230
- if (decoded[0] === TOKEN_VERSION_COMPACT) {
231
- let i = 1;
232
- const lenP = decoded[i++];
233
- const passphrase = decoded.subarray(i, i + lenP).toString("utf-8");
234
- i += lenP;
235
- const lenI = decoded[i++];
236
- const pin = decoded.subarray(i, i + lenI).toString("utf-8");
237
- i += lenI;
238
- const lenN = decoded[i++];
239
- const network = decoded.subarray(i, i + lenN).toString("utf-8");
240
- return [passphrase, pin, network, false];
241
- }
242
- const str = decoded.toString("utf-8");
243
- const [passphrase, pin, network] = str.split(URL_DELIMITER);
244
- return [passphrase, pin, network, true];
245
- }
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.1.0",
3
+ "version": "2.2.1",
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",
@@ -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,69 @@
1
+ import zxcvbn from "zxcvbn";
2
+ import {
3
+ PASSPHRASE_MIN_ZXCVBN_SCORE,
4
+ PIN_MIN_ZXCVBN_SCORE,
5
+ } from "./credentials.js";
6
+
7
+ const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
8
+ const LOWER = "abcdefghijklmnopqrstuvwxyz";
9
+ const DIGITS = "0123456789";
10
+ const SYMBOLS = "`~!@#$%^&*()-_+={}[]\\|:;\"'<>,.?/";
11
+
12
+ function randomInt(max) {
13
+ if (max > 255) {
14
+ throw new RangeError(`max must be <= 255, got ${max}`);
15
+ }
16
+ const arr = new Uint8Array(1);
17
+ const limit = 256 - (256 % max);
18
+ do {
19
+ globalThis.crypto.getRandomValues(arr);
20
+ } while (arr[0] >= limit);
21
+ return arr[0] % max;
22
+ }
23
+
24
+ function randomChar(charset) {
25
+ return charset[randomInt(charset.length)];
26
+ }
27
+
28
+ function generateSegment(length, charset) {
29
+ return Array.from({ length }, () => randomChar(charset)).join("");
30
+ }
31
+
32
+ /**
33
+ * Generates a random passphrase in a UUID-inspired hyphen-separated block format (e.g. "xxxx-xxxx-xxxx-xxxx").
34
+ * Each block is composed of characters from uppercase, lowercase, digits, and symbols.
35
+ * Retries recursively until zxcvbn score >= 3 (required by generateHash).
36
+ *
37
+ * @param {number} blockLength - Number of characters per block (default: 4)
38
+ * @param {number} numBlocks - Number of blocks (default: 4)
39
+ * @returns {string} A randomly generated passphrase string
40
+ */
41
+ export function randomPassphrase(blockLength = 4, numBlocks = 4) {
42
+ const segments = Array.from({ length: numBlocks }, () =>
43
+ generateSegment(blockLength, UPPER + LOWER + DIGITS + SYMBOLS),
44
+ );
45
+ const passphrase = segments.join("-");
46
+
47
+ if (zxcvbn(passphrase).score < PASSPHRASE_MIN_ZXCVBN_SCORE) {
48
+ return randomPassphrase(blockLength, numBlocks);
49
+ }
50
+
51
+ return passphrase;
52
+ }
53
+
54
+ /**
55
+ * Generates a random PIN composed of digits and lowercase letters.
56
+ * Retries until the PIN passes zxcvbn score >= 1 (required by generateHash).
57
+ *
58
+ * @param {number} length - Number of characters in the PIN (default: 6)
59
+ * @returns {string} A randomly generated PIN string
60
+ */
61
+ export function randomPIN(length = 6) {
62
+ const pin = generateSegment(length, DIGITS + LOWER);
63
+
64
+ if (zxcvbn(pin).score < PIN_MIN_ZXCVBN_SCORE) {
65
+ return randomPIN(length);
66
+ }
67
+
68
+ return pin;
69
+ }
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
+ }
@@ -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
+ });