Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 { md5 } from '@noble/hashes/legacy';
|
||||||
|
import { scryptAsync } from '@noble/hashes/scrypt';
|
||||||
import { randomBytes } from '@noble/hashes/utils';
|
import { randomBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras';
|
import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras';
|
||||||
@ -36,39 +37,65 @@ export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLen
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Salted__" — OpenSSL envelope magic. Hardcoded as bytes so the wire
|
// =============================================================================
|
||||||
// format cannot drift through any encoder.
|
// v2: scrypt + AES-256-GCM. Always emitted by `encrypt`.
|
||||||
const SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]);
|
// -----------------------------------------------------------------------------
|
||||||
const SALT_LEN = 8;
|
// Wire format: "v2:" + base64( salt(16) || nonce(12) || gcm_ciphertext_and_tag )
|
||||||
const KEY_LEN = 32;
|
// KDF: scrypt N=2^15, r=8, p=1, dkLen=32. Memory-hard, GPU/ASIC-resistant.
|
||||||
const IV_LEN = 16;
|
// Cipher: AES-256-GCM. AEAD — auth-tag mismatch on wrong password → throw → false.
|
||||||
const BLOCK_LEN = 16;
|
// =============================================================================
|
||||||
|
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> {
|
||||||
* AES-256-CBC encrypt with the OpenSSL "Salted__" envelope, EVP_BytesToKey-MD5
|
return scryptAsync(stringToUint8Array(password), salt, V2_SCRYPT_OPTS);
|
||||||
* 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 encryptV2(data: string, password: string): Promise<string> {
|
||||||
* Inverse of `encrypt`. Accepts the legacy CryptoJS wire format and returns
|
const salt = randomBytes(V2_SALT_LEN);
|
||||||
* the original UTF-8 plaintext. Any error (bad base64, missing magic, wrong
|
const nonce = randomBytes(V2_NONCE_LEN);
|
||||||
* password, bad padding) collapses to `false`.
|
const key = await deriveV2Key(password, salt);
|
||||||
*/
|
const ct = gcm(key, nonce).encrypt(stringToUint8Array(data));
|
||||||
export function decrypt(data: string, password: string): string | false {
|
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 {
|
try {
|
||||||
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
|
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
|
||||||
// export/import flows (manual file paste, clipboard transit, email-based
|
// 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
|
// before strict base64 decode so legacy backups still open. `\s` does not
|
||||||
// include `=`, so base64 padding survives.
|
// include `=`, so base64 padding survives.
|
||||||
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
|
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
|
||||||
if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false;
|
if (envelope.length < V1_SALT_MAGIC.length + V1_SALT_LEN + V1_BLOCK_LEN) return false;
|
||||||
if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false;
|
if (!areUint8ArraysEqual(envelope.subarray(0, V1_SALT_MAGIC.length), V1_SALT_MAGIC)) return false;
|
||||||
const salt = envelope.subarray(SALT_MAGIC.length, SALT_MAGIC.length + SALT_LEN);
|
const salt = envelope.subarray(V1_SALT_MAGIC.length, V1_SALT_MAGIC.length + V1_SALT_LEN);
|
||||||
const ciphertext = envelope.subarray(SALT_MAGIC.length + SALT_LEN);
|
const ciphertext = envelope.subarray(V1_SALT_MAGIC.length + V1_SALT_LEN);
|
||||||
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
|
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, V1_KEY_LEN + V1_IV_LEN);
|
||||||
const key = kdf.subarray(0, KEY_LEN);
|
const key = kdf.subarray(0, V1_KEY_LEN);
|
||||||
const iv = kdf.subarray(KEY_LEN);
|
const iv = kdf.subarray(V1_KEY_LEN);
|
||||||
const plain = cbc(key, iv).decrypt(ciphertext);
|
const plain = cbc(key, iv).decrypt(ciphertext);
|
||||||
// Strict UTF-8 decode — wrong-password decrypts that happen to survive
|
// Strict UTF-8 — wrong-password decrypts that happen to survive PKCS7 unpad
|
||||||
// PKCS7 unpadding overwhelmingly fail here (crypto-js's `enc.Utf8` was
|
// overwhelmingly fail here. crypto-js's `enc.Utf8` was strict; we match that
|
||||||
// strict too; we preserve that gate by using `fatal: true`).
|
// gate via `fatal: true`.
|
||||||
const str = new TextDecoder('utf-8', { fatal: true }).decode(plain);
|
const str = new TextDecoder('utf-8', { fatal: true }).decode(plain);
|
||||||
// Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars
|
// Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars (enforced
|
||||||
// (enforced by encrypt()), so anything shorter is rejected.
|
// by encrypt()), so anything shorter is treated as wrong-password garbage.
|
||||||
if (str.length < 10) return false;
|
if (str.length < 10) return false;
|
||||||
return str;
|
return str;
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return false;
|
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) => {
|
isPasswordInUse = async (password: string) => {
|
||||||
try {
|
try {
|
||||||
let data = await this.getItem('data');
|
let data = await this.getItem('data');
|
||||||
data = this.decryptData(data, password);
|
data = await this.decryptData(data, password);
|
||||||
return Boolean(data);
|
return Boolean(data);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
return false;
|
return false;
|
||||||
@ -181,18 +181,37 @@ export class BlueApp {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterates through all values of `data` trying to
|
* Iterates through all values of `data` trying to decrypt each one, and
|
||||||
* decrypt each one, and returns first one successfully decrypted
|
* 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 {
|
async decryptData(data: string, password: string, opts?: { upgrade?: boolean }): Promise<boolean | string> {
|
||||||
data = JSON.parse(data);
|
const buckets: string[] = JSON.parse(data);
|
||||||
let decrypted;
|
|
||||||
let num = 0;
|
let num = 0;
|
||||||
for (const value of data) {
|
for (const value of buckets) {
|
||||||
decrypted = encryption.decrypt(value, password);
|
const decrypted = await encryption.decrypt(value, password);
|
||||||
|
|
||||||
if (decrypted) {
|
if (decrypted) {
|
||||||
usedBucketNum = num;
|
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;
|
return decrypted;
|
||||||
}
|
}
|
||||||
num++;
|
num++;
|
||||||
@ -220,7 +239,7 @@ export class BlueApp {
|
|||||||
let data = await this.getItem('data');
|
let data = await this.getItem('data');
|
||||||
// TODO: refactor ^^^ (should not save & load to fetch 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 = [];
|
||||||
data.push(encrypted); // putting in array as we might have many buckets with storages
|
data.push(encrypted); // putting in array as we might have many buckets with storages
|
||||||
data = JSON.stringify(data);
|
data = JSON.stringify(data);
|
||||||
@ -247,7 +266,7 @@ export class BlueApp {
|
|||||||
|
|
||||||
let buckets = await this.getItem('data');
|
let buckets = await this.getItem('data');
|
||||||
buckets = JSON.parse(buckets);
|
buckets = JSON.parse(buckets);
|
||||||
buckets.push(encryption.encrypt(JSON.stringify(data), fakePassword));
|
buckets.push(await encryption.encrypt(JSON.stringify(data), fakePassword));
|
||||||
this.cachedPassword = fakePassword;
|
this.cachedPassword = fakePassword;
|
||||||
const bucketsString = JSON.stringify(buckets);
|
const bucketsString = JSON.stringify(buckets);
|
||||||
await this.setItem('data', bucketsString);
|
await this.setItem('data', bucketsString);
|
||||||
@ -363,7 +382,7 @@ export class BlueApp {
|
|||||||
}
|
}
|
||||||
let dataRaw = await this.getItemWithFallbackToRealm('data');
|
let dataRaw = await this.getItemWithFallbackToRealm('data');
|
||||||
if (password) {
|
if (password) {
|
||||||
dataRaw = this.decryptData(dataRaw, password);
|
dataRaw = await this.decryptData(dataRaw, password, { upgrade: true });
|
||||||
if (dataRaw) {
|
if (dataRaw) {
|
||||||
// password is good, cache it
|
// password is good, cache it
|
||||||
this.cachedPassword = password;
|
this.cachedPassword = password;
|
||||||
@ -674,7 +693,7 @@ export class BlueApp {
|
|||||||
} else {
|
} else {
|
||||||
// we dont have `usedBucketNum` for whatever reason, so lets try to decrypt each bucket after bucket
|
// we dont have `usedBucketNum` for whatever reason, so lets try to decrypt each bucket after bucket
|
||||||
// till we find the right one
|
// till we find the right one
|
||||||
decrypted = encryption.decrypt(bucket, this.cachedPassword);
|
decrypted = await encryption.decrypt(bucket, this.cachedPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decrypted) {
|
if (!decrypted) {
|
||||||
@ -683,7 +702,7 @@ export class BlueApp {
|
|||||||
} else {
|
} else {
|
||||||
// decrypted ok, this is our bucket
|
// decrypted ok, this is our bucket
|
||||||
// we serialize our object's data, encrypt it, and add it to buckets
|
// 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 {
|
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
|
||||||
// crypto-js's old implementation silently returned '' on malformed
|
// crypto-js's old path either returned '' or threw on malformed ciphertext,
|
||||||
// ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on
|
// and `.toString(enc.Utf8)` strictly threw on invalid UTF-8. @noble/ciphers
|
||||||
// malformed UTF-8 plaintext. @noble/ciphers throws on the former. We
|
// throws on bad padding / non-16-aligned bytes. We wrap every throwing call
|
||||||
// catch every throw and return '' — the call site at
|
// — base64/hex decode, AES-CBC decrypt, and a `fatal: true` UTF-8 decode —
|
||||||
// screen/lnd/lnurlPaySuccess.tsx renders this directly without a
|
// and collapse all of them to '' so the user-facing call site at
|
||||||
// try/catch, so a misbehaving LNURL server should not crash the screen.
|
// screen/lnd/lnurlPaySuccess.tsx (no surrounding try/catch) cannot be tricked
|
||||||
// Note: unlike crypto-js's strict `enc.Utf8` decoder, `uint8ArrayToString`
|
// by a misbehaving LNURL server into rendering mojibake or crashing.
|
||||||
// is lenient on bad UTF-8 (mojibake instead of throw); this is strictly
|
|
||||||
// safer than the old behaviour for this user-facing path.
|
|
||||||
try {
|
try {
|
||||||
const key = hexToUint8Array(preimageHex);
|
const key = hexToUint8Array(preimageHex);
|
||||||
const iv = base64ToUint8Array(ivBase64);
|
const iv = base64ToUint8Array(ivBase64);
|
||||||
const ct = base64ToUint8Array(ciphertextBase64);
|
const ct = base64ToUint8Array(ciphertextBase64);
|
||||||
const pt = cbc(key, iv).decrypt(ct);
|
const pt = cbc(key, iv).decrypt(ct);
|
||||||
return uint8ArrayToString(pt);
|
return new TextDecoder('utf-8', { fatal: true }).decode(pt);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -252,8 +252,8 @@ export default class SelfTest extends Component {
|
|||||||
//
|
//
|
||||||
|
|
||||||
const data2encrypt = 'really long data string';
|
const data2encrypt = 'really long data string';
|
||||||
const crypted = encryption.encrypt(data2encrypt, 'password');
|
const crypted = await encryption.encrypt(data2encrypt, 'password');
|
||||||
const decrypted = encryption.decrypt(crypted, 'password');
|
const decrypted = await encryption.decrypt(crypted, 'password');
|
||||||
|
|
||||||
if (decrypted !== data2encrypt) {
|
if (decrypted !== data2encrypt) {
|
||||||
throw new Error('encryption lib is not ok');
|
throw new Error('encryption lib is not ok');
|
||||||
|
|||||||
@ -2,49 +2,49 @@ import assert from 'assert';
|
|||||||
|
|
||||||
import * as c from '../../blue_modules/encryption';
|
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 () {
|
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 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 crypted = await c.encrypt(data2encrypt, 'password');
|
||||||
const decrypted = c.decrypt(crypted, 'password');
|
const decrypted = await c.decrypt(crypted, 'password');
|
||||||
|
|
||||||
assert.ok(crypted);
|
assert.ok(crypted);
|
||||||
assert.ok(decrypted);
|
assert.ok(decrypted);
|
||||||
assert.strictEqual(decrypted, data2encrypt);
|
assert.strictEqual(decrypted, data2encrypt);
|
||||||
assert.ok(crypted !== data2encrypt);
|
assert.ok(crypted !== data2encrypt);
|
||||||
|
assert.ok(crypted.startsWith('v2:'), 'new encryptions must use the v2 envelope');
|
||||||
|
|
||||||
let decryptedWithBadPassword;
|
const decryptedWithBadPassword = await c.decrypt(crypted, 'passwordBad');
|
||||||
try {
|
assert.strictEqual(decryptedWithBadPassword, false);
|
||||||
decryptedWithBadPassword = c.decrypt(crypted, 'passwordBad');
|
|
||||||
} catch (e) {}
|
|
||||||
assert.ok(!decryptedWithBadPassword);
|
|
||||||
|
|
||||||
let exceptionRaised = false;
|
let exceptionRaised = false;
|
||||||
try {
|
try {
|
||||||
c.encrypt('yolo', 'password');
|
await c.encrypt('yolo', 'password');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
exceptionRaised = true;
|
exceptionRaised = true;
|
||||||
}
|
}
|
||||||
assert.ok(exceptionRaised);
|
assert.ok(exceptionRaised);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles ok malformed data', function () {
|
it('handles ok malformed data', async function () {
|
||||||
const decrypted = c.decrypt(
|
const decrypted = await c.decrypt(
|
||||||
'U2FsdGVkX1/OSNdi0JrLANn9qdNEiXgP20MJgT13CMKC7xKe+sb7x0An6r8lzrYeL2vjoPm2Xi5I3UdBcsgjgh0TR4PypNdDaW1tW8LhFH1wVCh1hacrFsJjoKMBmdCn4IVMwtIffGPptqBrGZl+6kjOc3BBbgq4uaAavFIwTS86WdaRt9qAboBcoPJZxsj37othbZfZfl2GBTCWnR1tOYAbElKWv4lBwNQpX7HqX3wTQkAbamBslsH5FfZRY1c38lOHrZMwNSyxhgspydksTxKkhPqWQu3XWT4GpRoRuVvYlBNvJOCUu2JbiVSp4NiOMSfnA8ahvpCGRNy+qPWsXqmJtz9BwyzedzDkgg6QOqxXz4oOeEJa/XLKiuv3ItsLrZb+sSA6wjB1Cx6/Oh2vW7eiHjCITeC7KUK1fAxVwufLcprNkvG8qFzkOcHxDyzG+sNL0cMipAxhpMX7qIcYcZFoLYkQRQHpOZKZCIAdNTfPGJ7M4cxGM0V+Uuirjyn+KAPJwNElwmPpX8sTQyEqlIlEwVjFXBpz28N5RAGN2zzCzEjD8NVYQJ2QyHj0gfWe',
|
'U2FsdGVkX1/OSNdi0JrLANn9qdNEiXgP20MJgT13CMKC7xKe+sb7x0An6r8lzrYeL2vjoPm2Xi5I3UdBcsgjgh0TR4PypNdDaW1tW8LhFH1wVCh1hacrFsJjoKMBmdCn4IVMwtIffGPptqBrGZl+6kjOc3BBbgq4uaAavFIwTS86WdaRt9qAboBcoPJZxsj37othbZfZfl2GBTCWnR1tOYAbElKWv4lBwNQpX7HqX3wTQkAbamBslsH5FfZRY1c38lOHrZMwNSyxhgspydksTxKkhPqWQu3XWT4GpRoRuVvYlBNvJOCUu2JbiVSp4NiOMSfnA8ahvpCGRNy+qPWsXqmJtz9BwyzedzDkgg6QOqxXz4oOeEJa/XLKiuv3ItsLrZb+sSA6wjB1Cx6/Oh2vW7eiHjCITeC7KUK1fAxVwufLcprNkvG8qFzkOcHxDyzG+sNL0cMipAxhpMX7qIcYcZFoLYkQRQHpOZKZCIAdNTfPGJ7M4cxGM0V+Uuirjyn+KAPJwNElwmPpX8sTQyEqlIlEwVjFXBpz28N5RAGN2zzCzEjD8NVYQJ2QyHj0gfWe',
|
||||||
'fakePassword',
|
'fakePassword',
|
||||||
);
|
);
|
||||||
assert.ok(!decrypted);
|
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 data2decrypt = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
||||||
const crypted =
|
const crypted =
|
||||||
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||||
const decrypted = c.decrypt(crypted, 'password');
|
const decrypted = await c.decrypt(crypted, 'password');
|
||||||
assert.deepEqual(data2decrypt, decrypted);
|
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):
|
// Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below):
|
||||||
//
|
//
|
||||||
// { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \
|
// { 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
|
// passing `-S <hex>` suppresses the header, so we prepend it manually. Pins the
|
||||||
// on-disk format against an independent reference beyond crypto-js.
|
// on-disk format against an independent reference beyond crypto-js.
|
||||||
const crypted = 'U2FsdGVkX18BAgMEBQYHCMqtJuZaneiHrVN/oMPPLvFplovZbI1K+lulGJn7NAvn';
|
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)', () => {
|
it('decipherAES returns empty string on malformed input (preserves crypto-js contract)', () => {
|
||||||
const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b';
|
const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b';
|
||||||
const validIv = 'eTGduB45hWTOxHj1dR+LJw==';
|
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), '');
|
assert.strictEqual(Lnurl.decipherAES('not-base64-aligned', preimage, validIv), '');
|
||||||
// Bad PKCS7 padding (random 16-byte block won't unpad cleanly)
|
// Bad PKCS7 padding (random 16-byte block won't unpad cleanly)
|
||||||
assert.strictEqual(Lnurl.decipherAES('AAAAAAAAAAAAAAAAAAAAAA==', preimage, validIv), '');
|
assert.strictEqual(Lnurl.decipherAES('AAAAAAAAAAAAAAAAAAAAAA==', preimage, validIv), '');
|
||||||
// Empty ciphertext
|
// Empty ciphertext
|
||||||
assert.strictEqual(Lnurl.decipherAES('', preimage, validIv), '');
|
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();
|
const storage = new BlueApp();
|
||||||
assert.strictEqual(storage.hashIt('hello'), '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
|
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