Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit f5f0686

Browse files
authored
Merge pull request #5239 from uhoreg/dehydration
Add support for dehydrated devices
2 parents fdb8cb7 + 49cc62c commit f5f0686

File tree

5 files changed

+176
-16
lines changed

5 files changed

+176
-16
lines changed

src/Lifecycle.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
186186
console.log("Logged in with token");
187187
return _clearStorage().then(() => {
188188
_persistCredentialsToLocalStorage(creds);
189+
// remember that we just logged in
190+
sessionStorage.setItem("mx_fresh_login", true);
189191
return true;
190192
});
191193
}).catch((err) => {
@@ -312,6 +314,9 @@ async function _restoreFromLocalStorage(opts) {
312314
console.log("No pickle key available");
313315
}
314316

317+
const freshLogin = sessionStorage.getItem("mx_fresh_login");
318+
sessionStorage.removeItem("mx_fresh_login");
319+
315320
console.log(`Restoring session for ${userId}`);
316321
await _doSetLoggedIn({
317322
userId: userId,
@@ -321,6 +326,7 @@ async function _restoreFromLocalStorage(opts) {
321326
identityServerUrl: isUrl,
322327
guest: isGuest,
323328
pickleKey: pickleKey,
329+
freshLogin: freshLogin,
324330
}, false);
325331
return true;
326332
} else {
@@ -364,6 +370,7 @@ async function _handleLoadSessionFailure(e) {
364370
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
365371
*/
366372
export async function setLoggedIn(credentials) {
373+
credentials.freshLogin = true;
367374
stopMatrixClient();
368375
const pickleKey = credentials.userId && credentials.deviceId
369376
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
@@ -429,6 +436,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
429436
" guest: " + credentials.guest +
430437
" hs: " + credentials.homeserverUrl +
431438
" softLogout: " + softLogout,
439+
" freshLogin: " + credentials.freshLogin,
432440
);
433441

434442
// This is dispatched to indicate that the user is still in the process of logging in
@@ -462,10 +470,28 @@ async function _doSetLoggedIn(credentials, clearStorage) {
462470

463471
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
464472

473+
MatrixClientPeg.replaceUsingCreds(credentials);
474+
const client = MatrixClientPeg.get();
475+
476+
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
477+
// If we just logged in, try to rehydrate a device instead of using a
478+
// new device. If it succeeds, we'll get a new device ID, so make sure
479+
// we persist that ID to localStorage
480+
const newDeviceId = await client.rehydrateDevice();
481+
if (newDeviceId) {
482+
credentials.deviceId = newDeviceId;
483+
}
484+
485+
delete credentials.freshLogin;
486+
}
487+
465488
if (localStorage) {
466489
try {
467490
_persistCredentialsToLocalStorage(credentials);
468491

492+
// make sure we don't think that it's a fresh login any more
493+
sessionStorage.removeItem("mx_fresh_login");
494+
469495
// The user registered as a PWLU (PassWord-Less User), the generated password
470496
// is cached here such that the user can change it at a later time.
471497
if (credentials.password) {
@@ -482,12 +508,10 @@ async function _doSetLoggedIn(credentials, clearStorage) {
482508
console.warn("No local storage available: can't persist session!");
483509
}
484510

485-
MatrixClientPeg.replaceUsingCreds(credentials);
486-
487511
dis.dispatch({ action: 'on_logged_in' });
488512

489513
await startMatrixClient(/*startSyncing=*/!softLogout);
490-
return MatrixClientPeg.get();
514+
return client;
491515
}
492516

493517
function _showStorageEvictedDialog() {

src/MatrixClientPeg.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto';
3131
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
3232
import * as StorageManager from './utils/StorageManager';
3333
import IdentityAuthClient from './IdentityAuthClient';
34-
import { crossSigningCallbacks } from './SecurityManager';
34+
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
3535
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
3636

3737
export interface IMatrixClientCreds {
@@ -42,6 +42,7 @@ export interface IMatrixClientCreds {
4242
accessToken: string;
4343
guest: boolean;
4444
pickleKey?: string;
45+
freshLogin?: boolean;
4546
}
4647

4748
// TODO: Move this to the js-sdk
@@ -192,6 +193,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
192193
this.matrixClient.setCryptoTrustCrossSignedDevices(
193194
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
194195
);
196+
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
195197
StorageManager.setCryptoInitialised(true);
196198
}
197199
} catch (e) {

src/SecurityManager.js

Lines changed: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,21 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
2424
import { isSecureBackupRequired } from './utils/WellKnownUtils';
2525
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
2626
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
27+
import SettingsStore from "./settings/SettingsStore";
2728

2829
// This stores the secret storage private keys in memory for the JS SDK. This is
2930
// only meant to act as a cache to avoid prompting the user multiple times
3031
// during the same single operation. Use `accessSecretStorage` below to scope a
3132
// single secret storage operation, as it will clear the cached keys once the
3233
// operation ends.
3334
let secretStorageKeys = {};
35+
let secretStorageKeyInfo = {};
3436
let secretStorageBeingAccessed = false;
3537

38+
let nonInteractive = false;
39+
40+
let dehydrationCache = {};
41+
3642
function isCachingAllowed() {
3743
return secretStorageBeingAccessed;
3844
}
@@ -66,6 +72,20 @@ async function confirmToDismiss() {
6672
return !sure;
6773
}
6874

75+
function makeInputToKey(keyInfo) {
76+
return async ({ passphrase, recoveryKey }) => {
77+
if (passphrase) {
78+
return deriveKey(
79+
passphrase,
80+
keyInfo.passphrase.salt,
81+
keyInfo.passphrase.iterations,
82+
);
83+
} else {
84+
return decodeRecoveryKey(recoveryKey);
85+
}
86+
};
87+
}
88+
6989
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
7090
const keyInfoEntries = Object.entries(keyInfos);
7191
if (keyInfoEntries.length > 1) {
@@ -78,17 +98,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
7898
return [keyId, secretStorageKeys[keyId]];
7999
}
80100

81-
const inputToKey = async ({ passphrase, recoveryKey }) => {
82-
if (passphrase) {
83-
return deriveKey(
84-
passphrase,
85-
keyInfo.passphrase.salt,
86-
keyInfo.passphrase.iterations,
87-
);
88-
} else {
89-
return decodeRecoveryKey(recoveryKey);
101+
if (dehydrationCache.key) {
102+
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
103+
cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo);
104+
return [keyId, dehydrationCache.key];
90105
}
91-
};
106+
}
107+
108+
if (nonInteractive) {
109+
throw new Error("Could not unlock non-interactively");
110+
}
111+
112+
const inputToKey = makeInputToKey(keyInfo);
92113
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
93114
AccessSecretStorageDialog,
94115
/* props= */
@@ -118,14 +139,56 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
118139
const key = await inputToKey(input);
119140

120141
// Save to cache to avoid future prompts in the current session
121-
cacheSecretStorageKey(keyId, key);
142+
cacheSecretStorageKey(keyId, key, keyInfo);
122143

123144
return [keyId, key];
124145
}
125146

126-
function cacheSecretStorageKey(keyId, key) {
147+
export async function getDehydrationKey(keyInfo, checkFunc) {
148+
const inputToKey = makeInputToKey(keyInfo);
149+
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
150+
AccessSecretStorageDialog,
151+
/* props= */
152+
{
153+
keyInfo,
154+
checkPrivateKey: async (input) => {
155+
const key = await inputToKey(input);
156+
try {
157+
checkFunc(key);
158+
return true;
159+
} catch (e) {
160+
return false;
161+
}
162+
},
163+
},
164+
/* className= */ null,
165+
/* isPriorityModal= */ false,
166+
/* isStaticModal= */ false,
167+
/* options= */ {
168+
onBeforeClose: async (reason) => {
169+
if (reason === "backgroundClick") {
170+
return confirmToDismiss();
171+
}
172+
return true;
173+
},
174+
},
175+
);
176+
const [input] = await finished;
177+
if (!input) {
178+
throw new AccessCancelledError();
179+
}
180+
const key = await inputToKey(input);
181+
182+
// need to copy the key because rehydration (unpickling) will clobber it
183+
dehydrationCache = {key: new Uint8Array(key), keyInfo};
184+
185+
return key;
186+
}
187+
188+
function cacheSecretStorageKey(keyId, key, keyInfo) {
127189
if (isCachingAllowed()) {
128190
secretStorageKeys[keyId] = key;
191+
secretStorageKeyInfo[keyId] = keyInfo;
129192
}
130193
}
131194

@@ -176,6 +239,7 @@ export const crossSigningCallbacks = {
176239
getSecretStorageKey,
177240
cacheSecretStorageKey,
178241
onSecretRequested,
242+
getDehydrationKey,
179243
};
180244

181245
export async function promptForBackupPassphrase() {
@@ -262,6 +326,18 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
262326
await cli.bootstrapSecretStorage({
263327
getKeyBackupPassphrase: promptForBackupPassphrase,
264328
});
329+
330+
const keyId = Object.keys(secretStorageKeys)[0];
331+
if (keyId && SettingsStore.getValue("feature_dehydration")) {
332+
const dehydrationKeyInfo =
333+
secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase
334+
? {passphrase: secretStorageKeyInfo[keyId].passphrase}
335+
: {};
336+
console.log("Setting dehydration key");
337+
await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
338+
} else {
339+
console.log("Not setting dehydration key: no SSSS key found");
340+
}
265341
}
266342

267343
// `return await` needed here to ensure `finally` block runs after the
@@ -272,6 +348,57 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
272348
secretStorageBeingAccessed = false;
273349
if (!isCachingAllowed()) {
274350
secretStorageKeys = {};
351+
secretStorageKeyInfo = {};
352+
}
353+
}
354+
}
355+
356+
// FIXME: this function name is a bit of a mouthful
357+
export async function tryToUnlockSecretStorageWithDehydrationKey(client) {
358+
const key = dehydrationCache.key;
359+
let restoringBackup = false;
360+
if (key && await client.isSecretStorageReady()) {
361+
console.log("Trying to set up cross-signing using dehydration key");
362+
secretStorageBeingAccessed = true;
363+
nonInteractive = true;
364+
try {
365+
await client.checkOwnCrossSigningTrust();
366+
367+
// we also need to set a new dehydrated device to replace the
368+
// device we rehydrated
369+
const dehydrationKeyInfo =
370+
dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase
371+
? {passphrase: dehydrationCache.keyInfo.passphrase}
372+
: {};
373+
await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device");
374+
375+
// and restore from backup
376+
const backupInfo = await client.getKeyBackupVersion();
377+
if (backupInfo) {
378+
restoringBackup = true;
379+
// don't await, because this can take a long time
380+
client.restoreKeyBackupWithSecretStorage(backupInfo)
381+
.finally(() => {
382+
secretStorageBeingAccessed = false;
383+
nonInteractive = false;
384+
if (!isCachingAllowed()) {
385+
secretStorageKeys = {};
386+
secretStorageKeyInfo = {};
387+
}
388+
});
389+
}
390+
} finally {
391+
dehydrationCache = {};
392+
// the secret storage cache is needed for restoring from backup, so
393+
// don't clear it yet if we're restoring from backup
394+
if (!restoringBackup) {
395+
secretStorageBeingAccessed = false;
396+
nonInteractive = false;
397+
if (!isCachingAllowed()) {
398+
secretStorageKeys = {};
399+
secretStorageKeyInfo = {};
400+
}
401+
}
275402
}
276403
}
277404
}

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@
452452
"Support adding custom themes": "Support adding custom themes",
453453
"Show message previews for reactions in DMs": "Show message previews for reactions in DMs",
454454
"Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms",
455+
"Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices",
455456
"Enable advanced debugging for the room list": "Enable advanced debugging for the room list",
456457
"Show info about bridges in room settings": "Show info about bridges in room settings",
457458
"Font size": "Font size",

src/settings/Settings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ export const SETTINGS: {[setting: string]: ISetting} = {
186186
supportedLevels: LEVELS_FEATURE,
187187
default: false,
188188
},
189+
"feature_dehydration": {
190+
isFeature: true,
191+
displayName: _td("Offline encrypted messaging using dehydrated devices"),
192+
supportedLevels: LEVELS_FEATURE,
193+
default: false,
194+
},
189195
"advancedRoomListLogging": {
190196
// TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231
191197
displayName: _td("Enable advanced debugging for the room list"),

0 commit comments

Comments
 (0)