@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 +71 -0
- package/README.md +213 -0
- package/dist/bip39-utils.d.ts +5 -0
- package/dist/bip39-utils.js +27 -0
- package/dist/decrypt.d.ts +4 -0
- package/dist/decrypt.js +29 -0
- package/dist/honey.d.ts +5 -0
- package/dist/honey.js +66 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/kdf.d.ts +1 -0
- package/dist/kdf.js +23 -0
- package/dist/legacy.d.ts +9 -0
- package/dist/legacy.js +95 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +1 -0
- package/package.json +48 -0
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>;
|
package/dist/decrypt.js
ADDED
|
@@ -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
|
+
}
|
package/dist/honey.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
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
|
+
}
|
package/dist/legacy.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|