From 276a9ea8f85edc948228a6933919ea270b856083 Mon Sep 17 00:00:00 2001 From: Ivan Vershigora Date: Mon, 18 May 2026 14:05:28 +0100 Subject: [PATCH] REF: swap crypto-js for @noble/ciphers + hashes --- blue_modules/encryption.ts | 105 ++++++++++++++++++++++++---- class/lnurl.ts | 27 ++++--- package-lock.json | 32 +++++---- package.json | 5 +- tests/unit/encryption.test.ts | 15 ++++ tests/unit/evp-bytes-to-key.test.ts | 51 ++++++++++++++ tests/unit/lnurl.test.js | 11 +++ 7 files changed, 206 insertions(+), 40 deletions(-) create mode 100644 tests/unit/evp-bytes-to-key.test.ts diff --git a/blue_modules/encryption.ts b/blue_modules/encryption.ts index 746926a76..9575e66a8 100644 --- a/blue_modules/encryption.ts +++ b/blue_modules/encryption.ts @@ -1,23 +1,98 @@ -import AES from 'crypto-js/aes'; -import Utf8 from 'crypto-js/enc-utf8'; +import { cbc } from '@noble/ciphers/aes'; +import { md5 } from '@noble/hashes/legacy'; +import { randomBytes } from '@noble/hashes/utils'; +import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras'; + +/** + * OpenSSL EVP_BytesToKey using MD5 with 1 iteration. + * + * Reproduces the default key+IV derivation used by CryptoJS@4.x's + * `AES.encrypt(string, password)` so the on-disk wire format stays + * bit-identical after we swap the underlying library. + * + * D1 = MD5( password || salt ) + * Di = MD5( D(i-1) || password || salt ) for i ≥ 2 + * key||iv = D1 || D2 || ... (take first `byteLength` bytes) + * + * MD5 is intentional: it matches the legacy OpenSSL format. The + * cryptographic weakness of MD5 is not relevant here — the function is + * only used as a deterministic byte-stretcher; the password's entropy is + * what protects the wallet, not MD5. + */ +export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLength: number): Uint8Array { + if (!Number.isInteger(byteLength) || byteLength < 0) { + throw new Error('evpBytesToKeyMd5: byteLength must be a non-negative integer'); + } + const out = new Uint8Array(byteLength); + let written = 0; + let prev: Uint8Array = new Uint8Array(0); + while (written < byteLength) { + prev = md5(concatUint8Arrays([prev, password, salt])); + const take = Math.min(prev.length, byteLength - written); + out.set(prev.subarray(0, take), written); + written += take; + } + return out; +} + +// "Salted__" — OpenSSL envelope magic. Hardcoded as bytes so the wire +// format cannot drift through any encoder. +const SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]); +const SALT_LEN = 8; +const KEY_LEN = 32; +const IV_LEN = 16; +const BLOCK_LEN = 16; + +/** + * AES-256-CBC encrypt with the OpenSSL "Salted__" envelope, EVP_BytesToKey-MD5 + * key derivation and PKCS7 padding. Output is base64-encoded. + * + * Wire format is bit-identical to CryptoJS@4.x's default + * `AES.encrypt(data, password).toString()` — we kept the swap-the-library + * change a drop-in replacement so existing encrypted wallets on user + * devices remain readable, with no migration step. + */ export function encrypt(data: string, password: string): string { if (data.length < 10) throw new Error('data length cant be < 10'); - const ciphertext = AES.encrypt(data, password); - return ciphertext.toString(); + const salt = randomBytes(SALT_LEN); + const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN); + const key = kdf.subarray(0, KEY_LEN); + const iv = kdf.subarray(KEY_LEN); + const ciphertext = cbc(key, iv).encrypt(stringToUint8Array(data)); + return uint8ArrayToBase64(concatUint8Arrays([SALT_MAGIC, salt, ciphertext])); } +/** + * Inverse of `encrypt`. Accepts the legacy CryptoJS wire format and returns + * the original UTF-8 plaintext. Any error (bad base64, missing magic, wrong + * password, bad padding) collapses to `false`. + */ export function decrypt(data: string, password: string): string | false { - const bytes = AES.decrypt(data, password); - let str: string | false = false; try { - str = bytes.toString(Utf8); - } catch (e) {} - - // For some reason, sometimes decrypt would succeed with an incorrect password and return random characters. - // In this TypeScript version, we are not allowing the encryption of data that is shorter than - // 10 characters. If the decrypted data is less than 10 characters, we assume that the decrypt actually failed. - if (str && str.length < 10) return false; - - return str; + // crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup + // export/import flows (manual file paste, clipboard transit, email-based + // wallet transfer) introduced stray newlines or padding spaces. Strip them + // before strict base64 decode so legacy backups still open. `\s` does not + // include `=`, so base64 padding survives. + const envelope = base64ToUint8Array(data.replace(/\s+/g, '')); + if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false; + if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false; + const salt = envelope.subarray(SALT_MAGIC.length, SALT_MAGIC.length + SALT_LEN); + const ciphertext = envelope.subarray(SALT_MAGIC.length + SALT_LEN); + const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN); + const key = kdf.subarray(0, KEY_LEN); + const iv = kdf.subarray(KEY_LEN); + const plain = cbc(key, iv).decrypt(ciphertext); + // Strict UTF-8 decode — wrong-password decrypts that happen to survive + // PKCS7 unpadding overwhelmingly fail here (crypto-js's `enc.Utf8` was + // strict too; we preserve that gate by using `fatal: true`). + const str = new TextDecoder('utf-8', { fatal: true }).decode(plain); + // Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars + // (enforced by encrypt()), so anything shorter is rejected. + if (str.length < 10) return false; + return str; + } catch (e) { + return false; + } } diff --git a/class/lnurl.ts b/class/lnurl.ts index 346b75c39..80f333dca 100644 --- a/class/lnurl.ts +++ b/class/lnurl.ts @@ -2,7 +2,7 @@ import { bech32 } from 'bech32'; import bolt11 from 'bolt11'; import { sha256 } from '@noble/hashes/sha256'; import { hmac } from '@noble/hashes/hmac'; -import CryptoJS from 'crypto-js'; +import { cbc } from '@noble/ciphers/aes'; import ecc from '../blue_modules/noble_ecc'; import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api import { fetch } from '../util/fetch'; @@ -321,13 +321,24 @@ export default class Lnurl { } static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string { - const iv = CryptoJS.enc.Base64.parse(ivBase64); - const key = CryptoJS.enc.Hex.parse(preimageHex); - return CryptoJS.AES.decrypt(uint8ArrayToHex(base64ToUint8Array(ciphertextBase64)), key, { - iv, - mode: CryptoJS.mode.CBC, - format: CryptoJS.format.Hex, - }).toString(CryptoJS.enc.Utf8); + // crypto-js's old implementation silently returned '' on malformed + // ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on + // malformed UTF-8 plaintext. @noble/ciphers throws on the former. We + // catch every throw and return '' — the call site at + // screen/lnd/lnurlPaySuccess.tsx renders this directly without a + // try/catch, so a misbehaving LNURL server should not crash the screen. + // Note: unlike crypto-js's strict `enc.Utf8` decoder, `uint8ArrayToString` + // is lenient on bad UTF-8 (mojibake instead of throw); this is strictly + // safer than the old behaviour for this user-facing path. + try { + const key = hexToUint8Array(preimageHex); + const iv = base64ToUint8Array(ivBase64); + const ct = base64ToUint8Array(ciphertextBase64); + const pt = cbc(key, iv).decrypt(ct); + return uint8ArrayToString(pt); + } catch (_) { + return ''; + } } getCommentAllowed(): number | false { diff --git a/package-lock.json b/package-lock.json index 802ffffb4..ff03645a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "@bugsnag/source-maps": "2.3.3", "@keystonehq/bc-ur-registry": "0.7.1", "@ngraveio/bc-ur": "1.1.13", - "@noble/hashes": "1.3.3", + "@noble/ciphers": "1.3.0", + "@noble/hashes": "1.8.0", "@noble/secp256k1": "3.1.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-clipboard/clipboard": "1.16.3", @@ -57,7 +58,6 @@ "buffer": "6.0.3", "coinselect": "github:BlueWallet/coinselect#35f8038", "crypto-browserify": "3.12.1", - "crypto-js": "4.2.0", "dayjs": "1.11.21", "detox": "20.51.3", "ecpair": "3.0.1", @@ -126,7 +126,6 @@ "@types/bip38": "^3.1.2", "@types/bs58check": "^2.1.0", "@types/create-hash": "^1.2.2", - "@types/crypto-js": "^4.2.2", "@types/jest": "^29.5.13", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", @@ -3497,6 +3496,18 @@ "eslint-scope": "5.1.1" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", @@ -3525,10 +3536,12 @@ } }, "node_modules/@noble/hashes": { - "version": "1.3.3", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "engines": { - "node": ">= 16" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -5263,11 +5276,6 @@ "@types/node": "*" } }, - "node_modules/@types/crypto-js": { - "version": "4.2.2", - "dev": true, - "license": "MIT" - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "dev": true, @@ -7955,10 +7963,6 @@ "node": ">= 0.10" } }, - "node_modules/crypto-js": { - "version": "4.2.0", - "license": "MIT" - }, "node_modules/css-select": { "version": "5.1.0", "license": "BSD-2-Clause", diff --git a/package.json b/package.json index d8e7177af..83dfd3003 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "@types/bip38": "^3.1.2", "@types/bs58check": "^2.1.0", "@types/create-hash": "^1.2.2", - "@types/crypto-js": "^4.2.2", "@types/jest": "^29.5.13", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", @@ -100,7 +99,8 @@ "@bugsnag/source-maps": "2.3.3", "@keystonehq/bc-ur-registry": "0.7.1", "@ngraveio/bc-ur": "1.1.13", - "@noble/hashes": "1.3.3", + "@noble/ciphers": "1.3.0", + "@noble/hashes": "1.8.0", "@noble/secp256k1": "3.1.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-clipboard/clipboard": "1.16.3", @@ -140,7 +140,6 @@ "buffer": "6.0.3", "coinselect": "github:BlueWallet/coinselect#35f8038", "crypto-browserify": "3.12.1", - "crypto-js": "4.2.0", "dayjs": "1.11.21", "detox": "20.51.3", "ecpair": "3.0.1", diff --git a/tests/unit/encryption.test.ts b/tests/unit/encryption.test.ts index b2f8712d2..03edb4ee2 100644 --- a/tests/unit/encryption.test.ts +++ b/tests/unit/encryption.test.ts @@ -43,4 +43,19 @@ describe('unit - encryption', function () { const decrypted = c.decrypt(crypted, 'password'); assert.deepEqual(data2decrypt, decrypted); }); + + it('can decrypt a ciphertext produced by the OpenSSL CLI (wire-format check)', () => { + // Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below): + // + // { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \ + // printf 'hello world this is plaintext' \ + // | openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5; \ + // } | base64 + // + // OpenSSL's `enc` only emits the `Salted__` envelope when it picks the salt itself; + // passing `-S ` suppresses the header, so we prepend it manually. Pins the + // on-disk format against an independent reference beyond crypto-js. + const crypted = 'U2FsdGVkX18BAgMEBQYHCMqtJuZaneiHrVN/oMPPLvFplovZbI1K+lulGJn7NAvn'; + assert.strictEqual(c.decrypt(crypted, 'mypassword'), 'hello world this is plaintext'); + }); }); diff --git a/tests/unit/evp-bytes-to-key.test.ts b/tests/unit/evp-bytes-to-key.test.ts new file mode 100644 index 000000000..3a52df125 --- /dev/null +++ b/tests/unit/evp-bytes-to-key.test.ts @@ -0,0 +1,51 @@ +import assert from 'assert'; + +import { evpBytesToKeyMd5 } from '../../blue_modules/encryption'; +import { hexToUint8Array, stringToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras'; + +describe('evpBytesToKeyMd5', () => { + // Vectors computed against the OpenSSL EVP_BytesToKey reference algorithm + // (MD5, 1 iteration). The KDF is purely deterministic, so a single fixed + // (password, salt) pair pins the bytes our wallet store relies on. + it('matches the OpenSSL CLI reference for password="mypassword"', () => { + // openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5 -p + const out = evpBytesToKeyMd5(stringToUint8Array('mypassword'), hexToUint8Array('0102030405060708'), 48); + assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), '20814c3ad75ac1d26c61a8e4702b5ff4d7baaee00c595bab71592aaf45bf41e4'); + assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '43269499cb6d59f4e3b9dda68098b673'); + }); + + it('matches a Node-crypto reference vector for a multi-word password', () => { + const out = evpBytesToKeyMd5(stringToUint8Array('correct horse'), hexToUint8Array('0102030405060708'), 48); + assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), 'bcf8d941d9291141709c9d56360eb7148e3960ab3dc44d832c4028568545c91d'); + assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '5a7a1d12207f801d2f6f4cf578e8708c'); + }); + + it('returns exactly the requested number of bytes', () => { + const pwd = stringToUint8Array('pw'); + const salt = hexToUint8Array('00000000000000ff'); + assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 1).length, 1); + assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 15).length, 15); + assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 16).length, 16); // one MD5 block exactly + assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 17).length, 17); // one block + 1 + assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 48).length, 48); // key + iv default + assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 65).length, 65); // multi-block + spillover + }); + + it('is a prefix-stable stream (same first N bytes regardless of total length)', () => { + const pwd = stringToUint8Array('xyz'); + const salt = hexToUint8Array('cafebabedeadbeef'); + const long = evpBytesToKeyMd5(pwd, salt, 64); + for (const n of [1, 16, 17, 32, 48]) { + assert.strictEqual(uint8ArrayToHex(evpBytesToKeyMd5(pwd, salt, n)), uint8ArrayToHex(long.subarray(0, n))); + } + }); + + it('rejects non-integer or negative byteLength', () => { + const pwd = stringToUint8Array('pw'); + const salt = hexToUint8Array('0102030405060708'); + assert.throws(() => evpBytesToKeyMd5(pwd, salt, -1)); + assert.throws(() => evpBytesToKeyMd5(pwd, salt, 1.5)); + assert.throws(() => evpBytesToKeyMd5(pwd, salt, NaN)); + assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 0).length, 0); + }); +}); diff --git a/tests/unit/lnurl.test.js b/tests/unit/lnurl.test.js index 876aa3ab8..67f27d28d 100644 --- a/tests/unit/lnurl.test.js +++ b/tests/unit/lnurl.test.js @@ -208,6 +208,17 @@ describe('LNURL', function () { assert.strictEqual(Lnurl.decipherAES(ciphertext, preimage, iv), '1234'); }); + + it('decipherAES returns empty string on malformed input (preserves crypto-js contract)', () => { + const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b'; + const validIv = 'eTGduB45hWTOxHj1dR+LJw=='; + // Non-block-aligned ciphertext — would throw under raw @noble/ciphers + assert.strictEqual(Lnurl.decipherAES('not-base64-aligned', preimage, validIv), ''); + // Bad PKCS7 padding (random 16-byte block won't unpad cleanly) + assert.strictEqual(Lnurl.decipherAES('AAAAAAAAAAAAAAAAAAAAAA==', preimage, validIv), ''); + // Empty ciphertext + assert.strictEqual(Lnurl.decipherAES('', preimage, validIv), ''); + }); }); describe('lightning address', function () {