Merge pull request #1068 from vector-im/cross-signing/user-trust-observable

Cross-signing: implement observing user trust so UI can update when signing
This commit is contained in:
Bruno Windels 2023-03-31 11:57:43 +02:00 committed by GitHub
commit 515bd24cfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 97 additions and 32 deletions

View File

@ -30,20 +30,14 @@ export class MemberDetailsViewModel extends ViewModel {
this._session = options.session;
this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange()));
this.track(this._observableMember.subscribe( () => this._onMemberChange()));
this.track(this._session.crossSigning.subscribe(() => {
this.emitChange("trustShieldColor");
}));
this._userTrust = undefined;
this.init(); // TODO: call this from parent view model and do something smart with error view model if it fails async?
}
async init() {
this._userTrustSubscription = undefined;
if (this.features.crossSigning) {
this._userTrust = await this.logger.run({l: "MemberDetailsViewModel.get user trust", id: this._member.userId}, log => {
return this._session.crossSigning.get()?.getUserTrust(this._member.userId, log);
});
this.emitChange("trustShieldColor");
this.track(this._session.crossSigning.subscribe(() => {
this._onCrossSigningChange();
}));
}
this._onCrossSigningChange();
}
get name() { return this._member.name; }
@ -51,7 +45,7 @@ export class MemberDetailsViewModel extends ViewModel {
get userId() { return this._member.userId; }
get trustDescription() {
switch (this._userTrust) {
switch (this._userTrust?.get()) {
case UserTrust.Trusted: return this.i18n`You have verified this user. This user has verified all of their sessions.`;
case UserTrust.UserNotSigned: return this.i18n`You have not verified this user.`;
case UserTrust.UserSignatureMismatch: return this.i18n`You appear to have signed this user, but the signature is invalid.`;
@ -59,18 +53,20 @@ export class MemberDetailsViewModel extends ViewModel {
case UserTrust.UserDeviceSignatureMismatch: return this.i18n`This user has a session signature that is invalid.`;
case UserTrust.UserSetupError: return this.i18n`This user hasn't set up cross-signing correctly`;
case UserTrust.OwnSetupError: return this.i18n`Cross-signing wasn't set up correctly on your side.`;
default: return this.i18n`Pending…`;
case undefined:
default: // adding default as well because jslint can't check for switch exhaustiveness
return this.i18n`Please wait…`;
}
}
get trustShieldColor() {
if (!this._isEncrypted) {
return undefined;
return "";
}
switch (this._userTrust) {
switch (this._userTrust?.get()) {
case undefined:
case UserTrust.OwnSetupError:
return undefined;
return "";
case UserTrust.Trusted:
return "green";
case UserTrust.UserNotSigned:
@ -103,9 +99,10 @@ export class MemberDetailsViewModel extends ViewModel {
}
async signUser() {
if (this._session.crossSigning) {
const crossSigning = this._session.crossSigning.get();
if (crossSigning) {
await this.logger.run("MemberDetailsViewModel.signUser", async log => {
await this._session.crossSigning.signUser(this.userId, log);
await crossSigning.signUser(this.userId, log);
});
}
}
@ -150,4 +147,19 @@ export class MemberDetailsViewModel extends ViewModel {
}
this.navigation.push("room", roomId);
}
_onCrossSigningChange() {
const crossSigning = this._session.crossSigning.get();
this._userTrustSubscription = this.disposeTracked(this._userTrustSubscription);
this._userTrust = undefined;
if (crossSigning) {
this.logger.run("MemberDetailsViewModel.observeUserTrust", log => {
this._userTrust = crossSigning.observeUserTrust(this.userId, log);
this._userTrustSubscription = this.track(this._userTrust.subscribe(() => {
this.emitChange("trustShieldColor");
}));
});
}
this.emitChange("trustShieldColor");
}
}

View File

@ -163,6 +163,16 @@ export class DeviceTracker {
}
}
async invalidateUserKeys(userId: string): Promise<void> {
const txn = await this._storage.readWriteTxn([this._storage.storeNames.userIdentities]);
const userIdentity = await txn.userIdentities.get(userId);
if (userIdentity) {
userIdentity.keysTrackingStatus = KeysTrackingStatus.Outdated;
txn.userIdentities.set(userIdentity);
}
await txn.complete();
}
async getCrossSigningKeyForUser(userId: string, usage: KeyUsage, hsApi: HomeServerApi | undefined, log: ILogItem): Promise<CrossSigningKey | undefined> {
return await log.wrap({l: "DeviceTracker.getCrossSigningKeyForUser", id: userId, usage}, async log => {
const txn = await this._storage.readTxn([

View File

@ -15,6 +15,7 @@ limitations under the License.
*/
import {verifyEd25519Signature, SignatureVerification} from "../e2ee/common";
import {BaseObservableValue, RetainedObservableValue} from "../../observable/value";
import {pkSign} from "./common";
import {SASVerification} from "./SAS/SASVerification";
import {ToDeviceChannel} from "./SAS/channel/Channel";
@ -89,6 +90,7 @@ export class CrossSigning {
private readonly e2eeAccount: Account;
private readonly deviceMessageHandler: DeviceMessageHandler;
private _isMasterKeyTrusted: boolean = false;
private readonly observedUsers: Map<string, RetainedObservableValue<UserTrust | undefined>> = new Map();
private readonly deviceId: string;
private sasVerificationInProgress?: SASVerification;
public receivedSASVerifications: ObservableMap<string, SASRequest> = new ObservableMap();
@ -295,50 +297,59 @@ export class CrossSigning {
};
const request = this.hsApi.uploadSignatures(payload, {log});
await request.response();
// we don't write the signatures to storage, as we don't want to have too many special
// cases in the trust algorithm, so instead we just clear the cross signing keys
// so that they will be refetched when trust is recalculated
await this.deviceTracker.invalidateUserKeys(userId);
this.emitUserTrustUpdate(userId, log);
return keyToSign;
});
}
async getUserTrust(userId: string, log: ILogItem): Promise<UserTrust> {
return log.wrap("getUserTrust", async log => {
getUserTrust(userId: string, log: ILogItem): Promise<UserTrust> {
return log.wrap("CrossSigning.getUserTrust", async log => {
log.set("id", userId);
const logResult = (trust: UserTrust): UserTrust => {
log.set("result", trust);
return trust;
};
if (!this.isMasterKeyTrusted) {
return UserTrust.OwnSetupError;
return logResult(UserTrust.OwnSetupError);
}
const ourMSK = await log.wrap("get our msk", log => this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.Master, this.hsApi, log));
if (!ourMSK) {
return UserTrust.OwnSetupError;
return logResult(UserTrust.OwnSetupError);
}
const ourUSK = await log.wrap("get our usk", log => this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.UserSigning, this.hsApi, log));
if (!ourUSK) {
return UserTrust.OwnSetupError;
return logResult(UserTrust.OwnSetupError);
}
const ourUSKVerification = log.wrap("verify our usk", log => this.hasValidSignatureFrom(ourUSK, ourMSK, log));
if (ourUSKVerification !== SignatureVerification.Valid) {
return UserTrust.OwnSetupError;
return logResult(UserTrust.OwnSetupError);
}
const theirMSK = await log.wrap("get their msk", log => this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log));
if (!theirMSK) {
/* assume that when they don't have an MSK, they've never enabled cross-signing on their client
(or it's not supported) rather than assuming a setup error on their side.
Later on, for their SSK, we _do_ assume it's a setup error as it doesn't make sense to have an MSK without a SSK */
return UserTrust.UserNotSigned;
return logResult(UserTrust.UserNotSigned);
}
const theirMSKVerification = log.wrap("verify their msk", log => this.hasValidSignatureFrom(theirMSK, ourUSK, log));
if (theirMSKVerification !== SignatureVerification.Valid) {
if (theirMSKVerification === SignatureVerification.NotSigned) {
return UserTrust.UserNotSigned;
return logResult(UserTrust.UserNotSigned);
} else { /* SignatureVerification.Invalid */
return UserTrust.UserSignatureMismatch;
return logResult(UserTrust.UserSignatureMismatch);
}
}
const theirSSK = await log.wrap("get their ssk", log => this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.SelfSigning, this.hsApi, log));
if (!theirSSK) {
return UserTrust.UserSetupError;
return logResult(UserTrust.UserSetupError);
}
const theirSSKVerification = log.wrap("verify their ssk", log => this.hasValidSignatureFrom(theirSSK, theirMSK, log));
if (theirSSKVerification !== SignatureVerification.Valid) {
return UserTrust.UserSetupError;
return logResult(UserTrust.UserSetupError);
}
const theirDeviceKeys = await log.wrap("get their devices", log => this.deviceTracker.devicesForUsers([userId], this.hsApi, log));
const lowestDeviceVerification = theirDeviceKeys.reduce((lowest, dk) => log.wrap({l: "verify device", id: dk.device_id}, log => {
@ -356,15 +367,32 @@ export class CrossSigning {
}), SignatureVerification.Valid);
if (lowestDeviceVerification !== SignatureVerification.Valid) {
if (lowestDeviceVerification === SignatureVerification.NotSigned) {
return UserTrust.UserDeviceNotSigned;
return logResult(UserTrust.UserDeviceNotSigned);
} else { /* SignatureVerification.Invalid */
return UserTrust.UserDeviceSignatureMismatch;
return logResult(UserTrust.UserDeviceSignatureMismatch);
}
}
return UserTrust.Trusted;
return logResult(UserTrust.Trusted);
});
}
observeUserTrust(userId: string, log: ILogItem): BaseObservableValue<UserTrust | undefined> {
const existingValue = this.observedUsers.get(userId);
if (existingValue) {
return existingValue;
}
const observable = new RetainedObservableValue<UserTrust | undefined>(undefined, () => {
this.observedUsers.delete(userId);
});
this.observedUsers.set(userId, observable);
log.wrapDetached("get user trust", async log => {
if (observable.get() === undefined) {
observable.set(await this.getUserTrust(userId, log));
}
});
return observable;
}
private async signDeviceKey(keyToSign: DeviceKey, log: ILogItem): Promise<DeviceKey | undefined> {
const signingKey = await this.getSigningKey(KeyUsage.SelfSigning);
if (!signingKey) {
@ -382,6 +410,11 @@ export class CrossSigning {
};
const request = this.hsApi.uploadSignatures(payload, {log});
await request.response();
// we don't write the signatures to storage, as we don't want to have too many special
// cases in the trust algorithm, so instead we just clear the device keys
// so that they will be refetched when trust is recalculated
await this.deviceTracker.invalidateUserKeys(this.ownUserId);
this.emitUserTrustUpdate(this.ownUserId, log);
return keyToSign;
}
@ -403,6 +436,16 @@ export class CrossSigning {
}
return verifyEd25519Signature(this.olmUtil, signingKey.user_id, pubKey, pubKey, key, log);
}
private emitUserTrustUpdate(userId: string, log: ILogItem) {
const observable = this.observedUsers.get(userId);
if (observable && observable.get() !== undefined) {
observable.set(undefined);
log.wrapDetached("update user trust", async log => {
observable.set(await this.getUserTrust(userId, log));
});
}
}
}
export function getKeyUsage(keyInfo: CrossSigningKey): KeyUsage | undefined {