@norionsoft/m2qr 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ABOUT.md ADDED
@@ -0,0 +1,71 @@
1
+ # m2qr - Honey Encryption for BIP-39 Mnemonics
2
+
3
+ ## Problem
4
+
5
+ Standard encryption (AES-GCM, ChaCha20-Poly1305, etc.) uses **authenticated encryption** - decryption with a wrong password fails with an error. This gives attackers a binary oracle: try a password, check if decryption succeeds or fails. With enough compute, any password can be brute-forced offline without touching any external service.
6
+
7
+ Even with expensive key derivation (Argon2id), an attacker with access to the encrypted QR code knows instantly whether each guess is right or wrong.
8
+
9
+ ## Solution
10
+
11
+ **m2qr** uses honey encryption: decryption with **any** password always succeeds, always returning a valid BIP-39 mnemonic phrase. The correct password returns the original mnemonic. Every wrong password returns a different - but equally valid - mnemonic. There is no error, no signal, no way to distinguish correct from incorrect offline.
12
+
13
+ ## How It Works
14
+
15
+ 1. The mnemonic is converted to its underlying **entropy bytes** (128 bits for 12 words, 256 bits for 24 words)
16
+ 2. A random **salt** (16 bytes) is generated
17
+ 3. A **mask** is derived: `mask = Argon2id(password, salt)` with OWASP-recommended parameters (64 MB memory, 3 iterations)
18
+ 4. The ciphertext is computed: `encrypted = entropy XOR mask`
19
+
20
+ On decryption:
21
+
22
+ 1. Recompute the mask: `mask = Argon2id(password_attempt, salt)`
23
+ 2. Recover entropy: `entropy = encrypted XOR mask`
24
+ 3. Compute checksum and build the mnemonic from the entropy
25
+
26
+ Since **any** byte sequence of the right length is valid BIP-39 entropy, step 3 always produces a valid mnemonic. There is no checksum or authentication tag that could reveal a wrong password.
27
+
28
+ ## Why Brute-Force Cannot Work
29
+
30
+ | Defense layer | What it prevents |
31
+ |---|---|
32
+ | **Honey encryption** | No offline oracle - every password "works", producing a valid mnemonic |
33
+ | **Argon2id (64 MB, 3 iterations)** | Each password attempt costs ~1-2 seconds of computation |
34
+ | **Uniform entropy space** | Every decryption output is equally likely; no statistical distinguisher exists |
35
+ | **Open-source safe** | Security relies on the uniform distribution of BIP-39 entropy, not on secret code |
36
+
37
+ To verify a guess, an attacker must:
38
+ - Derive wallet addresses from the mnemonic (multiple derivation paths, multiple chains)
39
+ - Query blockchain APIs for each address (rate-limited, detectable)
40
+ - At ~2 sec/attempt + blockchain lookups, even 1 million guesses take years
41
+
42
+ ### Formal Security Property
43
+
44
+ Under the assumption that Argon2id is a secure pseudorandom function (PRF), for any wrong password `p'`, the decrypted entropy `E XOR Argon2id(p*, salt) XOR Argon2id(p', salt)` is computationally indistinguishable from a uniformly random element of the entropy space. This satisfies the Distribution-Transforming Encoder (DTE) security model of Juels & Ristenpart (Eurocrypt 2014) for flat message distributions.
45
+
46
+ ## Binary Format (v4)
47
+
48
+ ```
49
+ Byte 0: Version (0x04)
50
+ Byte 1: Word count (12, 15, 18, 21, or 24)
51
+ Bytes 2-17: Salt (16 random bytes)
52
+ Bytes 18-N: Encrypted entropy (16-32 bytes)
53
+ ```
54
+
55
+ Total sizes: 34 bytes (12 words) to 50 bytes (24 words). Compact enough for any QR code.
56
+
57
+ ## Backward Compatibility
58
+
59
+ - **Old QR codes** (wallet2qr v1/v2/v3) can be decrypted using m2qr's legacy functions
60
+ - **New QR codes** (v4) cannot be decrypted by old wallet2qr versions - old code expects AES-GCM authentication tags which v4 intentionally omits
61
+
62
+ ## API
63
+
64
+ ```typescript
65
+ // Honey encryption - always returns a valid mnemonic, never errors on wrong password
66
+ const encrypted = await honeyEncrypt(mnemonic, password);
67
+ const mnemonic = await honeyDecrypt(encrypted, password);
68
+
69
+ // Legacy v3 decryption - returns null on wrong password (GCM auth tag)
70
+ const result = await decryptV3(ciphertext, password, salt);
71
+ ```
package/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # @norionsoft-admin/m2qr
2
+
3
+ Honey encryption for BIP-39 mnemonic phrases. Every password decrypts to a valid mnemonic — only the correct password recovers the original.
4
+
5
+ ## Why
6
+
7
+ Standard encryption (AES-GCM, etc.) reveals when a password is wrong — decryption fails. This gives attackers an offline oracle: try passwords until one "works." Even with strong key derivation, brute-force remains feasible because each guess is instantly verified locally.
8
+
9
+ **m2qr** eliminates this oracle entirely. Wrong passwords return different but equally valid BIP-39 mnemonics. An attacker cannot distinguish correct from incorrect without querying external blockchains for every single guess — turning a fast offline attack into an impossibly slow online one.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @norionsoft-admin/m2qr
15
+ ```
16
+
17
+ Requires Node.js >= 20.
18
+
19
+ ## Quick Start
20
+
21
+ ```typescript
22
+ import { honeyEncrypt, honeyDecrypt, isValidMnemonic } from '@norionsoft-admin/m2qr';
23
+
24
+ const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
25
+
26
+ // Encrypt
27
+ const encrypted = await honeyEncrypt(mnemonic, 'my-password');
28
+ // → Uint8Array (34 bytes for 12 words, 50 bytes for 24 words)
29
+
30
+ // Decrypt with correct password → original mnemonic
31
+ const result = await honeyDecrypt(encrypted, 'my-password');
32
+ // → "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
33
+
34
+ // Decrypt with wrong password → DIFFERENT valid mnemonic (no error!)
35
+ const wrong = await honeyDecrypt(encrypted, 'wrong-password');
36
+ // → "share merry latin tag ..." (valid BIP-39, deterministic)
37
+
38
+ isValidMnemonic(result); // true
39
+ isValidMnemonic(wrong); // true — attacker can't tell which is real
40
+ ```
41
+
42
+ ## How It Works
43
+
44
+ 1. Convert mnemonic to raw **entropy** (128–256 bits depending on word count)
45
+ 2. Generate random **salt** (16 bytes)
46
+ 3. Derive a **mask** via `Argon2id(password, salt)` with OWASP parameters (64 MB memory, 3 iterations)
47
+ 4. Ciphertext = `entropy XOR mask`
48
+
49
+ On decryption, any password produces a mask → XOR recovers some entropy → any entropy maps to a valid BIP-39 mnemonic (checksum is computed from entropy, not stored). No authentication tag, no error signal, no oracle.
50
+
51
+ ## Why Brute-Force Cannot Work
52
+
53
+ | Layer | Protection |
54
+ |---|---|
55
+ | **Honey encryption** | No offline oracle — every password yields a valid mnemonic |
56
+ | **Argon2id** | ~1–2 seconds per attempt (64 MB memory-hard) |
57
+ | **Uniform entropy space** | All decryption outputs equally likely; no statistical distinguisher |
58
+ | **Open-source safe** | Security relies on math (uniform BIP-39 entropy distribution), not secret code |
59
+
60
+ To verify a guess, an attacker must:
61
+
62
+ 1. Derive wallet addresses from the mnemonic (multiple derivation paths: BIP-44, BIP-49, BIP-84)
63
+ 2. Query blockchain APIs for each address (multiple chains: Bitcoin, Ethereum, Solana, etc.)
64
+ 3. Check for any balance or transaction history
65
+
66
+ At ~2 seconds per Argon2id attempt + blockchain lookups for each result, even a dictionary of 1 million passwords would take **years** — and the attacker doesn't know which blockchain to check.
67
+
68
+ ### Formal Security Property
69
+
70
+ Under the assumption that Argon2id is a secure pseudorandom function (PRF), for any wrong password `p'`, the decrypted entropy is computationally indistinguishable from a uniformly random element of the entropy space. This satisfies the Distribution-Transforming Encoder (DTE) security model of Juels & Ristenpart (Eurocrypt 2014) for flat message distributions.
71
+
72
+ ## Backward Compatibility
73
+
74
+ m2qr provides full backward-compatible **decryption** for all legacy wallet2qr formats. Encryption always uses the new V4 honey format.
75
+
76
+ | Format | Encrypt | Decrypt | Method |
77
+ |---|---|---|---|
78
+ | **V4** (honey) | Yes | Yes — always returns a valid mnemonic | `honeyEncrypt` / `honeyDecrypt` |
79
+ | **V3** (AES-256-GCM + Argon2id) | No | Yes — returns `null` on wrong password | `decryptV3` |
80
+ | **V2** (CryptoJS AES + pepper) | No | Yes — returns `null` on wrong password | `decryptV2` |
81
+ | **V1** (CryptoJS AES) | No | Yes — returns `null` on wrong password | `decryptV1` |
82
+
83
+ Old QR codes can be decrypted with m2qr. New V4 QR codes cannot be decrypted by old wallet2qr versions (they expect AES-GCM authentication tags which V4 intentionally omits).
84
+
85
+ ### Legacy Decryption
86
+
87
+ ```typescript
88
+ import { decrypt, decryptV1, decryptV2, decryptV3 } from '@norionsoft-admin/m2qr';
89
+
90
+ // V1: password-only (CryptoJS AES)
91
+ const v1Result = decryptV1(ciphertextString, password);
92
+ // → mnemonic string or null
93
+
94
+ // V2: password + social account pepper
95
+ const v2Result = decryptV2(ciphertextString, password, pepper);
96
+ // → mnemonic string or null
97
+
98
+ // V3: AES-256-GCM + Argon2id (all modes)
99
+ const v3Result = await decryptV3(ciphertextBytes, password, saltBytes, {
100
+ mode: 'a', // 'a' | 'b' | 'c' | 'd'
101
+ factor: providerStableId, // for modes b/d
102
+ backupCode: backupCode, // for modes c/d
103
+ wrappedKey1: wrappedKey1, // for mode d (social factor)
104
+ wrappedKey2: wrappedKey2, // for mode d (backup code)
105
+ });
106
+ // → mnemonic string or null
107
+ ```
108
+
109
+ ### Unified Decrypt
110
+
111
+ A single `decrypt` function that handles all versions automatically:
112
+
113
+ ```typescript
114
+ import { decrypt } from '@norionsoft-admin/m2qr';
115
+
116
+ // V1
117
+ await decrypt({ version: 1, ciphertext: ds }, password);
118
+
119
+ // V2
120
+ await decrypt({ version: 2, ciphertext: ds, pepper: pepperValue }, password);
121
+
122
+ // V3 mode a (password only)
123
+ await decrypt({ version: 3, ciphertext: ctBytes, salt: saltBytes, mode: 'a' }, password);
124
+
125
+ // V3 mode d (dual-factor)
126
+ await decrypt({
127
+ version: 3,
128
+ ciphertext: ctBytes,
129
+ salt: saltBytes,
130
+ mode: 'd',
131
+ factor: providerStableId,
132
+ backupCode: backupCode,
133
+ wrappedKey1: wk1Bytes,
134
+ wrappedKey2: wk2Bytes,
135
+ }, password);
136
+
137
+ // V4 (honey encryption — never returns null)
138
+ await decrypt({ version: 4, data: encryptedBytes }, password);
139
+ ```
140
+
141
+ ## Upgrading Old QR Codes
142
+
143
+ The `upgrade` function decrypts a legacy QR code and re-encrypts it with V4 honey encryption in a single step:
144
+
145
+ ```typescript
146
+ import { upgrade } from '@norionsoft-admin/m2qr';
147
+
148
+ // Upgrade V1 → V4 (keep same password)
149
+ const v4Data = await upgrade({ version: 1, ciphertext: oldCiphertext }, password);
150
+ // → Uint8Array (V4 format) or null if wrong password
151
+
152
+ // Upgrade V2 → V4 with a new password
153
+ const v4Data = await upgrade(
154
+ { version: 2, ciphertext: oldCiphertext, pepper: oldPepper },
155
+ oldPassword,
156
+ newPassword // optional: use a different password for the V4 data
157
+ );
158
+
159
+ // Upgrade V3 → V4
160
+ const v4Data = await upgrade(
161
+ { version: 3, ciphertext: ctBytes, salt: saltBytes, mode: 'b', factor: providerId },
162
+ password
163
+ );
164
+ ```
165
+
166
+ Returns `null` if the old password/credentials are wrong (legacy formats can detect incorrect passwords). Returns a `Uint8Array` in V4 honey format on success.
167
+
168
+ ## Binary Format (V4)
169
+
170
+ ```
171
+ Byte 0: Version (0x04)
172
+ Byte 1: Word count (12, 15, 18, 21, or 24)
173
+ Bytes 2-17: Salt (16 random bytes)
174
+ Bytes 18-N: Encrypted entropy (16-32 bytes)
175
+ ```
176
+
177
+ Total: 34 bytes (12 words) to 50 bytes (24 words). Compact enough for any QR code.
178
+
179
+ ## API Reference
180
+
181
+ ### Honey Encryption (V4)
182
+
183
+ | Function | Description |
184
+ |---|---|
185
+ | `honeyEncrypt(mnemonic, password)` | Encrypt a BIP-39 mnemonic → `Uint8Array`. Throws if mnemonic is invalid. |
186
+ | `honeyDecrypt(data, password)` | Decrypt → **always returns a valid mnemonic**, never throws on wrong password. |
187
+
188
+ ### Unified
189
+
190
+ | Function | Description |
191
+ |---|---|
192
+ | `decrypt(input, password)` | Auto-detect version and decrypt. Returns `string \| null`. V4 never returns null. |
193
+ | `upgrade(input, password, newPassword?)` | Decrypt legacy format and re-encrypt as V4. Returns `Uint8Array \| null`. |
194
+
195
+ ### Legacy Decryption
196
+
197
+ | Function | Description |
198
+ |---|---|
199
+ | `decryptV1(ciphertext, password)` | V1 CryptoJS AES. Returns `string \| null`. |
200
+ | `decryptV2(ciphertext, password, pepper)` | V2 CryptoJS AES + pepper. Returns `string \| null`. |
201
+ | `decryptV3(ciphertext, password, salt, options?)` | V3 AES-256-GCM + Argon2id (modes a/b/c/d). Returns `Promise<string \| null>`. |
202
+
203
+ ### Utilities
204
+
205
+ | Function | Description |
206
+ |---|---|
207
+ | `isValidMnemonic(phrase)` | Validate BIP-39 mnemonic (English, checksum). |
208
+ | `detectVersion(data)` | Read version byte from encrypted data. |
209
+ | `parseHoneyData(data)` | Parse V4 binary into `{ version, wordCount, salt, encryptedEntropy }`. |
210
+
211
+ ## License
212
+
213
+ MIT
@@ -0,0 +1,5 @@
1
+ export declare function mnemonicToBytes(mnemonic: string): Uint8Array;
2
+ export declare function bytesToMnemonic(entropy: Uint8Array): string;
3
+ export declare function isValid(mnemonic: string): boolean;
4
+ export declare function getWordCount(mnemonic: string): number;
5
+ export declare function entropyBytesForWordCount(wordCount: number): number;
@@ -0,0 +1,27 @@
1
+ import { mnemonicToEntropy, entropyToMnemonic, validateMnemonic } from '@scure/bip39';
2
+ import { wordlist } from '@scure/bip39/wordlists/english';
3
+ export function mnemonicToBytes(mnemonic) {
4
+ return mnemonicToEntropy(mnemonic, wordlist);
5
+ }
6
+ export function bytesToMnemonic(entropy) {
7
+ return entropyToMnemonic(entropy, wordlist);
8
+ }
9
+ export function isValid(mnemonic) {
10
+ return validateMnemonic(mnemonic, wordlist);
11
+ }
12
+ export function getWordCount(mnemonic) {
13
+ return mnemonic.trim().split(/\s+/).length;
14
+ }
15
+ const ENTROPY_BYTES = {
16
+ 12: 16,
17
+ 15: 20,
18
+ 18: 24,
19
+ 21: 28,
20
+ 24: 32,
21
+ };
22
+ export function entropyBytesForWordCount(wordCount) {
23
+ const bytes = ENTROPY_BYTES[wordCount];
24
+ if (!bytes)
25
+ throw new Error(`Unsupported word count: ${wordCount}`);
26
+ return bytes;
27
+ }
@@ -0,0 +1,4 @@
1
+ import type { DecryptInput, V1DecryptInput, V2DecryptInput, V3DecryptInput } from './types.js';
2
+ export declare function decrypt(input: DecryptInput, password: string): Promise<string | null>;
3
+ export type LegacyInput = V1DecryptInput | V2DecryptInput | V3DecryptInput;
4
+ export declare function upgrade(input: LegacyInput, password: string, newPassword?: string): Promise<Uint8Array | null>;
@@ -0,0 +1,29 @@
1
+ import { decryptV1, decryptV2, decryptV3 } from './legacy.js';
2
+ import { honeyEncrypt, honeyDecrypt } from './honey.js';
3
+ import { isValid } from './bip39-utils.js';
4
+ export async function decrypt(input, password) {
5
+ switch (input.version) {
6
+ case 1:
7
+ return decryptV1(input.ciphertext, password);
8
+ case 2:
9
+ return decryptV2(input.ciphertext, password, input.pepper);
10
+ case 3:
11
+ return decryptV3(input.ciphertext, password, input.salt, {
12
+ mode: input.mode,
13
+ factor: input.factor,
14
+ backupCode: input.backupCode,
15
+ wrappedKey1: input.wrappedKey1,
16
+ wrappedKey2: input.wrappedKey2,
17
+ });
18
+ case 4:
19
+ return honeyDecrypt(input.data, password);
20
+ }
21
+ }
22
+ export async function upgrade(input, password, newPassword) {
23
+ const mnemonic = await decrypt(input, password);
24
+ if (!mnemonic)
25
+ return null;
26
+ if (!isValid(mnemonic))
27
+ return null;
28
+ return honeyEncrypt(mnemonic, newPassword ?? password);
29
+ }
@@ -0,0 +1,5 @@
1
+ import type { HoneyEncryptedData } from './types.js';
2
+ export declare function honeyEncrypt(mnemonic: string, password: string): Promise<Uint8Array>;
3
+ export declare function honeyDecrypt(data: Uint8Array, password: string): Promise<string>;
4
+ export declare function parseHoneyData(data: Uint8Array): HoneyEncryptedData;
5
+ export declare function detectVersion(data: Uint8Array): number;
package/dist/honey.js ADDED
@@ -0,0 +1,66 @@
1
+ import { deriveKey } from './kdf.js';
2
+ import { mnemonicToBytes, bytesToMnemonic, isValid, getWordCount, entropyBytesForWordCount, } from './bip39-utils.js';
3
+ const VERSION = 4;
4
+ const SALT_LENGTH = 16;
5
+ const VALID_WORD_COUNTS = new Set([12, 15, 18, 21, 24]);
6
+ function xor(a, b) {
7
+ const result = new Uint8Array(a.length);
8
+ for (let i = 0; i < a.length; i++) {
9
+ result[i] = a[i] ^ b[i];
10
+ }
11
+ return result;
12
+ }
13
+ export async function honeyEncrypt(mnemonic, password) {
14
+ const normalized = mnemonic.trim().toLowerCase();
15
+ if (!isValid(normalized)) {
16
+ throw new Error('Invalid BIP-39 mnemonic');
17
+ }
18
+ const entropy = mnemonicToBytes(normalized);
19
+ const wordCount = getWordCount(normalized);
20
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
21
+ const mask = await deriveKey(password, salt, entropy.length);
22
+ const encryptedEntropy = xor(entropy, mask);
23
+ const result = new Uint8Array(2 + SALT_LENGTH + encryptedEntropy.length);
24
+ result[0] = VERSION;
25
+ result[1] = wordCount;
26
+ result.set(salt, 2);
27
+ result.set(encryptedEntropy, 2 + SALT_LENGTH);
28
+ return result;
29
+ }
30
+ export async function honeyDecrypt(data, password) {
31
+ if (data.length < 2 + SALT_LENGTH + 16) {
32
+ throw new Error('Invalid data: too short');
33
+ }
34
+ if (data[0] !== VERSION) {
35
+ throw new Error(`Unsupported version: ${data[0]}`);
36
+ }
37
+ const wordCount = data[1];
38
+ if (!VALID_WORD_COUNTS.has(wordCount)) {
39
+ throw new Error(`Invalid word count: ${wordCount}`);
40
+ }
41
+ const entropyLength = entropyBytesForWordCount(wordCount);
42
+ const expectedLength = 2 + SALT_LENGTH + entropyLength;
43
+ if (data.length < expectedLength) {
44
+ throw new Error('Invalid data: length mismatch');
45
+ }
46
+ const salt = data.slice(2, 2 + SALT_LENGTH);
47
+ const encryptedEntropy = data.slice(2 + SALT_LENGTH, expectedLength);
48
+ const mask = await deriveKey(password, salt, entropyLength);
49
+ const entropy = xor(encryptedEntropy, mask);
50
+ return bytesToMnemonic(entropy);
51
+ }
52
+ export function parseHoneyData(data) {
53
+ const wordCount = data[1];
54
+ const entropyLength = entropyBytesForWordCount(wordCount);
55
+ return {
56
+ version: 4,
57
+ wordCount: wordCount,
58
+ salt: data.slice(2, 2 + SALT_LENGTH),
59
+ encryptedEntropy: data.slice(2 + SALT_LENGTH, 2 + SALT_LENGTH + entropyLength),
60
+ };
61
+ }
62
+ export function detectVersion(data) {
63
+ if (data.length === 0)
64
+ throw new Error('Empty data');
65
+ return data[0];
66
+ }
@@ -0,0 +1,5 @@
1
+ export { honeyEncrypt, honeyDecrypt, parseHoneyData, detectVersion } from './honey.js';
2
+ export { decrypt, upgrade, type LegacyInput } from './decrypt.js';
3
+ export { decryptV1, decryptV2, decryptV3 } from './legacy.js';
4
+ export { isValid as isValidMnemonic } from './bip39-utils.js';
5
+ export type { HoneyEncryptedData, WordCount, DecryptInput, V1DecryptInput, V2DecryptInput, V3DecryptInput, V4DecryptInput, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { honeyEncrypt, honeyDecrypt, parseHoneyData, detectVersion } from './honey.js';
2
+ export { decrypt, upgrade } from './decrypt.js';
3
+ export { decryptV1, decryptV2, decryptV3 } from './legacy.js';
4
+ export { isValid as isValidMnemonic } from './bip39-utils.js';
package/dist/kdf.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function deriveKey(password: string, salt: Uint8Array, hashLength: number): Promise<Uint8Array>;
package/dist/kdf.js ADDED
@@ -0,0 +1,23 @@
1
+ import { argon2id } from 'hash-wasm';
2
+ const ARGON2_MEMORY = 65536; // 64 MB
3
+ const ARGON2_ITERATIONS = 3;
4
+ const ARGON2_PARALLELISM = 1;
5
+ function hexToBytes(hex) {
6
+ const bytes = new Uint8Array(hex.length / 2);
7
+ for (let i = 0; i < bytes.length; i++) {
8
+ bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
9
+ }
10
+ return bytes;
11
+ }
12
+ export async function deriveKey(password, salt, hashLength) {
13
+ const hex = await argon2id({
14
+ password,
15
+ salt,
16
+ parallelism: ARGON2_PARALLELISM,
17
+ iterations: ARGON2_ITERATIONS,
18
+ memorySize: ARGON2_MEMORY,
19
+ hashLength,
20
+ outputType: 'hex',
21
+ });
22
+ return hexToBytes(hex);
23
+ }
@@ -0,0 +1,9 @@
1
+ export declare function decryptV1(ciphertext: string, password: string): string | null;
2
+ export declare function decryptV2(ciphertext: string, password: string, pepper: string): string | null;
3
+ export declare function decryptV3(ciphertext: Uint8Array, password: string, salt: Uint8Array, options?: {
4
+ mode?: string;
5
+ factor?: string;
6
+ backupCode?: string;
7
+ wrappedKey1?: Uint8Array;
8
+ wrappedKey2?: Uint8Array;
9
+ }): Promise<string | null>;
package/dist/legacy.js ADDED
@@ -0,0 +1,95 @@
1
+ import CryptoJS from 'crypto-js';
2
+ import { deriveKey } from './kdf.js';
3
+ function toArrayBuffer(arr) {
4
+ const buf = new ArrayBuffer(arr.byteLength);
5
+ new Uint8Array(buf).set(arr);
6
+ return buf;
7
+ }
8
+ // --- V1/V2: CryptoJS AES (OpenSSL format, EvpKDF) ---
9
+ export function decryptV1(ciphertext, password) {
10
+ const candidates = [ciphertext];
11
+ try {
12
+ candidates.push(decodeURIComponent(ciphertext));
13
+ }
14
+ catch { }
15
+ if (ciphertext.includes('%25')) {
16
+ try {
17
+ candidates.push(decodeURIComponent(decodeURIComponent(ciphertext)));
18
+ }
19
+ catch { }
20
+ }
21
+ for (const candidate of candidates) {
22
+ try {
23
+ const decrypted = CryptoJS.AES.decrypt(candidate, password);
24
+ const result = decrypted.toString(CryptoJS.enc.Utf8);
25
+ if (result && !/[\x00-\x08\x0E-\x1F]/.test(result)) {
26
+ return result;
27
+ }
28
+ }
29
+ catch { }
30
+ }
31
+ return null;
32
+ }
33
+ export function decryptV2(ciphertext, password, pepper) {
34
+ return decryptV1(ciphertext, password + ':' + pepper);
35
+ }
36
+ // --- V3: AES-256-GCM + Argon2id ---
37
+ async function aesGcmDecryptBytes(data, keyBytes) {
38
+ try {
39
+ const key = await crypto.subtle.importKey('raw', toArrayBuffer(keyBytes), { name: 'AES-GCM' }, false, ['decrypt']);
40
+ const iv = data.slice(0, 12);
41
+ const ct = data.slice(12);
42
+ const result = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(ct));
43
+ return new Uint8Array(result);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ async function aesGcmDecryptText(data, keyBytes) {
50
+ const bytes = await aesGcmDecryptBytes(data, keyBytes);
51
+ if (!bytes)
52
+ return null;
53
+ return new TextDecoder().decode(bytes);
54
+ }
55
+ export async function decryptV3(ciphertext, password, salt, options) {
56
+ const mode = options?.mode || 'a';
57
+ if (mode === 'a') {
58
+ const keyBytes = await deriveKey(password, salt, 32);
59
+ return aesGcmDecryptText(ciphertext, keyBytes);
60
+ }
61
+ if (mode === 'b') {
62
+ if (!options?.factor)
63
+ return null;
64
+ const keyBytes = await deriveKey(password + '|' + options.factor, salt, 32);
65
+ return aesGcmDecryptText(ciphertext, keyBytes);
66
+ }
67
+ if (mode === 'c') {
68
+ if (!options?.backupCode)
69
+ return null;
70
+ const keyBytes = await deriveKey(password + '|' + options.backupCode, salt, 32);
71
+ return aesGcmDecryptText(ciphertext, keyBytes);
72
+ }
73
+ if (mode === 'd') {
74
+ if (options?.factor && options?.wrappedKey1) {
75
+ const key1 = await deriveKey(password + '|' + options.factor, salt, 32);
76
+ const dek = await aesGcmDecryptBytes(options.wrappedKey1, key1);
77
+ if (dek) {
78
+ const result = await aesGcmDecryptText(ciphertext, dek);
79
+ if (result)
80
+ return result;
81
+ }
82
+ }
83
+ if (options?.backupCode && options?.wrappedKey2) {
84
+ const key2 = await deriveKey(password + '|' + options.backupCode, salt, 32);
85
+ const dek = await aesGcmDecryptBytes(options.wrappedKey2, key2);
86
+ if (dek) {
87
+ const result = await aesGcmDecryptText(ciphertext, dek);
88
+ if (result)
89
+ return result;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ return null;
95
+ }
@@ -0,0 +1,31 @@
1
+ export type WordCount = 12 | 15 | 18 | 21 | 24;
2
+ export interface HoneyEncryptedData {
3
+ version: 4;
4
+ wordCount: WordCount;
5
+ salt: Uint8Array;
6
+ encryptedEntropy: Uint8Array;
7
+ }
8
+ export interface V1DecryptInput {
9
+ version: 1;
10
+ ciphertext: string;
11
+ }
12
+ export interface V2DecryptInput {
13
+ version: 2;
14
+ ciphertext: string;
15
+ pepper: string;
16
+ }
17
+ export interface V3DecryptInput {
18
+ version: 3;
19
+ ciphertext: Uint8Array;
20
+ salt: Uint8Array;
21
+ mode: 'a' | 'b' | 'c' | 'd';
22
+ factor?: string;
23
+ backupCode?: string;
24
+ wrappedKey1?: Uint8Array;
25
+ wrappedKey2?: Uint8Array;
26
+ }
27
+ export interface V4DecryptInput {
28
+ version: 4;
29
+ data: Uint8Array;
30
+ }
31
+ export type DecryptInput = V1DecryptInput | V2DecryptInput | V3DecryptInput | V4DecryptInput;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@norionsoft/m2qr",
3
+ "version": "0.3.0",
4
+ "description": "Honey encryption for BIP-39 mnemonic phrases - every password decrypts to a valid mnemonic",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "ABOUT.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "tsx test/test.ts",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "encryption",
26
+ "honey-encryption",
27
+ "bip39",
28
+ "mnemonic",
29
+ "wallet",
30
+ "crypto",
31
+ "qr"
32
+ ],
33
+ "author": "Alex",
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@scure/bip39": "^1.5.4",
37
+ "crypto-js": "^4.2.0",
38
+ "hash-wasm": "^4.12.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/crypto-js": "^4.2.2",
42
+ "tsx": "^4.19.0",
43
+ "typescript": "^5.7.0"
44
+ },
45
+ "engines": {
46
+ "node": ">=20.0.0"
47
+ }
48
+ }