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.
This commit is contained in:
parent
6639891c24
commit
31eca3cc2d
@ -1,5 +1,6 @@
|
||||
import { cbc } from '@noble/ciphers/aes';
|
||||
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';
|
||||
@ -36,39 +37,65 @@ export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLen
|
||||
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;
|
||||
// =============================================================================
|
||||
// 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;
|
||||
|
||||
/**
|
||||
* 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]));
|
||||
async function deriveV2Key(password: string, salt: Uint8Array): Promise<Uint8Array> {
|
||||
return scryptAsync(stringToUint8Array(password), salt, V2_SCRYPT_OPTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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
|
||||
@ -76,23 +103,49 @@ export function decrypt(data: string, password: string): string | false {
|
||||
// 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);
|
||||
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 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`).
|
||||
// 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 rejected.
|
||||
// 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 (e) {
|
||||
} 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);
|
||||
}
|
||||
|
||||
@ -173,7 +173,7 @@ export class BlueApp {
|
||||
isPasswordInUse = async (password: string) => {
|
||||
try {
|
||||
let data = await this.getItem('data');
|
||||
data = this.decryptData(data, password);
|
||||
data = await this.decryptData(data, password);
|
||||
return Boolean(data);
|
||||
} catch (_e) {
|
||||
return false;
|
||||
@ -181,18 +181,37 @@ export class BlueApp {
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterates through all values of `data` trying to
|
||||
* decrypt each one, and returns first one successfully decrypted
|
||||
* Iterates through all values of `data` trying to decrypt each one, and
|
||||
* returns the first one successfully decrypted.
|
||||
*
|
||||
* Pass `{ upgrade: true }` to opt into the lazy v1 → v2 rewrite on success.
|
||||
* The opt-in is deliberate: `isPasswordInUse` calls this method as a
|
||||
* read-only probe during the create-fake-storage flow, and must NOT have
|
||||
* the side-effect of mutating on-disk state (that would widen the same
|
||||
* plausible-deniability fingerprint leak the lazy upgrade is closing).
|
||||
* Only `loadFromDisk` (the real unlock path) opts in.
|
||||
*/
|
||||
decryptData(data: string, password: string): boolean | string {
|
||||
data = JSON.parse(data);
|
||||
let decrypted;
|
||||
async decryptData(data: string, password: string, opts?: { upgrade?: boolean }): Promise<boolean | string> {
|
||||
const buckets: string[] = JSON.parse(data);
|
||||
let num = 0;
|
||||
for (const value of data) {
|
||||
decrypted = encryption.decrypt(value, password);
|
||||
for (const value of buckets) {
|
||||
const decrypted = await encryption.decrypt(value, password);
|
||||
|
||||
if (decrypted) {
|
||||
usedBucketNum = num;
|
||||
// Lazy v1 → v2 upgrade (only when explicitly requested): if this
|
||||
// bucket is still in the legacy `Salted__` format, re-encrypt under
|
||||
// the same password so the on-disk fingerprint converges over time.
|
||||
// Decoy buckets the user never unlocks stay v1 — accepted
|
||||
// plausible-deniability tradeoff, documented in release notes.
|
||||
if (opts?.upgrade && !value.startsWith('v2:')) {
|
||||
try {
|
||||
buckets[num] = await encryption.encrypt(decrypted as string, password);
|
||||
await this.setItem('data', JSON.stringify(buckets));
|
||||
} catch (e) {
|
||||
console.warn('lazy v2 upgrade failed:', e);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
num++;
|
||||
@ -220,7 +239,7 @@ export class BlueApp {
|
||||
let data = await this.getItem('data');
|
||||
// TODO: refactor ^^^ (should not save & load to fetch data)
|
||||
|
||||
const encrypted = encryption.encrypt(data, password);
|
||||
const encrypted = await encryption.encrypt(data, password);
|
||||
data = [];
|
||||
data.push(encrypted); // putting in array as we might have many buckets with storages
|
||||
data = JSON.stringify(data);
|
||||
@ -247,7 +266,7 @@ export class BlueApp {
|
||||
|
||||
let buckets = await this.getItem('data');
|
||||
buckets = JSON.parse(buckets);
|
||||
buckets.push(encryption.encrypt(JSON.stringify(data), fakePassword));
|
||||
buckets.push(await encryption.encrypt(JSON.stringify(data), fakePassword));
|
||||
this.cachedPassword = fakePassword;
|
||||
const bucketsString = JSON.stringify(buckets);
|
||||
await this.setItem('data', bucketsString);
|
||||
@ -363,7 +382,7 @@ export class BlueApp {
|
||||
}
|
||||
let dataRaw = await this.getItemWithFallbackToRealm('data');
|
||||
if (password) {
|
||||
dataRaw = this.decryptData(dataRaw, password);
|
||||
dataRaw = await this.decryptData(dataRaw, password, { upgrade: true });
|
||||
if (dataRaw) {
|
||||
// password is good, cache it
|
||||
this.cachedPassword = password;
|
||||
@ -674,7 +693,7 @@ export class BlueApp {
|
||||
} else {
|
||||
// we dont have `usedBucketNum` for whatever reason, so lets try to decrypt each bucket after bucket
|
||||
// till we find the right one
|
||||
decrypted = encryption.decrypt(bucket, this.cachedPassword);
|
||||
decrypted = await encryption.decrypt(bucket, this.cachedPassword);
|
||||
}
|
||||
|
||||
if (!decrypted) {
|
||||
@ -683,7 +702,7 @@ export class BlueApp {
|
||||
} else {
|
||||
// decrypted ok, this is our bucket
|
||||
// we serialize our object's data, encrypt it, and add it to buckets
|
||||
newData.push(encryption.encrypt(JSON.stringify(data), this.cachedPassword));
|
||||
newData.push(await encryption.encrypt(JSON.stringify(data), this.cachedPassword));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -321,21 +321,19 @@ export default class Lnurl {
|
||||
}
|
||||
|
||||
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
|
||||
// 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.
|
||||
// crypto-js's old path either returned '' or threw on malformed ciphertext,
|
||||
// and `.toString(enc.Utf8)` strictly threw on invalid UTF-8. @noble/ciphers
|
||||
// throws on bad padding / non-16-aligned bytes. We wrap every throwing call
|
||||
// — base64/hex decode, AES-CBC decrypt, and a `fatal: true` UTF-8 decode —
|
||||
// and collapse all of them to '' so the user-facing call site at
|
||||
// screen/lnd/lnurlPaySuccess.tsx (no surrounding try/catch) cannot be tricked
|
||||
// by a misbehaving LNURL server into rendering mojibake or crashing.
|
||||
try {
|
||||
const key = hexToUint8Array(preimageHex);
|
||||
const iv = base64ToUint8Array(ivBase64);
|
||||
const ct = base64ToUint8Array(ciphertextBase64);
|
||||
const pt = cbc(key, iv).decrypt(ct);
|
||||
return uint8ArrayToString(pt);
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(pt);
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -252,8 +252,8 @@ export default class SelfTest extends Component {
|
||||
//
|
||||
|
||||
const data2encrypt = 'really long data string';
|
||||
const crypted = encryption.encrypt(data2encrypt, 'password');
|
||||
const decrypted = encryption.decrypt(crypted, 'password');
|
||||
const crypted = await encryption.encrypt(data2encrypt, 'password');
|
||||
const decrypted = await encryption.decrypt(crypted, 'password');
|
||||
|
||||
if (decrypted !== data2encrypt) {
|
||||
throw new Error('encryption lib is not ok');
|
||||
|
||||
@ -2,49 +2,49 @@ import assert from 'assert';
|
||||
|
||||
import * as c from '../../blue_modules/encryption';
|
||||
|
||||
jest.setTimeout(30 * 1000); // scrypt KDF (N=2^15) is ~200–400 ms per call
|
||||
|
||||
describe('unit - encryption', function () {
|
||||
it('encrypts and decrypts', function () {
|
||||
it('encrypts and decrypts', async function () {
|
||||
const data2encrypt = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
||||
const crypted = c.encrypt(data2encrypt, 'password');
|
||||
const decrypted = c.decrypt(crypted, 'password');
|
||||
const crypted = await c.encrypt(data2encrypt, 'password');
|
||||
const decrypted = await c.decrypt(crypted, 'password');
|
||||
|
||||
assert.ok(crypted);
|
||||
assert.ok(decrypted);
|
||||
assert.strictEqual(decrypted, data2encrypt);
|
||||
assert.ok(crypted !== data2encrypt);
|
||||
assert.ok(crypted.startsWith('v2:'), 'new encryptions must use the v2 envelope');
|
||||
|
||||
let decryptedWithBadPassword;
|
||||
try {
|
||||
decryptedWithBadPassword = c.decrypt(crypted, 'passwordBad');
|
||||
} catch (e) {}
|
||||
assert.ok(!decryptedWithBadPassword);
|
||||
const decryptedWithBadPassword = await c.decrypt(crypted, 'passwordBad');
|
||||
assert.strictEqual(decryptedWithBadPassword, false);
|
||||
|
||||
let exceptionRaised = false;
|
||||
try {
|
||||
c.encrypt('yolo', 'password');
|
||||
await c.encrypt('yolo', 'password');
|
||||
} catch (_) {
|
||||
exceptionRaised = true;
|
||||
}
|
||||
assert.ok(exceptionRaised);
|
||||
});
|
||||
|
||||
it('handles ok malformed data', function () {
|
||||
const decrypted = c.decrypt(
|
||||
it('handles ok malformed data', async function () {
|
||||
const decrypted = await c.decrypt(
|
||||
'U2FsdGVkX1/OSNdi0JrLANn9qdNEiXgP20MJgT13CMKC7xKe+sb7x0An6r8lzrYeL2vjoPm2Xi5I3UdBcsgjgh0TR4PypNdDaW1tW8LhFH1wVCh1hacrFsJjoKMBmdCn4IVMwtIffGPptqBrGZl+6kjOc3BBbgq4uaAavFIwTS86WdaRt9qAboBcoPJZxsj37othbZfZfl2GBTCWnR1tOYAbElKWv4lBwNQpX7HqX3wTQkAbamBslsH5FfZRY1c38lOHrZMwNSyxhgspydksTxKkhPqWQu3XWT4GpRoRuVvYlBNvJOCUu2JbiVSp4NiOMSfnA8ahvpCGRNy+qPWsXqmJtz9BwyzedzDkgg6QOqxXz4oOeEJa/XLKiuv3ItsLrZb+sSA6wjB1Cx6/Oh2vW7eiHjCITeC7KUK1fAxVwufLcprNkvG8qFzkOcHxDyzG+sNL0cMipAxhpMX7qIcYcZFoLYkQRQHpOZKZCIAdNTfPGJ7M4cxGM0V+Uuirjyn+KAPJwNElwmPpX8sTQyEqlIlEwVjFXBpz28N5RAGN2zzCzEjD8NVYQJ2QyHj0gfWe',
|
||||
'fakePassword',
|
||||
);
|
||||
assert.ok(!decrypted);
|
||||
});
|
||||
|
||||
it('can decrypt cipher created by CryptoJS@3.1.9-1', () => {
|
||||
it('can decrypt cipher created by CryptoJS@3.1.9-1 (legacy v1 path)', async () => {
|
||||
const data2decrypt = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
||||
const crypted =
|
||||
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||
const decrypted = c.decrypt(crypted, 'password');
|
||||
const decrypted = await c.decrypt(crypted, 'password');
|
||||
assert.deepEqual(data2decrypt, decrypted);
|
||||
});
|
||||
|
||||
it('can decrypt a ciphertext produced by the OpenSSL CLI (wire-format check)', () => {
|
||||
it('can decrypt a ciphertext produced by the OpenSSL CLI (legacy v1 wire-format check)', async () => {
|
||||
// Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below):
|
||||
//
|
||||
// { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \
|
||||
@ -56,6 +56,28 @@ describe('unit - encryption', function () {
|
||||
// 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');
|
||||
assert.strictEqual(await c.decrypt(crypted, 'mypassword'), 'hello world this is plaintext');
|
||||
});
|
||||
|
||||
it('roundtrips multi-byte UTF-8 (emoji / CJK) under v2', async () => {
|
||||
const data = '日本語テスト 🌅🔥🌊 multi-byte plaintext ✅';
|
||||
const crypted = await c.encrypt(data, 'pässwörd中');
|
||||
assert.ok(crypted.startsWith('v2:'));
|
||||
const decrypted = await c.decrypt(crypted, 'pässwörd中');
|
||||
assert.strictEqual(decrypted, data);
|
||||
});
|
||||
|
||||
it('returns false on tampered v2 ciphertext (AEAD auth-tag mismatch)', async () => {
|
||||
const crypted = await c.encrypt('legitimate payload bytes here', 'password');
|
||||
assert.ok(crypted.startsWith('v2:'));
|
||||
// Flip one base64 char near the end (inside the auth tag region) — must not decrypt
|
||||
const tampered = `${crypted.slice(0, -2)}${crypted.slice(-2) === 'AA' ? 'BB' : 'AA'}`;
|
||||
assert.strictEqual(await c.decrypt(tampered, 'password'), false);
|
||||
});
|
||||
|
||||
it('returns false on empty / non-string input', async () => {
|
||||
assert.strictEqual(await c.decrypt('', 'password'), false);
|
||||
// @ts-expect-error — runtime guard for non-string input
|
||||
assert.strictEqual(await c.decrypt(undefined, 'password'), false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -212,12 +212,16 @@ describe('LNURL', function () {
|
||||
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
|
||||
// Valid base64 that decodes to 13 bytes — not block-aligned for AES
|
||||
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), '');
|
||||
// Valid AES + valid PKCS7 padding but plaintext is invalid UTF-8 (0xff 0xfe 0xfd 0xfc + PKCS7).
|
||||
// Without a fatal UTF-8 decode the call would return mojibake; we want '' here so the
|
||||
// success screen renders a blank line instead of garbage characters.
|
||||
assert.strictEqual(Lnurl.decipherAES('AdX3qMNfEqZLW65Z8xk/fQ==', preimage, validIv), '');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -525,3 +525,70 @@ it('Appstorage - hashIt() works', async () => {
|
||||
const storage = new BlueApp();
|
||||
assert.strictEqual(storage.hashIt('hello'), '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
|
||||
});
|
||||
|
||||
it('Appstorage - lazy v1 → v2 upgrade rewrites legacy bucket on successful decrypt (opt-in)', async () => {
|
||||
// Legacy CryptoJS@3.1.9-1 ciphertext under password 'password' — same fixture
|
||||
// used in tests/unit/encryption.test.ts.
|
||||
const legacyV1 =
|
||||
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||
const expectedPlaintext = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
||||
|
||||
await AsyncStorage.setItem('data', JSON.stringify([legacyV1]));
|
||||
await AsyncStorage.setItem(BlueApp.FLAG_ENCRYPTED, '1');
|
||||
|
||||
const Storage = new BlueApp();
|
||||
const decrypted = await Storage.decryptData(JSON.stringify([legacyV1]), 'password', { upgrade: true });
|
||||
assert.strictEqual(decrypted, expectedPlaintext);
|
||||
|
||||
// On-disk bucket should have been rewritten as v2 by the lazy upgrade.
|
||||
const rewritten = JSON.parse(await AsyncStorage.getItem('data'));
|
||||
assert.strictEqual(rewritten.length, 1);
|
||||
assert.ok(rewritten[0].startsWith('v2:'), `expected v2: prefix after upgrade, got: ${rewritten[0].slice(0, 16)}…`);
|
||||
|
||||
// Sanity: the new v2 ciphertext still decrypts to the same plaintext.
|
||||
const Storage2 = new BlueApp();
|
||||
const reread = await Storage2.decryptData(await AsyncStorage.getItem('data'), 'password');
|
||||
assert.strictEqual(reread, expectedPlaintext);
|
||||
});
|
||||
|
||||
it('Appstorage - decryptData does NOT rewrite bucket without the upgrade opt-in (default read-only behaviour)', async () => {
|
||||
const legacyV1 =
|
||||
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||
const onDisk = JSON.stringify([legacyV1]);
|
||||
await AsyncStorage.setItem('data', onDisk);
|
||||
await AsyncStorage.setItem(BlueApp.FLAG_ENCRYPTED, '1');
|
||||
|
||||
// No opts → no side-effect. Critical for isPasswordInUse (PD probe path).
|
||||
const Storage = new BlueApp();
|
||||
const decrypted = await Storage.decryptData(onDisk, 'password');
|
||||
assert.ok(decrypted);
|
||||
|
||||
// On-disk state must be byte-exact unchanged.
|
||||
assert.strictEqual(await AsyncStorage.getItem('data'), onDisk);
|
||||
});
|
||||
|
||||
it('Appstorage - lazy v1 → v2 upgrade leaves untouched buckets at v1 (loop skips non-matching bucket)', async () => {
|
||||
// Decoy bucket FIRST (different password — decryptV1 returns false on it),
|
||||
// real bucket SECOND. Exercises the loop continuation path where the
|
||||
// upgrade has to skip a non-matching bucket and only upgrade the one
|
||||
// whose password we know. Models the plausible-deniability scenario where
|
||||
// decoy buckets the user does not unlock stay legacy.
|
||||
// Decoy bucket: base64 that decodes to non-"Salted__" bytes — fails the magic
|
||||
// check inside decryptV1, returns false, loop continues to the next bucket.
|
||||
// Stands in for a bucket whose password the user did not supply this session.
|
||||
const legacyV1Decoy = 'bm90LWEtdjEtY2lwaGVydGV4dC1qdXN0LXNvbWUtcmFuZG9tLWJ5dGVz';
|
||||
const legacyV1Real =
|
||||
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||
|
||||
await AsyncStorage.setItem('data', JSON.stringify([legacyV1Decoy, legacyV1Real]));
|
||||
await AsyncStorage.setItem(BlueApp.FLAG_ENCRYPTED, '1');
|
||||
|
||||
const Storage = new BlueApp();
|
||||
const decrypted = await Storage.decryptData(JSON.stringify([legacyV1Decoy, legacyV1Real]), 'password', { upgrade: true });
|
||||
assert.ok(decrypted);
|
||||
|
||||
const rewritten = JSON.parse(await AsyncStorage.getItem('data'));
|
||||
assert.strictEqual(rewritten.length, 2);
|
||||
assert.strictEqual(rewritten[0], legacyV1Decoy, 'decoy bucket must remain byte-exact unchanged');
|
||||
assert.ok(rewritten[1].startsWith('v2:'), 'real bucket should be upgraded to v2');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user