99 lines
4.4 KiB
TypeScript
99 lines
4.4 KiB
TypeScript
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 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 {
|
|
try {
|
|
// 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;
|
|
}
|
|
}
|