This commit is contained in:
Bruno Windels 2021-10-27 15:08:53 +02:00
parent 718b410253
commit c89e414bb5
13 changed files with 190 additions and 79 deletions

View File

@ -0,0 +1,49 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ViewModel} from "./ViewModel.js";
export class AccountSetupViewModel extends ViewModel {
constructor(accountSetup) {
super();
this._accountSetup = accountSetup;
this._dehydratedDevice = undefined;
}
get canDehydrateDevice() {
return !!this._accountSetup.encryptedDehydratedDevice;
}
get deviceDecrypted() {
return !!this._dehydratedDevice;
}
get dehydratedDeviceId() {
return this._accountSetup.encryptedDehydratedDevice.deviceId;
}
tryDecryptDehydratedDevice(password) {
const {encryptedDehydratedDevice} = this._accountSetup;
if (encryptedDehydratedDevice) {
this._dehydratedDevice = encryptedDehydratedDevice.decrypt(password);
this.emitChange("deviceDecrypted");
}
}
finish() {
this._accountSetup.finish(this._dehydratedDevice);
}
}

View File

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {AccountSetupViewModel} from "./AccountSetupViewModel.js";
import {LoadStatus} from "../matrix/SessionContainer.js";
import {SyncStatus} from "../matrix/Sync.js";
import {ViewModel} from "./ViewModel.js";
@ -29,7 +30,8 @@ export class SessionLoadViewModel extends ViewModel {
this._loading = false;
this._error = null;
this.backUrl = this.urlCreator.urlForSegment("session", true);
this._dehydratedDevice = undefined;
this._accountSetupViewModel = undefined;
}
async start() {
@ -40,6 +42,11 @@ export class SessionLoadViewModel extends ViewModel {
this._loading = true;
this.emitChange("loading");
this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => {
if (s === LoadStatus.AccountSetup) {
this._accountSetupViewModel = new AccountSetupViewModel(this._sessionContainer.accountSetup);
} else {
this._accountSetupViewModel = undefined;
}
this.emitChange("loadLabel");
// wait for initial sync, but not catchup sync
const isCatchupSync = s === LoadStatus.FirstSync &&
@ -98,6 +105,10 @@ export class SessionLoadViewModel extends ViewModel {
// to show a spinner or not
get loading() {
const sc = this._sessionContainer;
if (sc && sc.loadStatus.get() === LoadStatus.AccountSetup) {
return false;
}
return this._loading;
}
@ -113,8 +124,8 @@ export class SessionLoadViewModel extends ViewModel {
switch (sc.loadStatus.get()) {
case LoadStatus.QueryAccount:
return `Querying account encryption setup…`;
case LoadStatus.SetupAccount:
return `Please enter your password to restore your encryption setup`;
case LoadStatus.AccountSetup:
return `Do you want to restore this dehydrated device?`;
case LoadStatus.SessionSetup:
return `Setting up your encryption keys…`;
case LoadStatus.Loading:
@ -142,26 +153,7 @@ export class SessionLoadViewModel extends ViewModel {
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
}
get canSetupAccount() {
return this._sessionContainer.loadStatus === LoadStatus.SetupAccount;
}
get canDehydrateDevice() {
return this.canSetupAccount && !!this._sessionContainer.accountSetup.encryptedDehydratedDevice;
}
tryDecryptDehydratedDevice(password) {
const {encryptedDehydratedDevice} = this._sessionContainer.accountSetup;
if (encryptedDehydratedDevice) {
this._dehydratedDevice = encryptedDehydratedDevice.decrypt(password);
return !!this._dehydratedDevice;
}
return false;
}
finishAccountSetup() {
const dehydratedDevice = this._dehydratedDevice;
this._dehydratedDevice = undefined;
this._sessionContainer.accountSetup.finish(dehydratedDevice);
get accountSetupViewModel() {
return this._accountSetupViewModel;
}
}

View File

@ -69,6 +69,10 @@ export class SettingsViewModel extends ViewModel {
});
}
setupDehydratedDevice(key) {
return this._session.setupDehydratedDevice(key);
}
get isLoggingOut() { return this._isLoggingOut; }
setSentImageSizeLimit(size) {

View File

@ -21,7 +21,6 @@ export class ConsoleLogger extends BaseLogger {
}
}
const excludedKeysFromTable = ["l", "id"];
function filterValues(values) {
if (!values) {
@ -79,7 +78,7 @@ function itemCaption(item) {
} else if (item._values.l && item.error) {
return `${item._values.l} failed`;
} else if (typeof item._values.ref !== "undefined") {
return `ref ${item._values.ref}`
return `ref ${item._values.ref}`;
} else {
return item._values.l || item._values.type;
}

View File

@ -108,8 +108,7 @@ export class Session {
}
async logout(log = undefined) {
const response = await this._hsApi.logout({log}).response();
console.log("logout", response);
await this._hsApi.logout({log}).response();
}
// called once this._e2eeAccount is assigned
@ -249,7 +248,7 @@ export class Session {
async createIdentity(log) {
if (this._olm) {
if (!this._e2eeAccount) {
this._e2eeAccount = this._createNewAccount(this._sessionInfo.deviceId, this._storage);
this._e2eeAccount = await this._createNewAccount(this._sessionInfo.deviceId, this._storage);
log.set("keys", this._e2eeAccount.identityKeys);
this._setupEncryption();
}
@ -259,39 +258,37 @@ export class Session {
}
/** @internal */
dehydrateIdentity(dehydratedDevice, log = null) {
this._platform.logger.wrapOrRun(log, "dehydrateIdentity", async log => {
log.set("deviceId", dehydratedDevice.deviceId);
if (!this._olm) {
log.set("no_olm", true);
return false;
}
if (dehydratedDevice.deviceId !== this.deviceId) {
log.set("wrong_device", true);
return false;
}
if (this._e2eeAccount) {
log.set("account_already_setup", true);
return false;
}
if (!await dehydratedDevice.claim(this._hsApi, log)) {
log.set("already_claimed", true);
return false;
}
this._e2eeAccount = await E2EEAccount.adoptDehydratedDevice({
dehydratedDevice,
hsApi: this._hsApi,
olm: this._olm,
pickleKey: PICKLE_KEY,
userId: this._sessionInfo.userId,
olmWorker: this._olmWorker,
deviceId: this.deviceId,
storage: this._storage,
});
log.set("keys", this._e2eeAccount.identityKeys);
this._setupEncryption();
return true;
async dehydrateIdentity(dehydratedDevice, log) {
log.set("deviceId", dehydratedDevice.deviceId);
if (!this._olm) {
log.set("no_olm", true);
return false;
}
if (dehydratedDevice.deviceId !== this.deviceId) {
log.set("wrong_device", true);
return false;
}
if (this._e2eeAccount) {
log.set("account_already_setup", true);
return false;
}
if (!await dehydratedDevice.claim(this._hsApi, log)) {
log.set("already_claimed", true);
return false;
}
this._e2eeAccount = await E2EEAccount.adoptDehydratedDevice({
dehydratedDevice,
hsApi: this._hsApi,
olm: this._olm,
pickleKey: PICKLE_KEY,
userId: this._sessionInfo.userId,
olmWorker: this._olmWorker,
deviceId: this.deviceId,
storage: this._storage,
});
log.set("keys", this._e2eeAccount.identityKeys);
this._setupEncryption();
return true;
}
_createNewAccount(deviceId, storage = undefined) {

View File

@ -36,7 +36,7 @@ export const LoadStatus = createEnum(
"Login",
"LoginFailed",
"QueryAccount", // check for dehydrated device after login
"SetupAccount", // asked to restore from dehydrated device if present, call sc.accountSetup.finish() to progress to the next stage
"AccountSetup", // asked to restore from dehydrated device if present, call sc.accountSetup.finish() to progress to the next stage
"Loading",
"SessionSetup", // upload e2ee keys, ...
"Migrating", // not used atm, but would fit here
@ -66,6 +66,7 @@ export class SessionContainer {
this._requestScheduler = null;
this._olmPromise = olmPromise;
this._workerPromise = workerPromise;
this._accountSetup = undefined;
}
createNewSessionId() {
@ -323,7 +324,7 @@ export class SessionContainer {
let resolveStageFinish;
const promiseStageFinish = new Promise(r => resolveStageFinish = r);
this._accountSetup = new AccountSetup(encryptedDehydratedDevice, resolveStageFinish);
this._status.set(LoadStatus.SetupAccount);
this._status.set(LoadStatus.AccountSetup);
await promiseStageFinish;
const dehydratedDevice = this._accountSetup?._dehydratedDevice;
this._accountSetup = null;

View File

@ -111,7 +111,7 @@ export class Account {
if (!this._areDeviceKeysUploaded) {
log.set("identity", true);
const identityKeys = JSON.parse(this._account.identity_keys());
payload.device_keys = this._deviceKeysPayload(identityKeys);
payload.device_keys = this._deviceKeysPayload(identityKeys, dehydratedDeviceId || this._deviceId);
}
if (oneTimeKeysEntries.length) {
log.set("otks", true);
@ -241,10 +241,10 @@ export class Account {
}
}
_deviceKeysPayload(identityKeys) {
_deviceKeysPayload(identityKeys, deviceId) {
const obj = {
user_id: this._userId,
device_id: this._deviceId,
device_id: deviceId,
algorithms: [OLM_ALGORITHM, MEGOLM_ALGORITHM],
keys: {}
};

View File

@ -54,13 +54,21 @@ class EncryptedDehydratedDevice {
const account = new this._olm.Account();
try {
const pickledAccount = this._dehydratedDevice.device_data.account;
account.unpickle(key, pickledAccount);
account.unpickle(new Uint8Array(key), pickledAccount);
return new DehydratedDevice(this._dehydratedDevice, account);
} catch (err) {
account.free();
return null;
if (err.message === "OLM.BAD_ACCOUNT_KEY") {
return undefined;
} else {
throw err;
}
}
}
get deviceId() {
return this._dehydratedDevice.device_id;
}
}
class DehydratedDevice {

View File

@ -19,7 +19,7 @@ import {encodeQueryParams, encodeBody} from "./common.js";
import {HomeServerRequest} from "./HomeServerRequest.js";
const CS_R0_PREFIX = "/_matrix/client/r0";
const DEHYDRATION_PREFIX = "/unstable/org.matrix.msc2697.v2";
const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2";
export class HomeServerApi {
constructor({homeserver, accessToken, request, reconnector}) {
@ -31,7 +31,7 @@ export class HomeServerApi {
this._reconnector = reconnector;
}
_url(csPath, prefix) {
_url(csPath, prefix = CS_R0_PREFIX) {
return this._homeserver + prefix + csPath;
}

View File

@ -54,15 +54,12 @@ limitations under the License.
padding: 0 0.4em 0.4em;
}
.SessionLoadStatusView, .LoginView_query-spinner {
.SessionLoadStatusView > .status, .LoginView_query-spinner {
display: flex;
gap: 12px;
}
.SessionLoadStatusView> :not(:first-child), .LoginView_query-spinner> :not(:first-child) {
margin-left: 12px;
}
.SessionLoadStatusView p, .LoginView_query-spinner p {
.SessionLoadStatusView > .status p, .LoginView_query-spinner p {
flex: 1;
margin: 0;
}

View File

@ -0,0 +1,46 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {TemplateView} from "../general/TemplateView";
export class AccountSetupView extends TemplateView {
render(t, vm) {
const keyLabel = vm => `Dehydration key for device ${vm.dehydratedDeviceId}`;
const password = t.input({
id: "dehydrated_device_key",
type: "password",
placeholder: keyLabel,
});
const status = t.output({for: password.id}, vm => vm.deviceDecrypted ? "Key matches, good to go!" : "");
return t.div({className: "form"}, [
t.form({
onSubmit: evt => {
evt.preventDefault();
vm.tryDecryptDehydratedDevice(password.value);
}
}, [
t.div({ className: "form-row" }, [t.label({ for: password.id }, keyLabel), password, status]),
]),
t.div({ className: "button-row" }, [
t.button({
className: "button-action primary",
onClick: () => { vm.finish(); },
type: "button",
}, vm.i18n`Continue`),
]),
]);
}
}

View File

@ -16,6 +16,7 @@ limitations under the License.
import {TemplateView} from "../general/TemplateView";
import {spinner} from "../common.js";
import {AccountSetupView} from "./AccountSetupView.js";
/** a view used both in the login view and the loading screen
to show the current state of loading the session.
@ -28,8 +29,12 @@ export class SessionLoadStatusView extends TemplateView {
}, vm.i18n`Export logs`);
});
return t.div({className: "SessionLoadStatusView"}, [
spinner(t, {hiddenWithLayout: vm => !vm.loading}),
t.p([vm => vm.loadLabel, exportLogsButtonIfFailed])
t.p({className: "status"}, [
spinner(t, {hidden: vm => !vm.loading}),
t.p(vm => vm.loadLabel),
exportLogsButtonIfFailed
]),
t.ifView(vm => vm.accountSetupViewModel, vm => new AccountSetupView(vm.accountSetupViewModel)),
]);
}
}

View File

@ -48,7 +48,20 @@ export class SettingsView extends TemplateView {
}
},
disabled: vm => vm.isLoggingOut
}, vm.i18n`Log out`))
}, vm.i18n`Log out`)),
row(t, "", t.button({
onClick: async () => {
const key = prompt(vm.i18n`Enter the key to encrypt the dehydrated device`);
if (key) {
try {
const deviceId = await vm.setupDehydratedDevice(key);
alert(`Successfully set up dehydrated device with id ${deviceId}`);
} catch (err) {
alert(`Failed to set up dehydrated device: ${err.message}`);
}
}
},
}, vm.i18n`Set up dehydrated device`))
);
settingNodes.push(
t.h3("Session Backup"),