@norionsoft/m2qr
v0.3.0
Published
Honey encryption for BIP-39 mnemonic phrases - every password decrypts to a valid mnemonic
Readme
@norionsoft-admin/m2qr
Honey encryption for BIP-39 mnemonic phrases. Every password decrypts to a valid mnemonic — only the correct password recovers the original.
Why
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.
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.
Install
npm install @norionsoft-admin/m2qrRequires Node.js >= 20.
Quick Start
import { honeyEncrypt, honeyDecrypt, isValidMnemonic } from '@norionsoft-admin/m2qr';
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
// Encrypt
const encrypted = await honeyEncrypt(mnemonic, 'my-password');
// → Uint8Array (34 bytes for 12 words, 50 bytes for 24 words)
// Decrypt with correct password → original mnemonic
const result = await honeyDecrypt(encrypted, 'my-password');
// → "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Decrypt with wrong password → DIFFERENT valid mnemonic (no error!)
const wrong = await honeyDecrypt(encrypted, 'wrong-password');
// → "share merry latin tag ..." (valid BIP-39, deterministic)
isValidMnemonic(result); // true
isValidMnemonic(wrong); // true — attacker can't tell which is realHow It Works
- Convert mnemonic to raw entropy (128–256 bits depending on word count)
- Generate random salt (16 bytes)
- Derive a mask via
Argon2id(password, salt)with OWASP parameters (64 MB memory, 3 iterations) - Ciphertext =
entropy XOR mask
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.
Why Brute-Force Cannot Work
| Layer | Protection | |---|---| | Honey encryption | No offline oracle — every password yields a valid mnemonic | | Argon2id | ~1–2 seconds per attempt (64 MB memory-hard) | | Uniform entropy space | All decryption outputs equally likely; no statistical distinguisher | | Open-source safe | Security relies on math (uniform BIP-39 entropy distribution), not secret code |
To verify a guess, an attacker must:
- Derive wallet addresses from the mnemonic (multiple derivation paths: BIP-44, BIP-49, BIP-84)
- Query blockchain APIs for each address (multiple chains: Bitcoin, Ethereum, Solana, etc.)
- Check for any balance or transaction history
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.
Formal Security Property
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.
Backward Compatibility
m2qr provides full backward-compatible decryption for all legacy wallet2qr formats. Encryption always uses the new V4 honey format.
| Format | Encrypt | Decrypt | Method |
|---|---|---|---|
| V4 (honey) | Yes | Yes — always returns a valid mnemonic | honeyEncrypt / honeyDecrypt |
| V3 (AES-256-GCM + Argon2id) | No | Yes — returns null on wrong password | decryptV3 |
| V2 (CryptoJS AES + pepper) | No | Yes — returns null on wrong password | decryptV2 |
| V1 (CryptoJS AES) | No | Yes — returns null on wrong password | decryptV1 |
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).
Legacy Decryption
import { decrypt, decryptV1, decryptV2, decryptV3 } from '@norionsoft-admin/m2qr';
// V1: password-only (CryptoJS AES)
const v1Result = decryptV1(ciphertextString, password);
// → mnemonic string or null
// V2: password + social account pepper
const v2Result = decryptV2(ciphertextString, password, pepper);
// → mnemonic string or null
// V3: AES-256-GCM + Argon2id (all modes)
const v3Result = await decryptV3(ciphertextBytes, password, saltBytes, {
mode: 'a', // 'a' | 'b' | 'c' | 'd'
factor: providerStableId, // for modes b/d
backupCode: backupCode, // for modes c/d
wrappedKey1: wrappedKey1, // for mode d (social factor)
wrappedKey2: wrappedKey2, // for mode d (backup code)
});
// → mnemonic string or nullUnified Decrypt
A single decrypt function that handles all versions automatically:
import { decrypt } from '@norionsoft-admin/m2qr';
// V1
await decrypt({ version: 1, ciphertext: ds }, password);
// V2
await decrypt({ version: 2, ciphertext: ds, pepper: pepperValue }, password);
// V3 mode a (password only)
await decrypt({ version: 3, ciphertext: ctBytes, salt: saltBytes, mode: 'a' }, password);
// V3 mode d (dual-factor)
await decrypt({
version: 3,
ciphertext: ctBytes,
salt: saltBytes,
mode: 'd',
factor: providerStableId,
backupCode: backupCode,
wrappedKey1: wk1Bytes,
wrappedKey2: wk2Bytes,
}, password);
// V4 (honey encryption — never returns null)
await decrypt({ version: 4, data: encryptedBytes }, password);Upgrading Old QR Codes
The upgrade function decrypts a legacy QR code and re-encrypts it with V4 honey encryption in a single step:
import { upgrade } from '@norionsoft-admin/m2qr';
// Upgrade V1 → V4 (keep same password)
const v4Data = await upgrade({ version: 1, ciphertext: oldCiphertext }, password);
// → Uint8Array (V4 format) or null if wrong password
// Upgrade V2 → V4 with a new password
const v4Data = await upgrade(
{ version: 2, ciphertext: oldCiphertext, pepper: oldPepper },
oldPassword,
newPassword // optional: use a different password for the V4 data
);
// Upgrade V3 → V4
const v4Data = await upgrade(
{ version: 3, ciphertext: ctBytes, salt: saltBytes, mode: 'b', factor: providerId },
password
);Returns null if the old password/credentials are wrong (legacy formats can detect incorrect passwords). Returns a Uint8Array in V4 honey format on success.
Binary Format (V4)
Byte 0: Version (0x04)
Byte 1: Word count (12, 15, 18, 21, or 24)
Bytes 2-17: Salt (16 random bytes)
Bytes 18-N: Encrypted entropy (16-32 bytes)Total: 34 bytes (12 words) to 50 bytes (24 words). Compact enough for any QR code.
API Reference
Honey Encryption (V4)
| Function | Description |
|---|---|
| honeyEncrypt(mnemonic, password) | Encrypt a BIP-39 mnemonic → Uint8Array. Throws if mnemonic is invalid. |
| honeyDecrypt(data, password) | Decrypt → always returns a valid mnemonic, never throws on wrong password. |
Unified
| Function | Description |
|---|---|
| decrypt(input, password) | Auto-detect version and decrypt. Returns string \| null. V4 never returns null. |
| upgrade(input, password, newPassword?) | Decrypt legacy format and re-encrypt as V4. Returns Uint8Array \| null. |
Legacy Decryption
| Function | Description |
|---|---|
| decryptV1(ciphertext, password) | V1 CryptoJS AES. Returns string \| null. |
| decryptV2(ciphertext, password, pepper) | V2 CryptoJS AES + pepper. Returns string \| null. |
| decryptV3(ciphertext, password, salt, options?) | V3 AES-256-GCM + Argon2id (modes a/b/c/d). Returns Promise<string \| null>. |
Utilities
| Function | Description |
|---|---|
| isValidMnemonic(phrase) | Validate BIP-39 mnemonic (English, checksum). |
| detectVersion(data) | Read version byte from encrypted data. |
| parseHoneyData(data) | Parse V4 binary into { version, wordCount, salt, encryptedEntropy }. |
License
MIT
