REF: swap crypto-js for @noble/ciphers + hashes
This commit is contained in:
parent
d415f1a0b8
commit
276a9ea8f8
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
32
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
51
tests/unit/evp-bytes-to-key.test.ts
Normal file
51
tests/unit/evp-bytes-to-key.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user