REF: swap crypto-js for @noble/ciphers + hashes

This commit is contained in:
Ivan Vershigora 2026-05-18 14:05:28 +01:00
parent d415f1a0b8
commit 276a9ea8f8
No known key found for this signature in database
GPG Key ID: DCCF7FB5ED2CEBD7
7 changed files with 206 additions and 40 deletions

View File

@ -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;
}
}

View File

@ -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 {

32
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 <hex>` 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');
});
});

View File

@ -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);
});
});

View File

@ -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 () {