BlueWallet/blue_modules/encryption.ts
Ivan Vershigora 31eca3cc2d REF: scrypt + AES-256-GCM (v2 envelope), legacy v1 read-only
Encrypt now emits a v2: envelope — scrypt (N=2^15, r=8, p=1) KDF plus
AES-256-GCM. Decrypt still reads legacy Salted__ (EVP_BytesToKey-MD5 +
AES-256-CBC) ciphertexts, and lazily rewrites them as v2 on the first
successful unlock. Public encrypt/decrypt are now async.
2026-06-22 11:20:56 +01:00

152 lines
7.3 KiB
TypeScript

import { gcm, cbc } from '@noble/ciphers/aes';
import { md5 } from '@noble/hashes/legacy';
import { scryptAsync } from '@noble/hashes/scrypt';
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;
}
// =============================================================================
// v2: scrypt + AES-256-GCM. Always emitted by `encrypt`.
// -----------------------------------------------------------------------------
// Wire format: "v2:" + base64( salt(16) || nonce(12) || gcm_ciphertext_and_tag )
// KDF: scrypt N=2^15, r=8, p=1, dkLen=32. Memory-hard, GPU/ASIC-resistant.
// Cipher: AES-256-GCM. AEAD — auth-tag mismatch on wrong password → throw → false.
// =============================================================================
const V2_PREFIX = 'v2:';
const V2_SALT_LEN = 16;
const V2_NONCE_LEN = 12;
const V2_KEY_LEN = 32;
const V2_TAG_LEN = 16;
const V2_SCRYPT_OPTS = { N: 2 ** 15, r: 8, p: 1, dkLen: V2_KEY_LEN, asyncTick: 10 } as const;
async function deriveV2Key(password: string, salt: Uint8Array): Promise<Uint8Array> {
return scryptAsync(stringToUint8Array(password), salt, V2_SCRYPT_OPTS);
}
async function encryptV2(data: string, password: string): Promise<string> {
const salt = randomBytes(V2_SALT_LEN);
const nonce = randomBytes(V2_NONCE_LEN);
const key = await deriveV2Key(password, salt);
const ct = gcm(key, nonce).encrypt(stringToUint8Array(data));
return V2_PREFIX + uint8ArrayToBase64(concatUint8Arrays([salt, nonce, ct]));
}
async function decryptV2(data: string, password: string): Promise<string | false> {
try {
const envelope = base64ToUint8Array(data.slice(V2_PREFIX.length).replace(/\s+/g, ''));
if (envelope.length < V2_SALT_LEN + V2_NONCE_LEN + V2_TAG_LEN) return false;
const salt = envelope.subarray(0, V2_SALT_LEN);
const nonce = envelope.subarray(V2_SALT_LEN, V2_SALT_LEN + V2_NONCE_LEN);
const ct = envelope.subarray(V2_SALT_LEN + V2_NONCE_LEN);
const key = await deriveV2Key(password, salt);
const plain = gcm(key, nonce).decrypt(ct); // throws on auth-tag mismatch (wrong password / tampered)
// Deliberately no `length < 10` belt-and-suspenders here: GCM's auth tag
// is the wrong-password gate. Any failed decrypt has already thrown
// above; if we reach this line, `plain` is the genuine plaintext.
return new TextDecoder('utf-8', { fatal: true }).decode(plain);
} catch (_) {
return false;
}
}
// =============================================================================
// v1: EVP_BytesToKey-MD5 + AES-256-CBC. Read-only — kept for legacy ciphertexts.
// -----------------------------------------------------------------------------
// Wire format: base64( "Salted__"(8) || salt(8) || aes_cbc_pkcs7_ciphertext )
// New encryptions never emit this format. Existing on-device wallets stay
// readable forever; `decryptData` lazily rewrites them as v2 on the first
// successful unlock via `loadFromDisk` (the only opt-in caller).
// =============================================================================
const V1_SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]); // "Salted__"
const V1_SALT_LEN = 8;
const V1_KEY_LEN = 32;
const V1_IV_LEN = 16;
const V1_BLOCK_LEN = 16;
function decryptV1(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 < V1_SALT_MAGIC.length + V1_SALT_LEN + V1_BLOCK_LEN) return false;
if (!areUint8ArraysEqual(envelope.subarray(0, V1_SALT_MAGIC.length), V1_SALT_MAGIC)) return false;
const salt = envelope.subarray(V1_SALT_MAGIC.length, V1_SALT_MAGIC.length + V1_SALT_LEN);
const ciphertext = envelope.subarray(V1_SALT_MAGIC.length + V1_SALT_LEN);
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, V1_KEY_LEN + V1_IV_LEN);
const key = kdf.subarray(0, V1_KEY_LEN);
const iv = kdf.subarray(V1_KEY_LEN);
const plain = cbc(key, iv).decrypt(ciphertext);
// Strict UTF-8 — wrong-password decrypts that happen to survive PKCS7 unpad
// overwhelmingly fail here. crypto-js's `enc.Utf8` was strict; we match that
// gate via `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 treated as wrong-password garbage.
if (str.length < 10) return false;
return str;
} catch (_) {
return false;
}
}
// =============================================================================
// Public API. Always async (scrypt KDF is non-trivial work; `scryptAsync` keeps
// the JS thread responsive by yielding every ~`asyncTick` ms).
// =============================================================================
/**
* Encrypt `data` under `password` and return a `v2:`-prefixed base64 envelope
* (scrypt + AES-256-GCM). New writes always use this format; the legacy
* `Salted__` reader still handles ciphertexts produced by earlier app versions.
*/
export async function encrypt(data: string, password: string): Promise<string> {
if (data.length < 10) throw new Error('data length cant be < 10');
return encryptV2(data, password);
}
/**
* Decrypt either a `v2:` ciphertext (scrypt + GCM) or a legacy `Salted__`
* envelope (EVP_BytesToKey-MD5 + CBC). Any error (wrong password, tampered
* bytes, malformed input) collapses to `false`.
*/
export async function decrypt(data: string, password: string): Promise<string | false> {
if (typeof data !== 'string' || data.length === 0) return false;
if (data.startsWith(V2_PREFIX)) return decryptV2(data, password);
return decryptV1(data, password);
}