REF: storage improvements

This commit is contained in:
Overtorment 2021-05-24 13:16:03 +01:00
parent ba098e822f
commit f00c5b4488
5 changed files with 170 additions and 65 deletions

View File

@ -29,7 +29,7 @@ const startAndDecrypt = async retry => {
} catch (error) {
// in case of exception reading from keystore, lets retry instead of assuming there is no storage and
// proceeding with no wallets
console.warn(error);
console.warn('exception loading from disk:', error);
wasException = true;
}
@ -38,7 +38,9 @@ const startAndDecrypt = async retry => {
try {
await new Promise(resolve => setTimeout(resolve, 3000)); // sleep
success = await BlueApp.loadFromDisk(password);
} catch (_) {}
} catch (error) {
console.warn('second exception loading from disk:', error);
}
}
if (success) {

View File

@ -1,6 +1,7 @@
/* global alert */
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNSecureKeyStore, { ACCESSIBLE } from 'react-native-secure-key-store';
import * as Keychain from 'react-native-keychain';
import {
HDLegacyBreadwalletWallet,
HDSegwitP2SHWallet,
@ -20,10 +21,12 @@ import {
SLIP39LegacyP2PKHWallet,
SLIP39SegwitBech32Wallet,
} from './';
import { randomBytes } from './rng';
const encryption = require('../blue_modules/encryption');
const Realm = require('realm');
const createHash = require('create-hash');
let usedBucketNum = false;
let savingInProgress = 0; // its both a flag and a counter of attempts to write to disk
export class AppStorage {
static FLAG_ENCRYPTED = 'data_encrypted';
@ -88,11 +91,36 @@ export class AppStorage {
}
};
/**
* @throws Error
* @param key {string}
* @returns {Promise<*>|null}
*/
getItemWithFallbackToRealm = async key => {
let value;
try {
return await this.getItem(key);
} catch (error) {
console.warn('error reading', key, error.message);
console.warn('fallback to realm');
const realmKeyValue = await this.openRealmKeyValue();
const obj = realmKeyValue.objectForPrimaryKey('KeyValue', key); // search for a realm object with a primary key
value = obj?.value;
realmKeyValue.close();
if (value) {
console.warn('successfully recovered', value.length, 'bytes from realm for key', key);
return value;
}
return null;
}
};
storageIsEncrypted = async () => {
let data;
try {
data = await this.getItem(AppStorage.FLAG_ENCRYPTED);
data = await this.getItemWithFallbackToRealm(AppStorage.FLAG_ENCRYPTED);
} catch (error) {
console.warn('error reading `' + AppStorage.FLAG_ENCRYPTED + '` key:', error.message);
return false;
}
@ -220,6 +248,58 @@ export class AppStorage {
});
}
/**
* Returns instace of the Realm database, which is encrypted by device unique id
* Database file is static.
*
* @returns {Promise<Realm>}
*/
async openRealmKeyValue() {
const service = 'realm_encryption_key';
let password;
const credentials = await Keychain.getGenericPassword({ service });
if (credentials) {
password = credentials.password;
} else {
const buf = await randomBytes(64);
password = buf.toString('hex');
await Keychain.setGenericPassword(service, password, { service });
}
const buf = Buffer.from(password, 'hex');
const encryptionKey = Int8Array.from(buf);
const path = 'keyvalue.realm';
const schema = [
{
name: 'KeyValue',
primaryKey: 'key',
properties: {
key: { type: 'string', indexed: true },
value: 'string', // stringified json, or whatever
},
},
];
return Realm.open({
schema,
path,
encryptionKey,
});
}
saveToRealmKeyValue(realmkeyValue, key, value) {
realmkeyValue.write(() => {
realmkeyValue.create(
'KeyValue',
{
key: key,
value: value,
},
Realm.UpdateMode.Modified,
);
});
}
/**
* Loads from storage all wallets and
* maps them to `this.wallets`
@ -228,7 +308,7 @@ export class AppStorage {
* @returns {Promise.<boolean>}
*/
async loadFromDisk(password) {
let data = await this.getItem('data');
let data = await this.getItemWithFallbackToRealm('data');
if (password) {
data = this.decryptData(data, password);
if (data) {
@ -414,71 +494,87 @@ export class AppStorage {
* @returns {Promise} Result of storage save
*/
async saveToDisk() {
const walletsToSave = [];
const realm = await this.getRealm();
for (const key of this.wallets) {
if (typeof key === 'boolean' || key.type === PlaceholderWallet.type) continue;
key.prepareForSerialization();
delete key.current;
const keyCloned = Object.assign({}, key); // stripped-down version of a wallet to save to secure keystore
if (key._hdWalletInstance) keyCloned._hdWalletInstance = Object.assign({}, key._hdWalletInstance);
this.offloadWalletToRealm(realm, key);
// stripping down:
if (key._txs_by_external_index) {
keyCloned._txs_by_external_index = {};
keyCloned._txs_by_internal_index = {};
}
if (key._hdWalletInstance) {
keyCloned._hdWalletInstance._txs_by_external_index = {};
keyCloned._hdWalletInstance._txs_by_internal_index = {};
}
walletsToSave.push(JSON.stringify({ ...keyCloned, type: keyCloned.type }));
if (savingInProgress) {
console.warn('saveToDisk is in progress');
if (++savingInProgress > 10) alert('Critical error. Last actions were not saved'); // should never happen
await new Promise(resolve => setTimeout(resolve, 1000 * savingInProgress)); // sleep
return this.saveToDisk();
}
realm.close();
let data = {
wallets: walletsToSave,
tx_metadata: this.tx_metadata,
};
savingInProgress = 1;
if (this.cachedPassword) {
// should find the correct bucket, encrypt and then save
let buckets = await this.getItem('data');
buckets = JSON.parse(buckets);
const newData = [];
let num = 0;
for (const bucket of buckets) {
let decrypted;
// if we had `usedBucketNum` during loadFromDisk(), no point to try to decode each bucket to find the one we
// need, we just to find bucket with the same index
if (usedBucketNum !== false) {
if (num === usedBucketNum) {
decrypted = true;
}
num++;
} 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);
}
if (!decrypted) {
// no luck decrypting, its not our bucket
newData.push(bucket);
} 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));
await this.setItem(AppStorage.FLAG_ENCRYPTED, '1');
}
}
data = newData;
} else {
await this.setItem(AppStorage.FLAG_ENCRYPTED, ''); // drop the flag
}
try {
return await this.setItem('data', JSON.stringify(data));
const walletsToSave = [];
const realm = await this.getRealm();
for (const key of this.wallets) {
if (typeof key === 'boolean' || key.type === PlaceholderWallet.type) continue;
key.prepareForSerialization();
delete key.current;
const keyCloned = Object.assign({}, key); // stripped-down version of a wallet to save to secure keystore
if (key._hdWalletInstance) keyCloned._hdWalletInstance = Object.assign({}, key._hdWalletInstance);
this.offloadWalletToRealm(realm, key);
// stripping down:
if (key._txs_by_external_index) {
keyCloned._txs_by_external_index = {};
keyCloned._txs_by_internal_index = {};
}
if (key._hdWalletInstance) {
keyCloned._hdWalletInstance._txs_by_external_index = {};
keyCloned._hdWalletInstance._txs_by_internal_index = {};
}
walletsToSave.push(JSON.stringify({ ...keyCloned, type: keyCloned.type }));
}
realm.close();
let data = {
wallets: walletsToSave,
tx_metadata: this.tx_metadata,
};
if (this.cachedPassword) {
// should find the correct bucket, encrypt and then save
let buckets = await this.getItemWithFallbackToRealm('data');
buckets = JSON.parse(buckets);
const newData = [];
let num = 0;
for (const bucket of buckets) {
let decrypted;
// if we had `usedBucketNum` during loadFromDisk(), no point to try to decode each bucket to find the one we
// need, we just to find bucket with the same index
if (usedBucketNum !== false) {
if (num === usedBucketNum) {
decrypted = true;
}
num++;
} 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);
}
if (!decrypted) {
// no luck decrypting, its not our bucket
newData.push(bucket);
} 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));
}
}
data = newData;
}
await this.setItem('data', JSON.stringify(data));
await this.setItem(AppStorage.FLAG_ENCRYPTED, this.cachedPassword ? '1' : '');
// now, backing up same data in realm:
const realmkeyValue = await this.openRealmKeyValue();
this.saveToRealmKeyValue(realmkeyValue, 'data', JSON.stringify(data));
this.saveToRealmKeyValue(realmkeyValue, AppStorage.FLAG_ENCRYPTED, this.cachedPassword ? '1' : '');
realmkeyValue.close();
} catch (error) {
alert(error.message);
console.error('save to disk exception:', error.message);
alert('save to disk exception: ' + error.message);
} finally {
savingInProgress = 0;
}
}

5
package-lock.json generated
View File

@ -17669,6 +17669,11 @@
"version": "git+https://github.com/BlueWallet/react-native-is-catalyst.git#a665155424ae5b865971f71ba2625b2c53983364",
"from": "git+https://github.com/BlueWallet/react-native-is-catalyst.git#v1.0.0"
},
"react-native-keychain": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/react-native-keychain/-/react-native-keychain-7.0.0.tgz",
"integrity": "sha512-tH26sgW4OxB/llXmhO+DajFISEUoF1Ip2+WSDMIgCt8SP1xRE81m2qFzgIOc/7StYsUERxHhDPkxvq2H0/Goig=="
},
"react-native-level-fs": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/react-native-level-fs/-/react-native-level-fs-3.0.1.tgz",

View File

@ -140,6 +140,7 @@
"react-native-image-picker": "3.5.0",
"react-native-inappbrowser-reborn": "https://github.com/BlueWallet/react-native-inappbrowser#fa2d8e1763e46dd12a7e53081e97a0f908049103",
"react-native-is-catalyst": "https://github.com/BlueWallet/react-native-is-catalyst#v1.0.0",
"react-native-keychain": "7.0.0",
"react-native-level-fs": "3.0.1",
"react-native-linear-gradient": "2.5.6",
"react-native-localize": "2.0.3",

View File

@ -22,6 +22,7 @@ jest.mock('@sentry/react-native', () => {
jest.mock('react-native-device-info', () => {
return {
getUniqueId: jest.fn().mockReturnValue('uniqueId'),
getSystemName: jest.fn(),
hasGmsSync: jest.fn().mockReturnValue(true),
hasHmsSync: jest.fn().mockReturnValue(false),