1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 16:23:44 +00:00

[PM-18903] Desktop sync issues (#13681)

* [PM-18707] Use different BroadcasterSubscriptionId in base view component to avoid collision with desktop view component

* [PM-18707] Use userId instead of payloadUserId for cipher notification syncs

* [PM-19032] Live Sync on Desktop (#13851)

* migrate the vault-items to an observables rather than async/promises

- this helps keep data in sync with the service state and avoids race conditions

* migrate the view component to an observables rather than async/promises

- this helps keep data in sync with the service state and avoids race conditions

* decrypt saved cipher from server

* bump timeout for upserting ciphers

* mark `go` as async in desktop vault

- previously it was a floating promise

* Revert "mark `go` as async in desktop vault"

This reverts commit fd28f40b18.

* Revert "bump timeout for upserting ciphers"

This reverts commit e963acc377.

* move vault utilities to `common` rather than `lib` to avoid circular dependencies

* use `perUserCache$` for `cipherViews$` to avoid new subscriptions from being created

* use userId from observable rather than locally set to be the most up to date

* [PM-18707] Add clearBuffer$ input to perUserCache$ helper so that  the internal share replay buffers can be cleared

* [PM-18707] Rework forceCipherViews$ to clearBuffer$ refactor

- Add dependency for cipherDecryptionKeys$ for the cipherViews so that decryption is never attempted without keys

* [PM-18707] Add overload to perUserCache to satisfy type checker

* [PM-18707] Fix overloads

* [PM-18707] Add check for empty failed to decrypt ciphers

* [PM-18707] Mark vault component for check after observable emits.

The cipherViews$ observable now persists between subscriptions, meaning that updates via the sync push notifications can occur outside the AngularZone causing delays in updating the view.

---------

Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
Co-authored-by: Nick Krantz <nick@livefront.com>
This commit is contained in:
Shane Melton
2025-04-15 12:17:41 -07:00
committed by GitHub
parent 4cddc40828
commit 8258ea39b0
10 changed files with 239 additions and 168 deletions

View File

@@ -1,17 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
combineLatest,
filter,
firstValueFrom,
map,
merge,
Observable,
of,
shareReplay,
Subject,
switchMap,
} from "rxjs";
import { combineLatest, filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
import { SemVer } from "semver";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -40,6 +29,7 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt
import { StateProvider } from "../../platform/state";
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey, UserKey } from "../../types/key";
import { perUserCache$ } from "../../vault/utils/observable-utilities";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { FieldType } from "../enums";
@@ -91,11 +81,12 @@ export class CipherService implements CipherServiceAbstraction {
this.sortCiphersByLastUsed,
);
/**
* Observable that forces the `cipherViews$` observable to re-emit with the provided value.
* Used to let subscribers of `cipherViews$` know that the decrypted ciphers have been cleared for the active user.
* Observable that forces the `cipherViews$` observable for the given user to emit a null value.
* Used to let subscribers of `cipherViews$` know that the decrypted ciphers have been cleared for the user and to
* clear them from the shareReplay buffer created in perUserCache$().
* @private
*/
private forceCipherViews$: Subject<CipherView[]> = new Subject<CipherView[]>();
private clearCipherViewsForUser$: Subject<UserId> = new Subject<UserId>();
constructor(
private keyService: KeyService,
@@ -132,13 +123,16 @@ export class CipherService implements CipherServiceAbstraction {
* A `null` value indicates that the latest encrypted ciphers have not been decrypted yet and that
* decryption is in progress. The latest decrypted ciphers will be emitted once decryption is complete.
*/
cipherViews$(userId: UserId): Observable<CipherView[] | null> {
return combineLatest([this.encryptedCiphersState(userId).state$, this.localData$(userId)]).pipe(
filter(([ciphers]) => ciphers != null), // Skip if ciphers haven't been loaded yor synced yet
switchMap(() => merge(this.forceCipherViews$, this.getAllDecrypted(userId))),
shareReplay({ bufferSize: 1, refCount: true }),
cipherViews$ = perUserCache$((userId: UserId): Observable<CipherView[] | null> => {
return combineLatest([
this.encryptedCiphersState(userId).state$,
this.localData$(userId),
this.keyService.cipherDecryptionKeys$(userId, true),
]).pipe(
filter(([ciphers, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet
switchMap(() => this.getAllDecrypted(userId)),
);
}
}, this.clearCipherViewsForUser$);
addEditCipherInfo$(userId: UserId): Observable<AddEditCipherInfo> {
return this.addEditCipherInfoState(userId).state$;
@@ -149,13 +143,11 @@ export class CipherService implements CipherServiceAbstraction {
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
failedToDecryptCiphers$(userId: UserId): Observable<CipherView[]> {
failedToDecryptCiphers$ = perUserCache$((userId: UserId): Observable<CipherView[]> => {
return this.failedToDecryptCiphersState(userId).state$.pipe(
filter((ciphers) => ciphers != null),
switchMap((ciphers) => merge(this.forceCipherViews$, of(ciphers))),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
}, this.clearCipherViewsForUser$);
async setDecryptedCipherCache(value: CipherView[], userId: UserId) {
// Sometimes we might prematurely decrypt the vault and that will result in no ciphers
@@ -190,10 +182,8 @@ export class CipherService implements CipherServiceAbstraction {
userId ??= activeUserId;
await this.clearDecryptedCiphersState(userId);
// Force the cipherView$ observable (which always tracks the active user) to re-emit
if (userId == activeUserId) {
this.forceCipherViews$.next(null);
}
// Force the cached cipherView$ observable(s) to emit a null value
this.clearCipherViewsForUser$.next(userId);
}
async encrypt(
@@ -402,10 +392,14 @@ export class CipherService implements CipherServiceAbstraction {
return await this.getDecryptedCiphers(userId);
}
const [newDecCiphers, failedCiphers] = await this.decryptCiphers(
await this.getAll(userId),
userId,
);
const decrypted = await this.decryptCiphers(await this.getAll(userId), userId);
// We failed to decrypt, return empty array but do not cache
if (decrypted == null) {
return [];
}
const [newDecCiphers, failedCiphers] = decrypted;
await this.setDecryptedCipherCache(newDecCiphers, userId);
await this.setFailedDecryptedCiphers(failedCiphers, userId);
@@ -429,12 +423,12 @@ export class CipherService implements CipherServiceAbstraction {
private async decryptCiphers(
ciphers: Cipher[],
userId: UserId,
): Promise<[CipherView[], CipherView[]]> {
): Promise<[CipherView[], CipherView[]] | null> {
const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId, true));
if (keys == null || (keys.userKey == null && Object.keys(keys.orgKeys).length === 0)) {
// return early if there are no keys to decrypt with
return [[], []];
return null;
}
// Group ciphers by orgId or under 'null' for the user's ciphers