Skip to content

Commit a13e32a

Browse files
authored
Merge pull request #961 from wwWallet/offline-sync
Keystore Offline Synchronization
2 parents 77bfad7 + 989e32c commit a13e32a

File tree

5 files changed

+135
-40
lines changed

5 files changed

+135
-40
lines changed

src/api/index.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,26 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
103103
const [cachedUsers] = useLocalStorage<CachedUser[] | null>("cachedUsers", null);
104104

105105
const [sessionState, setSessionState, clearSessionState] = useSessionStorage<SessionState | null>("sessionState", null);
106-
const clearSessionStorage = useClearStorages(clearAppToken, clearSessionState);
107-
108-
const navigate = useNavigate();
109106

110107
/**
111108
* Synchronization tag for the encrypted private data. To prevent data loss,
112109
* this MUST be refreshed only when a new version of the private data is
113110
* loaded into the keystore or successfully uploaded to the server.
114111
*/
115-
const [privateDataEtag, setPrivateDataEtag] = useLocalStorage<string | null>("privateDataEtag", null);
112+
const getPrivateDataEtag = useCallback(() => {
113+
return jsonParseTaggedBinary(localStorage.getItem('privateDataEtag'));
114+
}, []);
115+
116+
const setPrivateDataEtag = useCallback((v: string) => {
117+
localStorage.setItem('privateDataEtag', jsonStringifyTaggedBinary(v));
118+
}, []);
119+
120+
const removePrivateDataEtag = useCallback(() => {
121+
localStorage.removeItem('privateDataEtag');
122+
}, []);
123+
124+
const navigate = useNavigate();
125+
const clearSessionStorage = useClearStorages(clearAppToken, clearSessionState);
116126

117127
const getAppToken = useCallback((): string | null => {
118128
return appToken;
@@ -151,9 +161,9 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
151161
): { [header: string]: string } => {
152162
return {
153163
...buildGetHeaders(headers, options),
154-
...(privateDataEtag ? { 'X-Private-Data-If-Match': privateDataEtag } : {}),
164+
...(getPrivateDataEtag() ? { 'X-Private-Data-If-Match': getPrivateDataEtag() } : {}),
155165
};
156-
}, [buildGetHeaders, privateDataEtag]);
166+
}, [buildGetHeaders, getPrivateDataEtag]);
157167

158168
const getWithLocalDbKey = useCallback(async (
159169
path: string,
@@ -283,7 +293,10 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
283293
>> => {
284294

285295
try {
286-
const getPrivateDataResponse = await get('/user/session/private-data', { headers: { 'If-None-Match': privateDataEtag } });
296+
if (!isOnline) {
297+
return Ok.EMPTY;
298+
}
299+
const getPrivateDataResponse = await get('/user/session/private-data', { headers: { 'If-None-Match': getPrivateDataEtag() } });
287300
if (getPrivateDataResponse.status === 304) {
288301
return Ok.EMPTY; // already synced
289302
}
@@ -304,7 +317,7 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
304317
return Err('syncFailed');
305318
}
306319

307-
}, [privateDataEtag, get, navigate]);
320+
}, [getPrivateDataEtag, get, navigate, isOnline]);
308321

309322
const updateShowWelcome = useCallback((showWelcome: boolean): void => {
310323
if (sessionState) {
@@ -325,8 +338,9 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
325338

326339
const clearSession = useCallback((): void => {
327340
clearSessionStorage();
341+
removePrivateDataEtag();
328342
events.dispatchEvent(new CustomEvent<ClearSessionEvent>(CLEAR_SESSION_EVENT));
329-
}, [clearSessionStorage]);
343+
}, [clearSessionStorage, removePrivateDataEtag]);
330344

331345
const setSession = useCallback(async (
332346
response: AxiosResponse,
@@ -354,10 +368,29 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
354368
options?: { appToken?: string }
355369
): Promise<void> => {
356370
try {
371+
async function writeOnIndexedDB() {
372+
if (!userHandle) {
373+
return;
374+
}
375+
const userId = UserId.fromUserHandle(fromBase64Url(userHandle));
376+
const userObject = await getItem("users", userId.id);
377+
if (!userObject) {
378+
throw new Error(`Could not find user with userHandle ${userHandle} on indexedDB 'users' table`);
379+
}
380+
userObject.privateData = serializePrivateData(newPrivateData);
381+
await addItem("users", userId.id, userObject);
382+
}
383+
384+
if (!isOnline) {
385+
await writeOnIndexedDB();
386+
console.log("Cannot write to remote keystore while offline");
387+
return;
388+
}
357389
const updateResp = updatePrivateDataEtag(
358390
await post('/user/session/private-data', serializePrivateData(newPrivateData), options),
359391
);
360392
if (updateResp.status === 204) {
393+
await writeOnIndexedDB();
361394
return;
362395
} else {
363396
console.error("Failed to update private data", updateResp.status, updateResp);
@@ -373,7 +406,7 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
373406
}
374407
throw e;
375408
}
376-
}, [post, updatePrivateDataEtag, cachedUsers, userHandle, syncPrivateData]);
409+
}, [post, updatePrivateDataEtag, cachedUsers, userHandle, syncPrivateData, isOnline]);
377410

378411
const login = useCallback(async (
379412
username: string,

src/components/Credentials/CredentialDeleteButton.jsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import React, { useContext } from 'react';
1+
import React from 'react';
22
import { useTranslation } from 'react-i18next';
33
import Button from '../Buttons/Button';
4-
import StatusContext from '@/context/StatusContext';
54
import { Trash2 } from 'lucide-react';
65

6+
77
const CredentialDeleteButton = ({ onDelete }) => {
88
const { t } = useTranslation();
9-
const { isOnline } = useContext(StatusContext);
109

1110
const handleClick = () => {
1211
onDelete();
@@ -17,8 +16,7 @@ const CredentialDeleteButton = ({ onDelete }) => {
1716
id="credential-delete-button"
1817
onClick={handleClick}
1918
variant="delete"
20-
disabled={!isOnline}
21-
title={!isOnline && t('common.offlineTitle')}
19+
title={t('common.offlineTitle')}
2220
additionalClassName='xm:w-full'
2321
>
2422
<Trash2 size={18} /> {t('pageCredentials.delete')}

src/context/SessionContextProvider.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const SessionContextProvider = ({ children }: React.PropsWithChildren) =>
2525
const [globalTabId] = useLocalStorage<string | null>("globalTabId", null);
2626
const [tabId] = useSessionStorage<string | null>("tabId", null);
2727

28+
const [appToken] = useSessionStorage<string | null>("appToken", null);
29+
2830
useWalletStateCredentialsMigrationManager(keystore, api, isOnline, isLoggedIn);
2931
useWalletStatePresentationsMigrationManager(keystore, api, isOnline, isLoggedIn);
3032

@@ -99,6 +101,13 @@ export const SessionContextProvider = ({ children }: React.PropsWithChildren) =>
99101
}
100102
}, [globalTabId, tabId, clearSession, api, keystore]);
101103

104+
useEffect(() => {
105+
if ((appToken === "" && isLoggedIn === true && isOnline === true) || // is logged-in when offline but now user is online again
106+
(appToken !== "" && appToken !== null && isLoggedIn === true && isOnline === false)) { // is logged-in when online but now the user has lost connection
107+
logout();
108+
}
109+
110+
}, [appToken, isLoggedIn, isOnline, logout])
102111

103112
if ((api.isLoggedIn() === true && (keystore.isOpen() === false || !walletStateLoaded))) {
104113
return <></>

src/pages/Settings/Settings.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -998,8 +998,6 @@ const Settings = () => {
998998
className={`h-10 pl-3 pr-10 bg-lm-gray-200 dark:bg-dm-gray-800 border border-lm-gray-600 dark:border-dm-gray-400 dark:text-white rounded-lg dark:inputDarkModeOverride appearance-none`}
999999
defaultValue={userData.settings.openidRefreshTokenMaxAgeInSeconds}
10001000
onChange={(e) => handleTokenMaxAgeChange(e.target.value)}
1001-
disabled={!isOnline}
1002-
title={!isOnline ? t("common.offlineTitle") : undefined}
10031001
>
10041002
<option value="0">{t('pageSettings.rememberIssuer.options.none')}</option>
10051003
<option value="3600">{t('pageSettings.rememberIssuer.options.hour')}</option>

src/services/LocalStorageKeystore.ts

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ import { useNavigate } from 'react-router-dom';
33

44
import * as config from "../config";
55
import { useClearStorages, useLocalStorage, useSessionStorage } from "../hooks/useStorage";
6-
import { toBase64Url } from "../util";
6+
import { fromBase64Url, jsonStringifyTaggedBinary, toBase64Url } from "../util";
77
import { useIndexedDb } from "../hooks/useIndexedDb";
88
import { useOnUserInactivity } from "../hooks/useOnUserInactivity";
99

1010
import * as keystore from "./keystore";
1111
import type { AsymmetricEncryptedContainer, AsymmetricEncryptedContainerKeys, EncryptedContainer, OpenedContainer, PrivateData, UnlockSuccess, WebauthnPrfEncryptionKeyInfo, WebauthnPrfSaltInfo, WrappedKeyInfo } from "./keystore";
1212
import { MDoc } from "@auth0/mdl";
1313
import { WalletStateUtils } from "./WalletStateUtils";
14-
import { addAlterSettingsEvent, addDeleteCredentialEvent, addDeleteCredentialIssuanceSessionEvent, addDeleteKeypairEvent, addNewCredentialEvent, addNewPresentationEvent, addSaveCredentialIssuanceSessionEvent, CurrentSchema, foldOldEventsIntoBaseState } from "./WalletStateSchema";
14+
import { addAlterSettingsEvent, addDeleteCredentialEvent, addDeleteCredentialIssuanceSessionEvent, addDeleteKeypairEvent, addNewCredentialEvent, addNewPresentationEvent, addSaveCredentialIssuanceSessionEvent, CurrentSchema, foldOldEventsIntoBaseState, foldState, mergeEventHistories } from "./WalletStateSchema";
15+
import { UserId } from "@/api/types";
16+
import { getItem } from "@/indexedDB";
17+
import { WalletStateContainerGeneric } from "./WalletStateSchemaCommon";
1518

1619
type WalletState = CurrentSchema.WalletState;
1720
type WalletStateCredential = CurrentSchema.WalletStateCredential;
@@ -314,14 +317,73 @@ export function useLocalStorageKeystore(eventTarget: EventTarget): LocalStorageK
314317
}, [setPrivateData, setMainKey, assertKeystoreOpen, userHandleB64u, writePrivateDataOnIdb]);
315318

316319
const finishUnlock = useCallback(async (
317-
{ mainKey, privateData }: UnlockSuccess,
320+
unlockSuccess: UnlockSuccess,
318321
user: CachedUser | UserData | null,
319-
): Promise<void> => {
322+
credential: PublicKeyCredential | null,
323+
promptForPrfRetry: () => Promise<boolean | AbortSignal>,
324+
): Promise<keystore.EncryptedContainer> => {
320325
if (user) {
321326
const userHandleB64u = ("prfKeys" in user
322327
? user.userHandleB64u
323328
: toBase64Url(user.userHandle)
324329
);
330+
let newEncryptedContainer: keystore.EncryptedContainer;
331+
332+
if (privateData) { // keystore is already opened
333+
const [localPrivateData, localMainKey] = await assertKeystoreOpen();
334+
const [remoteContainer, remoteMainKey,] = await keystore.openPrivateData(unlockSuccess.mainKey, unlockSuccess.privateData);
335+
const [localContainer, ,] = await keystore.openPrivateData(localMainKey, localPrivateData);
336+
const mergedContainer = await mergeEventHistories(remoteContainer, localContainer);
337+
const { newContainer } = await keystore.updateWalletState([
338+
keystore.assertAsymmetricEncryptedContainer(unlockSuccess.privateData),
339+
remoteMainKey,
340+
], mergedContainer as CurrentSchema.WalletStateContainer);
341+
const [newPrivateDataEncryptedContainer, newMainKey] = newContainer;
342+
await writePrivateDataOnIdb(newPrivateDataEncryptedContainer, userHandleB64u);
343+
setPrivateData(newPrivateDataEncryptedContainer);
344+
newEncryptedContainer = newPrivateDataEncryptedContainer;
345+
setMainKey(await keystore.exportMainKey(newMainKey));
346+
const foldedState = foldState(mergedContainer as CurrentSchema.WalletStateContainer);
347+
setCalculatedWalletState(foldedState);
348+
}
349+
else {
350+
async function mergeWithLocalEncryptedPrivateData(container: [EncryptedContainer, CryptoKey, WalletStateContainerGeneric]): Promise<[EncryptedContainer, CryptoKey, WalletStateContainerGeneric]> {
351+
const userId = UserId.fromUserHandle(fromBase64Url(userHandleB64u));
352+
const localUser = await getItem("users", userId.id);
353+
if (!localUser) {
354+
return container;
355+
}
356+
const localPrivateData: Uint8Array = localUser.privateData;
357+
const parsedLocalEncryptedPrivateData = await keystore.parsePrivateData(localPrivateData);
358+
const stringifiedLocalPrivateData = jsonStringifyTaggedBinary(localPrivateData);
359+
const stringifiedSerializedNewlyUnlockedPrivateData = jsonStringifyTaggedBinary(keystore.serializePrivateData(unlockSuccess.privateData));
360+
if (stringifiedLocalPrivateData !== stringifiedSerializedNewlyUnlockedPrivateData) { // local and remote are different
361+
// decryption of local is required
362+
const [unlockPrfResult,] = await keystore.unlockPrf(parsedLocalEncryptedPrivateData, credential, promptForPrfRetry);
363+
const { privateData, mainKey } = unlockPrfResult;
364+
const [localContainer, ,] = await keystore.openPrivateData(mainKey, privateData);
365+
const mergedContainer = await mergeEventHistories(unlockedContainer, localContainer);
366+
const { newContainer } = await keystore.updateWalletState([
367+
keystore.assertAsymmetricEncryptedContainer(unlockSuccess.privateData),
368+
unlockSuccess.mainKey,
369+
], mergedContainer as CurrentSchema.WalletStateContainer);
370+
const [newPrivateDataEncryptedContainer, newMainKey] = newContainer;
371+
return [newPrivateDataEncryptedContainer, newMainKey, mergedContainer];
372+
}
373+
return container;
374+
}
375+
const { privateData, mainKey } = unlockSuccess;
376+
const [unlockedContainer, ,] = await keystore.openPrivateData(mainKey, privateData);
377+
const [encryptedContainer, newMainKey, decryptedWalletState] = await mergeWithLocalEncryptedPrivateData([privateData, mainKey, unlockedContainer]);
378+
const foldedState = foldState(decryptedWalletState as CurrentSchema.WalletStateContainer);
379+
newEncryptedContainer = encryptedContainer;
380+
setPrivateData(encryptedContainer);
381+
setMainKey(await keystore.exportMainKey(newMainKey));
382+
await writePrivateDataOnIdb(encryptedContainer, userHandleB64u);
383+
// after private data update, the calculated wallet state must be re-computed
384+
setCalculatedWalletState(foldedState);
385+
}
386+
325387
const newUser = ("prfKeys" in user
326388
? user
327389
: {
@@ -341,20 +403,15 @@ export function useLocalStorageKeystore(eventTarget: EventTarget): LocalStorageK
341403
// useEffect updating cachedUsers from corrupting cache entries for other
342404
// users logged in in other tabs.
343405
setGlobalUserHandleB64u(userHandleB64u);
344-
await writePrivateDataOnIdb(privateData, userHandleB64u);
345406

346407
setCachedUsers((cachedUsers) => {
347408
// Move most recently used user to front of list
348409
const otherUsers = (cachedUsers || []).filter((cu) => cu.userHandleB64u !== newUser.userHandleB64u);
349410
return [newUser, ...otherUsers];
350411
});
351-
}
352412

353-
setMainKey(await keystore.exportMainKey(mainKey));
354-
setPrivateData(privateData);
355-
// after private data update, the calculated wallet state must be re-computed
356-
const [, , newCalculatedWalletState] = await keystore.openPrivateData(mainKey, privateData);
357-
setCalculatedWalletState(newCalculatedWalletState);
413+
return newEncryptedContainer;
414+
}
358415
}, [
359416
setUserHandleB64u,
360417
setGlobalUserHandleB64u,
@@ -365,7 +422,9 @@ export function useLocalStorageKeystore(eventTarget: EventTarget): LocalStorageK
365422
setTabId,
366423
setGlobalTabId,
367424
tabId,
368-
writePrivateDataOnIdb
425+
writePrivateDataOnIdb,
426+
assertKeystoreOpen,
427+
privateData,
369428
]);
370429

371430

@@ -385,8 +444,7 @@ export function useLocalStorageKeystore(eventTarget: EventTarget): LocalStorageK
385444
user: UserData,
386445
): Promise<EncryptedContainer> => {
387446
const unlocked = await keystore.init(mainKey, keyInfo);
388-
await finishUnlock(unlocked, user);
389-
const { privateData } = unlocked;
447+
const privateData = await finishUnlock(unlocked, user, null);
390448
return privateData;
391449
},
392450
[finishUnlock]
@@ -399,7 +457,7 @@ export function useLocalStorageKeystore(eventTarget: EventTarget): LocalStorageK
399457
user: UserData,
400458
): Promise<[EncryptedContainer, CommitCallback] | null> => {
401459
const [unlockResult, newPrivateData] = await keystore.unlockPassword(privateData, password);
402-
await finishUnlock(unlockResult, user);
460+
await finishUnlock(unlockResult, user, null);
403461
return (
404462
newPrivateData
405463
?
@@ -492,26 +550,25 @@ export function useLocalStorageKeystore(eventTarget: EventTarget): LocalStorageK
492550

493551
const unlockPrf = useCallback(
494552
async (
495-
privateData: EncryptedContainer,
553+
encryptedPrivateData: EncryptedContainer,
496554
credential: PublicKeyCredential,
497555
promptForPrfRetry: () => Promise<boolean | AbortSignal>,
498556
user: CachedUser | UserData | null,
499557
): Promise<[EncryptedContainer, CommitCallback] | null> => {
500-
const [unlockPrfResult, newPrivateData] = await keystore.unlockPrf(privateData, credential, promptForPrfRetry);
501-
await finishUnlock(unlockPrfResult, user);
558+
const [unlockPrfResult,] = await keystore.unlockPrf(encryptedPrivateData, credential, promptForPrfRetry);
559+
const updatedPrivateData = await finishUnlock(unlockPrfResult, user, credential, promptForPrfRetry);
502560
return (
503-
newPrivateData
561+
updatedPrivateData
504562
?
505-
[newPrivateData,
563+
[updatedPrivateData,
506564
async () => {
507-
await writePrivateDataOnIdb(newPrivateData, userHandleB64u);
508-
setPrivateData(newPrivateData);
565+
509566
},
510567
]
511568
: null
512569
);
513570
},
514-
[finishUnlock, setPrivateData, writePrivateDataOnIdb, userHandleB64u]
571+
[finishUnlock]
515572
);
516573

517574
const getPasswordOrPrfKeyFromSession = useCallback(

0 commit comments

Comments
 (0)