Compare commits

...

1 Commits

Author SHA1 Message Date
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
7 changed files with 248 additions and 85 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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