Skip to content

Commit 5a0732f

Browse files
committed
Make changePassword atomic
Implement backup and restore mechanics
1 parent 622f3ba commit 5a0732f

File tree

8 files changed

+327
-109
lines changed

8 files changed

+327
-109
lines changed

src/background/Wallet/Wallet.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export class Wallet {
190190
this.emitter = createNanoEvents();
191191

192192
this.id = id;
193-
this.walletStore = new WalletStore({}, 'wallet');
193+
this.walletStore = new WalletStore({});
194194
this.disposer.add(
195195
globalPreferences.on('change', (state, prevState) => {
196196
emitter.emit('globalPreferencesChange', state, prevState);
@@ -239,7 +239,7 @@ export class Wallet {
239239
if (!this.userCredentials) {
240240
throw new Error('Cannot save pending wallet: encryptionKey is null');
241241
}
242-
this.walletStore.save(this.id, this.userCredentials.encryptionKey, record);
242+
this.walletStore.save(this.id, this.userCredentials, record);
243243
}
244244

245245
async ready() {
@@ -294,25 +294,6 @@ export class Wallet {
294294
await this.syncWithWalletStore();
295295
}
296296

297-
async assignNewCredentials({
298-
params: { credentials, newCredentials },
299-
}: PublicMethodParams<{
300-
credentials: SessionCredentials;
301-
newCredentials: SessionCredentials;
302-
}>) {
303-
this.ensureRecord(this.record);
304-
this.record = await Model.reEncryptRecord(this.record, {
305-
credentials,
306-
newCredentials,
307-
});
308-
this.userCredentials = newCredentials;
309-
310-
const { encryptionKey } = this.userCredentials;
311-
await this.walletStore.encryptAndSave(this.id, encryptionKey, this.record);
312-
313-
this.setExpirationForSeedPhraseEncryptionKey(1000 * 120);
314-
}
315-
316297
async resetCredentials() {
317298
this.userCredentials = null;
318299
}

src/background/Wallet/persistence.ts

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,40 @@
11
import { produce } from 'immer';
22
import { PersistentStore } from 'src/modules/persistent-store';
3-
import type { Credentials } from '../account/Credentials';
3+
import { invariant } from 'src/shared/invariant';
4+
import { getError } from 'src/shared/errors/getError';
5+
import { ErrorWithEnumerableMessage } from 'src/shared/errors/errors';
6+
import type { User } from 'src/shared/types/User';
7+
import type { Credentials, SessionCredentials } from '../account/Credentials';
8+
import { emitter } from '../events';
9+
import { Account } from '../account/Account';
410
import type { WalletRecord } from './model/types';
511
import { WalletRecordModel as Model } from './WalletRecord';
612

713
type EncryptedWalletRecord = string;
814

915
type WalletStoreState = Record<string, EncryptedWalletRecord | undefined>;
1016

17+
export class InternalBackupError extends ErrorWithEnumerableMessage {
18+
didRestore: boolean;
19+
constructor(error: Error, { didRestore }: { didRestore: boolean }) {
20+
super(error.message);
21+
this.name = error.name !== 'Error' ? error.name : 'InternalBackupError';
22+
this.didRestore = didRestore;
23+
}
24+
}
25+
26+
type RecordBackup = { user: User; record: string };
27+
function stringifyBackup({ user, record }: RecordBackup): string {
28+
return JSON.stringify({ user, record });
29+
}
30+
31+
function parseBackup(value: string): RecordBackup {
32+
const parsed = JSON.parse(value) as RecordBackup;
33+
invariant(parsed.user, 'User not found in backup');
34+
invariant(parsed.record, 'Record not found in backup');
35+
return parsed;
36+
}
37+
1138
export class WalletStore extends PersistentStore<WalletStoreState> {
1239
static key = 'wallet';
1340
/** Store unencrypted "lastRecord" to avoid unnecessary stringifications */
@@ -42,25 +69,130 @@ export class WalletStore extends PersistentStore<WalletStoreState> {
4269
}
4370

4471
/** Prefer WalletStore['save'] unless necessary */
45-
async encryptAndSave(
72+
private async encryptAndSave(
4673
id: string,
47-
encryptionKey: string,
74+
credentials: Credentials,
4875
record: WalletRecord
4976
) {
50-
const encryptedRecord = await Model.encryptRecord(encryptionKey, record);
51-
this.setState((state) =>
77+
const encryptedRecord = await Model.encryptRecord(
78+
credentials.encryptionKey,
79+
record
80+
);
81+
await this.setState((state) =>
5282
produce(state, (draft) => {
5383
draft[id] = encryptedRecord;
5484
})
5585
);
5686
this.lastRecord = record;
5787
}
5888

59-
async save(id: string, encryptionKey: string, record: WalletRecord) {
89+
async save(id: string, credentials: Credentials, record: WalletRecord) {
6090
if (this.lastRecord === record) {
6191
return;
6292
}
63-
await this.encryptAndSave(id, encryptionKey, record);
93+
await this.encryptAndSave(id, credentials, record);
94+
}
95+
96+
async createBackup(id: string) {
97+
/**
98+
* Accessing user is a cross-concern, but this is the only way
99+
* to make our backup truly atomic and independent:
100+
* encrypted record relies on `salt` stored in the user object,
101+
* and for a robust backup recovery it's best to store this object
102+
* together with the encrypted record
103+
*/
104+
const user = await Account.readCurrentUser();
105+
const record = (await this.getSavedState())[id];
106+
invariant(record, `Record not found for id: ${id}`);
107+
invariant(user && user.id === id, `User not found for id: ${id}`);
108+
return this.setState({
109+
...this.state,
110+
[`backup:${id}`]: stringifyBackup({ user, record }),
111+
});
112+
}
113+
114+
async restoreFromBackup(id: string) {
115+
const key = `backup:${id}`;
116+
const state = await this.getSavedState();
117+
const saved = state[key];
118+
invariant(saved, `Backup not found for id: ${id}`);
119+
const { user, record } = parseBackup(saved);
120+
await Promise.all([
121+
this.setState((state) =>
122+
produce(state, (draft) => {
123+
draft[id] = record;
124+
delete draft[key];
125+
})
126+
),
127+
Account.writeCurrentUser(user),
128+
]);
129+
}
130+
131+
async restoreFromAnyBackup() {
132+
const state = await this.getSavedState();
133+
const key = Object.keys(state).find((key) => key.startsWith('backup:'));
134+
if (key) {
135+
await this.restoreFromBackup(key.split(':')[1]);
136+
} else {
137+
throw new Error('No backups found');
138+
}
139+
}
140+
141+
async clearBackup(id: string) {
142+
return this.setState((state) =>
143+
produce(state, (draft) => {
144+
const key = `backup:${id}`;
145+
delete draft[key];
146+
})
147+
);
148+
}
149+
150+
/**
151+
* Executes an operation with a backup and an automatic recovery.
152+
* Guarantees atomicity by restoring to the previous state if the operation fails.
153+
*/
154+
async withBackup(id: string, operation: () => Promise<unknown>) {
155+
await this.createBackup(id);
156+
try {
157+
await operation();
158+
await this.clearBackup(id);
159+
} catch (error) {
160+
try {
161+
await this.restoreFromBackup(id);
162+
emitter.emit('globalError', {
163+
name: 'internal_error',
164+
message: 'Atomic wallet update failed. Restored from backup.',
165+
});
166+
console.log('Successfully restored wallet record from backup'); // eslint-disable-line no-console
167+
} catch {
168+
emitter.emit('globalError', {
169+
name: 'internal_error',
170+
message: 'Atomic wallet update failed. Restore from backup failed.',
171+
});
172+
throw new InternalBackupError(getError(error), { didRestore: false });
173+
}
174+
throw error;
175+
}
176+
}
177+
178+
async reEncrypt({
179+
id,
180+
credentials,
181+
newCredentials,
182+
}: {
183+
id: string;
184+
credentials: SessionCredentials;
185+
newCredentials: SessionCredentials;
186+
}) {
187+
await this.ready();
188+
console.log('reading', { id, credentials, state: this.getState() });
189+
const currentRecord = await this.read(id, credentials);
190+
invariant(currentRecord, `Record not found for ${id}`);
191+
const newRecord = await Model.reEncryptRecord(currentRecord, {
192+
credentials,
193+
newCredentials,
194+
});
195+
await this.encryptAndSave(id, newCredentials, newRecord);
64196
}
65197

66198
deleteMany(keys: string[]) {

src/background/account/Account.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ export class Account extends EventEmitter<AccountEvents> {
9898
private notificationWindow: NotificationWindow;
9999

100100
isPendingNewUser: boolean;
101+
isWriteLocked = false;
101102

102-
private static async writeCurrentUser(user: User) {
103+
static async writeCurrentUser(user: User) {
103104
await BrowserStorage.set(currentUserKey, user);
104105
}
105106

@@ -213,13 +214,17 @@ export class Account extends EventEmitter<AccountEvents> {
213214
}
214215

215216
async initialize() {
216-
// Try to automatically login if credentials are found in storage
217-
const credentials = await Account.readCredentials();
218-
const user = await Account.readCurrentUser();
219-
if (user && credentials) {
220-
const valid = await this.verifyCredentials(user, credentials);
221-
if (valid) {
222-
await this.setUser(user, credentials);
217+
try {
218+
await this.wallet.walletStore.restoreFromAnyBackup();
219+
} catch {
220+
// Try to automatically login if credentials are found in storage
221+
const credentials = await Account.readCredentials();
222+
const user = await Account.readCurrentUser();
223+
if (user && credentials) {
224+
const valid = await this.verifyCredentials(user, credentials);
225+
if (valid) {
226+
await this.setUser(user, credentials);
227+
}
223228
}
224229
}
225230
}
@@ -295,15 +300,25 @@ export class Account extends EventEmitter<AccountEvents> {
295300
) {
296301
throw new Error('Full credentials are expected');
297302
}
298-
await this.wallet.assignNewCredentials({
299-
id: payloadId(),
300-
params: { newCredentials, credentials: currentCredentials },
301-
});
302-
// Update local state only if the above call was successful
303-
this.user = updatedUser;
304-
this.encryptionKey = newCredentials.encryptionKey;
305-
await Account.writeCurrentUser(this.user);
306-
this.emit('authenticated');
303+
await this.logout();
304+
const { walletStore } = this.wallet;
305+
try {
306+
this.isWriteLocked = true;
307+
await walletStore.withBackup(currentCredentials.id, async () => {
308+
await walletStore.reEncrypt({
309+
id: currentCredentials.id,
310+
credentials: currentCredentials,
311+
newCredentials,
312+
});
313+
await Account.writeCurrentUser(updatedUser);
314+
await this.login(updatedUser, newPassword);
315+
});
316+
} catch (error) {
317+
await this.login(currentUser, currentPassword);
318+
throw error;
319+
} finally {
320+
this.isWriteLocked = false;
321+
}
307322
}
308323

309324
async setUser(
@@ -422,6 +437,7 @@ export class AccountPublicRPC {
422437
user: PublicUser;
423438
password: string;
424439
}>): Promise<PublicUser | null> {
440+
invariant(!this.account.isWriteLocked, 'Atomic operation in progress');
425441
const currentUser = await this.verifyUser(user);
426442
const canAuthorize = await this.account.verifyPassword(
427443
currentUser,

src/background/events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const emitter = createNanoEvents<{
3737
chainChanged: (chain: Chain, origin: string) => void;
3838
'ui:chainSelected': (chain: Chain) => void;
3939
globalError: (data: {
40-
name: 'network_error' | 'signing_error';
40+
name: 'network_error' | 'signing_error' | 'internal_error';
4141
message: string;
4242
}) => void;
4343
switchChainError: (chainId: ChainId, origin: string, error: unknown) => void;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Store } from 'store-unit';
2+
3+
type Options<S> = {
4+
retrieve: (key: string) => Promise<S | undefined>;
5+
save: (key: string, value: S) => Promise<void>;
6+
};
7+
8+
export class PersistentStoreBase<T> extends Store<T> {
9+
protected key: string;
10+
protected isReady: boolean;
11+
private readyPromise: Promise<void>;
12+
13+
public options: Options<T>;
14+
public defaultOptions: Options<T> = {
15+
retrieve: () => {
16+
throw new Error('PersistentStore:retrieve Not Implemented');
17+
},
18+
save: () => {
19+
throw new Error('PersistentStore:save Not Implemented');
20+
},
21+
};
22+
23+
constructor(initialState: T, key: string, options: Partial<Options<T>> = {}) {
24+
super(initialState);
25+
this.key = key;
26+
this.options = { ...this.defaultOptions, ...options };
27+
this.isReady = false;
28+
this.readyPromise = this.restore();
29+
this.on('change', (state) => {
30+
// only write to disk after restore so that we're not making
31+
// a redundant write on each initialization
32+
if (this.isReady) {
33+
this.options.save(this.key, state);
34+
}
35+
});
36+
}
37+
38+
getState() {
39+
if (!this.isReady) {
40+
throw new Error('Do not access getState() before checking ready()');
41+
}
42+
return super.getState();
43+
}
44+
45+
setState(...args: Parameters<Store<T>['setState']>) {
46+
if (!this.isReady) {
47+
if (process.env.NODE_ENV === 'development') {
48+
// Throw only in dev mode in case some production flow depends on
49+
// setting state sooner. Before this refactoring it was possible, so
50+
// it shouldn't really break anything
51+
throw new Error(
52+
'You are trying to write to a PersistentStore before {ready}'
53+
);
54+
}
55+
}
56+
super.setState(...args);
57+
return this.options.save(this.key, this.state);
58+
}
59+
60+
async restore() {
61+
const saved = await this.options.retrieve(this.key);
62+
if (saved) {
63+
super.setState(saved);
64+
}
65+
this.isReady = true;
66+
}
67+
68+
async ready(): Promise<void> {
69+
return this.isReady ? Promise.resolve() : this.readyPromise;
70+
}
71+
72+
async getSavedState() {
73+
await this.ready();
74+
return this.getState();
75+
}
76+
}

0 commit comments

Comments
 (0)