1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-23 16:13:21 +00:00

Merge branch 'main' into km/new-mp-service-api

This commit is contained in:
Bernd Schoolmann
2025-07-18 11:54:27 +02:00
committed by GitHub
113 changed files with 3305 additions and 983 deletions

View File

@@ -1,5 +1,7 @@
import { Observable } from "rxjs";
import { DeviceType } from "@bitwarden/common/enums";
import { DeviceResponse } from "./responses/device.response";
import { DeviceView } from "./views/device.view";
@@ -15,4 +17,5 @@ export abstract class DevicesServiceAbstraction {
): Observable<DeviceView>;
abstract deactivateDevice$(deviceId: string): Observable<void>;
abstract getCurrentDevice$(): Observable<DeviceResponse>;
abstract getReadableDeviceTypeName(deviceType: DeviceType): string;
}

View File

@@ -1,7 +1,28 @@
/**
* The authentication status of the user
*
* See `AuthService.authStatusFor$` for details on how we determine the user's `AuthenticationStatus`
*/
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum AuthenticationStatus {
/**
* User is not authenticated
* - The user does not have an active account userId and/or an access token in state
*/
LoggedOut = 0,
/**
* User is authenticated but not decrypted
* - The user has an access token, but no user key in state
* - Vault data cannot be decrypted (because there is no user key)
*/
Locked = 1,
/**
* User is authenticated and decrypted
* - The user has an access token and a user key in state
* - Vault data can be decrypted (via user key)
*/
Unlocked = 2,
}

View File

@@ -1,5 +1,8 @@
import { Observable, defer, map } from "rxjs";
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ListResponse } from "../../../models/response/list.response";
import { AppIdService } from "../../../platform/abstractions/app-id.service";
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
@@ -17,8 +20,9 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser
*/
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
constructor(
private devicesApiService: DevicesApiServiceAbstraction,
private appIdService: AppIdService,
private devicesApiService: DevicesApiServiceAbstraction,
private i18nService: I18nService,
) {}
/**
@@ -86,4 +90,23 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction {
return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier);
});
}
/**
* @description Gets a human readable string of the device type name
*/
getReadableDeviceTypeName(type: DeviceType): string {
if (type === undefined) {
return this.i18nService.t("unknownDevice");
}
const metadata = DeviceTypeMetadata[type];
if (!metadata) {
return this.i18nService.t("unknownDevice");
}
const platform =
metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform;
const category = this.i18nService.t(metadata.category);
return platform ? `${category} - ${platform}` : category;
}
}

View File

@@ -35,7 +35,7 @@ export enum DeviceType {
* Each device type has a category corresponding to the client type and platform (Android, iOS, Chrome, Firefox, etc.)
*/
interface DeviceTypeMetadata {
category: "mobile" | "extension" | "webVault" | "desktop" | "cli" | "sdk" | "server";
category: "mobile" | "extension" | "webApp" | "desktop" | "cli" | "sdk" | "server";
platform: string;
}
@@ -49,15 +49,15 @@ export const DeviceTypeMetadata: Record<DeviceType, DeviceTypeMetadata> = {
[DeviceType.EdgeExtension]: { category: "extension", platform: "Edge" },
[DeviceType.VivaldiExtension]: { category: "extension", platform: "Vivaldi" },
[DeviceType.SafariExtension]: { category: "extension", platform: "Safari" },
[DeviceType.ChromeBrowser]: { category: "webVault", platform: "Chrome" },
[DeviceType.FirefoxBrowser]: { category: "webVault", platform: "Firefox" },
[DeviceType.OperaBrowser]: { category: "webVault", platform: "Opera" },
[DeviceType.EdgeBrowser]: { category: "webVault", platform: "Edge" },
[DeviceType.IEBrowser]: { category: "webVault", platform: "IE" },
[DeviceType.SafariBrowser]: { category: "webVault", platform: "Safari" },
[DeviceType.VivaldiBrowser]: { category: "webVault", platform: "Vivaldi" },
[DeviceType.DuckDuckGoBrowser]: { category: "webVault", platform: "DuckDuckGo" },
[DeviceType.UnknownBrowser]: { category: "webVault", platform: "Unknown" },
[DeviceType.ChromeBrowser]: { category: "webApp", platform: "Chrome" },
[DeviceType.FirefoxBrowser]: { category: "webApp", platform: "Firefox" },
[DeviceType.OperaBrowser]: { category: "webApp", platform: "Opera" },
[DeviceType.EdgeBrowser]: { category: "webApp", platform: "Edge" },
[DeviceType.IEBrowser]: { category: "webApp", platform: "IE" },
[DeviceType.SafariBrowser]: { category: "webApp", platform: "Safari" },
[DeviceType.VivaldiBrowser]: { category: "webApp", platform: "Vivaldi" },
[DeviceType.DuckDuckGoBrowser]: { category: "webApp", platform: "DuckDuckGo" },
[DeviceType.UnknownBrowser]: { category: "webApp", platform: "Unknown" },
[DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" },
[DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" },
[DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" },

View File

@@ -53,6 +53,7 @@ export enum FeatureFlag {
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
CipherKeyEncryption = "cipher-key-encryption",
EndUserNotifications = "pm-10609-end-user-notifications",
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
@@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EndUserNotifications]: FALSE,
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM19315EndUserActivationMvp]: FALSE,
/* Auth */

View File

@@ -5,6 +5,7 @@ import { Observable } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { UserKeyRotationDataProvider } from "@bitwarden/key-management";
import { CipherListView } from "@bitwarden/sdk-internal";
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
@@ -20,6 +21,7 @@ import { AttachmentView } from "../models/view/attachment.view";
import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export type EncryptionContext = {
cipher: Cipher;
@@ -29,6 +31,7 @@ export type EncryptionContext = {
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
abstract cipherViews$(userId: UserId): Observable<CipherView[]>;
abstract cipherListViews$(userId: UserId): Observable<CipherListView[] | CipherView[]>;
abstract ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>>;
abstract localData$(userId: UserId): Observable<Record<CipherId, LocalData>>;
/**
@@ -65,12 +68,12 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
): Promise<CipherView[]>;
abstract filterCiphersForUrl(
ciphers: CipherView[],
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
ciphers: C[],
url: string,
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
): Promise<CipherView[]>;
): Promise<C[]>;
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
/**
* Gets ciphers belonging to the specified organization that the user has explicit collection level access to.
@@ -198,9 +201,9 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
userId: UserId,
admin: boolean,
): Promise<CipherData>;
abstract sortCiphersByLastUsed(a: CipherView, b: CipherView): number;
abstract sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number;
abstract getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number;
abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number;
abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number;
abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number;
abstract softDelete(id: string | string[], userId: UserId): Promise<any>;
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
@@ -251,4 +254,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
response: Response,
userId: UserId,
): Promise<Uint8Array | null>;
/**
* Decrypts the full `CipherView` for a given `CipherViewLike`.
* When a `CipherView` instance is passed, it returns it as is.
*/
abstract getFullCipherView(c: CipherViewLike): Promise<CipherView>;
}

View File

@@ -5,6 +5,7 @@ import { Observable } from "rxjs";
import { SendView } from "../../tools/send/models/view/send.view";
import { IndexedEntityId, UserId } from "../../types/guid";
import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export abstract class SearchService {
indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>;
@@ -16,12 +17,16 @@ export abstract class SearchService {
ciphersToIndex: CipherView[],
indexedEntityGuid?: string,
) => Promise<void>;
searchCiphers: (
searchCiphers: <C extends CipherViewLike>(
userId: UserId,
query: string,
filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[],
ciphers?: CipherView[],
) => Promise<CipherView[]>;
searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[];
filter?: ((cipher: C) => boolean) | ((cipher: C) => boolean)[],
ciphers?: C[],
) => Promise<C[]>;
searchCiphersBasic: <C extends CipherViewLike>(
ciphers: C[],
query: string,
deleted?: boolean,
) => C[];
searchSends: (sends: SendView[], query: string) => SendView[];
}

View File

@@ -1,6 +1,6 @@
import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
export interface CipherIconDetails {
imageEnabled: boolean;
@@ -14,7 +14,7 @@ export interface CipherIconDetails {
export function buildCipherIcon(
iconsServerUrl: string | null,
cipher: CipherView,
cipher: CipherViewLike,
showFavicon: boolean,
): CipherIconDetails {
let icon: string = "bwi-globe";
@@ -36,12 +36,16 @@ export function buildCipherIcon(
showFavicon = false;
}
switch (cipher.type) {
const cipherType = CipherViewLikeUtils.getType(cipher);
const uri = CipherViewLikeUtils.uri(cipher);
const card = CipherViewLikeUtils.getCard(cipher);
switch (cipherType) {
case CipherType.Login:
icon = "bwi-globe";
if (cipher.login.uri) {
let hostnameUri = cipher.login.uri;
if (uri) {
let hostnameUri = uri;
let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) {
@@ -84,8 +88,8 @@ export function buildCipherIcon(
break;
case CipherType.Card:
icon = "bwi-credit-card";
if (showFavicon && cipher.card.brand in cardIcons) {
icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`;
if (showFavicon && card?.brand && card.brand in cardIcons) {
icon = `credit-card-icon ${cardIcons[card.brand]}`;
}
break;
case CipherType.Identity:

View File

@@ -8,13 +8,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { CollectionId } from "@bitwarden/common/types/guid";
import { getUserId } from "../../auth/services/account.service";
import { Cipher } from "../models/domain/cipher";
import { CipherView } from "../models/view/cipher.view";
/**
* Represents either a cipher or a cipher view.
*/
type CipherLike = Cipher | CipherView;
import { CipherLike } from "../types/cipher-like";
/**
* Service for managing user cipher authorization.
@@ -95,7 +89,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
}
}
return cipher.permissions.delete;
return !!cipher.permissions?.delete;
}),
);
}
@@ -118,7 +112,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
}
}
return cipher.permissions.restore;
return !!cipher.permissions?.restore;
}),
);
}

View File

@@ -71,6 +71,7 @@ import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
import { PasswordHistoryView } from "../models/view/password-history.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
import {
ADD_EDIT_CIPHER_INFO_KEY,
@@ -123,6 +124,43 @@ export class CipherService implements CipherServiceAbstraction {
return this.encryptedCiphersState(userId).state$.pipe(map((ciphers) => ciphers ?? {}));
}
/**
* Observable that emits an array of decrypted ciphers for given userId.
* This observable will not emit until the encrypted ciphers have either been loaded from state or after sync.
*
* This uses the SDK for decryption, when the `PM22134SdkCipherListView` feature flag is disabled the full `cipherViews$` observable will be emitted.
* Usage of the {@link CipherViewLike} type is recommended to ensure both `CipherView` and `CipherListView` are supported.
*/
cipherListViews$ = perUserCache$((userId: UserId) => {
return this.configService.getFeatureFlag$(FeatureFlag.PM22134SdkCipherListView).pipe(
switchMap((useSdk) => {
if (!useSdk) {
return this.cipherViews$(userId);
}
return combineLatest([
this.encryptedCiphersState(userId).state$,
this.localData$(userId),
this.keyService.cipherDecryptionKeys$(userId, true),
]).pipe(
filter(([cipherDataState, _, keys]) => cipherDataState != null && keys != null),
map(([cipherDataState, localData]) =>
Object.values(cipherDataState).map(
(cipherData) => new Cipher(cipherData, localData?.[cipherData.id as CipherId]),
),
),
switchMap(async (ciphers) => {
// TODO: remove this once failed decrypted ciphers are handled in the SDK
await this.setFailedDecryptedCiphers([], userId);
return this.cipherEncryptionService
.decryptMany(ciphers, userId)
.then((ciphers) => ciphers.sort(this.getLocaleSortingFunction()));
}),
);
}),
);
});
/**
* Observable that emits an array of decrypted ciphers for the active user.
* This observable will not emit until the encrypted ciphers have either been loaded from state or after sync.
@@ -419,11 +457,13 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
): Promise<[CipherView[], CipherView[]] | null> {
if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) {
const decryptStartTime = new Date().getTime();
const decryptStartTime = performance.now();
const decrypted = await this.decryptCiphersWithSdk(ciphers, userId);
this.logService.info(
`[CipherService] Decrypting ${decrypted.length} ciphers took ${new Date().getTime() - decryptStartTime}ms`,
);
this.logService.measure(decryptStartTime, "Vault", "CipherService", "decrypt complete", [
["Items", ciphers.length],
]);
// With SDK, failed ciphers are not returned
return [decrypted, []];
}
@@ -442,7 +482,7 @@ export class CipherService implements CipherServiceAbstraction {
},
{} as Record<string, Cipher[]>,
);
const decryptStartTime = new Date().getTime();
const decryptStartTime = performance.now();
const allCipherViews = (
await Promise.all(
Object.entries(grouped).map(async ([orgId, groupedCiphers]) => {
@@ -462,9 +502,11 @@ export class CipherService implements CipherServiceAbstraction {
)
.flat()
.sort(this.getLocaleSortingFunction());
this.logService.info(
`[CipherService] Decrypting ${allCipherViews.length} ciphers took ${new Date().getTime() - decryptStartTime}ms`,
);
this.logService.measure(decryptStartTime, "Vault", "CipherService", "decrypt complete", [
["Items", ciphers.length],
]);
// Split ciphers into two arrays, one for successfully decrypted ciphers and one for ciphers that failed to decrypt
return allCipherViews.reduce(
(acc, c) => {
@@ -539,18 +581,23 @@ export class CipherService implements CipherServiceAbstraction {
filter((c) => c != null),
switchMap(
async (ciphers) =>
await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch),
await this.filterCiphersForUrl<CipherView>(
ciphers,
url,
includeOtherTypes,
defaultMatch,
),
),
),
);
}
async filterCiphersForUrl(
ciphers: CipherView[],
async filterCiphersForUrl<C extends CipherViewLike>(
ciphers: C[],
url: string,
includeOtherTypes?: CipherType[],
defaultMatch: UriMatchStrategySetting = null,
): Promise<CipherView[]> {
): Promise<C[]> {
if (url == null && includeOtherTypes == null) {
return [];
}
@@ -561,22 +608,20 @@ export class CipherService implements CipherServiceAbstraction {
defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
return ciphers.filter((cipher) => {
const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null;
const type = CipherViewLikeUtils.getType(cipher);
const login = CipherViewLikeUtils.getLogin(cipher);
const cipherIsLogin = login !== null;
if (cipher.deletedDate !== null) {
if (CipherViewLikeUtils.isDeleted(cipher)) {
return false;
}
if (
Array.isArray(includeOtherTypes) &&
includeOtherTypes.includes(cipher.type) &&
!cipherIsLogin
) {
if (Array.isArray(includeOtherTypes) && includeOtherTypes.includes(type) && !cipherIsLogin) {
return true;
}
if (cipherIsLogin) {
return cipher.login.matchesUri(url, equivalentDomains, defaultMatch);
return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch);
}
return false;
@@ -1169,7 +1214,7 @@ export class CipherService implements CipherServiceAbstraction {
return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId);
}
sortCiphersByLastUsed(a: CipherView, b: CipherView): number {
sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number {
const aLastUsed =
a.localData && a.localData.lastUsedDate ? (a.localData.lastUsedDate as number) : null;
const bLastUsed =
@@ -1193,7 +1238,7 @@ export class CipherService implements CipherServiceAbstraction {
return 0;
}
sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number {
sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number {
const result = this.sortCiphersByLastUsed(a, b);
if (result !== 0) {
return result;
@@ -1202,7 +1247,7 @@ export class CipherService implements CipherServiceAbstraction {
return this.getLocaleSortingFunction()(a, b);
}
getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number {
getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number {
return (a, b) => {
let aName = a.name;
let bName = b.name;
@@ -1221,16 +1266,22 @@ export class CipherService implements CipherServiceAbstraction {
? this.i18nService.collator.compare(aName, bName)
: aName.localeCompare(bName);
if (result !== 0 || a.type !== CipherType.Login || b.type !== CipherType.Login) {
const aType = CipherViewLikeUtils.getType(a);
const bType = CipherViewLikeUtils.getType(b);
if (result !== 0 || aType !== CipherType.Login || bType !== CipherType.Login) {
return result;
}
if (a.login.username != null) {
aName += a.login.username;
const aLogin = CipherViewLikeUtils.getLogin(a);
const bLogin = CipherViewLikeUtils.getLogin(b);
if (aLogin.username != null) {
aName += aLogin.username;
}
if (b.login.username != null) {
bName += b.login.username;
if (bLogin.username != null) {
bName += bLogin.username;
}
return this.i18nService.collator
@@ -1898,4 +1949,17 @@ export class CipherService implements CipherServiceAbstraction {
return decryptedViews.sort(this.getLocaleSortingFunction());
}
/** Fetches the full `CipherView` when a `CipherListView` is passed. */
async getFullCipherView(c: CipherViewLike): Promise<CipherView> {
if (CipherViewLikeUtils.isCipherListView(c)) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const cipher = await this.get(c.id!, activeUserId);
return this.decrypt(cipher, activeUserId);
}
return Promise.resolve(c);
}
}

View File

@@ -1,6 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, Subject, firstValueFrom, map, shareReplay, switchMap, merge } from "rxjs";
import {
Observable,
Subject,
firstValueFrom,
map,
shareReplay,
switchMap,
merge,
filter,
combineLatest,
} from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -69,8 +79,12 @@ export class FolderService implements InternalFolderServiceAbstraction {
const observable = merge(
this.forceFolderViews[userId],
this.encryptedFoldersState(userId).state$.pipe(
switchMap((folderData) => {
combineLatest([
this.encryptedFoldersState(userId).state$,
this.keyService.userKey$(userId),
]).pipe(
filter(([folderData, userKey]) => folderData != null && userKey != null),
switchMap(([folderData, _]) => {
return this.decryptFolders(userId, folderData);
}),
),

View File

@@ -9,17 +9,15 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { Cipher } from "../models/domain/cipher";
import { CipherLike } from "../types/cipher-like";
import { CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
export type RestrictedCipherType = {
cipherType: CipherType;
allowViewOrgIds: string[];
};
type CipherLike = Cipher | CipherView;
export class RestrictedItemTypesService {
/**
* Emits an array of RestrictedCipherType objects:
@@ -94,7 +92,9 @@ export class RestrictedItemTypesService {
* - Otherwise → restricted
*/
isCipherRestricted(cipher: CipherLike, restrictedTypes: RestrictedCipherType[]): boolean {
const restriction = restrictedTypes.find((r) => r.cipherType === cipher.type);
const restriction = restrictedTypes.find(
(r) => r.cipherType === CipherViewLikeUtils.getType(cipher),
);
// If cipher type is not restricted by any organization, allow it
if (!restriction) {

View File

@@ -19,6 +19,7 @@ import { SearchService as SearchServiceAbstraction } from "../abstractions/searc
import { FieldType } from "../enums";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
export type SerializedLunrIndex = {
version: string;
@@ -129,12 +130,15 @@ export class SearchService implements SearchServiceAbstraction {
}
async isSearchable(userId: UserId, query: string): Promise<boolean> {
const time = performance.now();
query = SearchService.normalizeSearchQuery(query);
const index = await this.getIndexForSearch(userId);
const notSearchable =
query == null ||
(index == null && query.length < this.searchableMinLength) ||
(index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0);
this.logService.measure(time, "Vault", "SearchService", "isSearchable");
return !notSearchable;
}
@@ -147,7 +151,7 @@ export class SearchService implements SearchServiceAbstraction {
return;
}
const indexingStartTime = new Date().getTime();
const indexingStartTime = performance.now();
await this.setIsIndexing(userId, true);
await this.setIndexedEntityIdForSearch(userId, indexedEntityId as IndexedEntityId);
const builder = new lunr.Builder();
@@ -188,20 +192,19 @@ export class SearchService implements SearchServiceAbstraction {
await this.setIndexForSearch(userId, index.toJSON() as SerializedLunrIndex);
await this.setIsIndexing(userId, false);
this.logService.info(
`[SearchService] Building search index of ${ciphers.length} ciphers took ${
new Date().getTime() - indexingStartTime
}ms`,
);
this.logService.measure(indexingStartTime, "Vault", "SearchService", "index complete", [
["Items", ciphers.length],
]);
}
async searchCiphers(
async searchCiphers<C extends CipherViewLike>(
userId: UserId,
query: string,
filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null,
ciphers: CipherView[],
): Promise<CipherView[]> {
const results: CipherView[] = [];
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
ciphers: C[],
): Promise<C[]> {
const results: C[] = [];
if (query != null) {
query = SearchService.normalizeSearchQuery(query.trim().toLowerCase());
}
@@ -216,7 +219,7 @@ export class SearchService implements SearchServiceAbstraction {
if (filter != null && Array.isArray(filter) && filter.length > 0) {
ciphers = ciphers.filter((c) => filter.every((f) => f == null || f(c)));
} else if (filter != null) {
ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean);
ciphers = ciphers.filter(filter as (cipher: C) => boolean);
}
if (!(await this.isSearchable(userId, query))) {
@@ -236,7 +239,7 @@ export class SearchService implements SearchServiceAbstraction {
return this.searchCiphersBasic(ciphers, query);
}
const ciphersMap = new Map<string, CipherView>();
const ciphersMap = new Map<string, C>();
ciphers.forEach((c) => ciphersMap.set(c.id, c));
let searchResults: lunr.Index.Result[] = null;
@@ -270,10 +273,10 @@ export class SearchService implements SearchServiceAbstraction {
return results;
}
searchCiphersBasic(ciphers: CipherView[], query: string, deleted = false) {
searchCiphersBasic<C extends CipherViewLike>(ciphers: C[], query: string, deleted = false) {
query = SearchService.normalizeSearchQuery(query.trim().toLowerCase());
return ciphers.filter((c) => {
if (deleted !== c.isDeleted) {
if (deleted !== CipherViewLikeUtils.isDeleted(c)) {
return false;
}
if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) {
@@ -282,13 +285,17 @@ export class SearchService implements SearchServiceAbstraction {
if (query.length >= 8 && c.id.startsWith(query)) {
return true;
}
if (c.subTitle != null && c.subTitle.toLowerCase().indexOf(query) > -1) {
const subtitle = CipherViewLikeUtils.subtitle(c);
if (subtitle != null && subtitle.toLowerCase().indexOf(query) > -1) {
return true;
}
const login = CipherViewLikeUtils.getLogin(c);
if (
c.login &&
c.login.hasUris &&
c.login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1)
login &&
login.uris.length &&
login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1)
) {
return true;
}

View File

@@ -0,0 +1,9 @@
import { Cipher } from "../models/domain/cipher";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
/**
* Represents either a Cipher, CipherView or CipherListView.
*
* {@link CipherViewLikeUtils} provides logic to perform operations on each type.
*/
export type CipherLike = Cipher | CipherViewLike;

View File

@@ -0,0 +1,624 @@
import { CipherListView } from "@bitwarden/sdk-internal";
import { CipherType } from "../enums";
import { Attachment } from "../models/domain/attachment";
import { AttachmentView } from "../models/view/attachment.view";
import { CipherView } from "../models/view/cipher.view";
import { Fido2CredentialView } from "../models/view/fido2-credential.view";
import { IdentityView } from "../models/view/identity.view";
import { LoginUriView } from "../models/view/login-uri.view";
import { LoginView } from "../models/view/login.view";
import { CipherViewLikeUtils } from "./cipher-view-like-utils";
describe("CipherViewLikeUtils", () => {
const createCipherView = (type: CipherType = CipherType.Login): CipherView => {
const cipherView = new CipherView();
// Always set a type to avoid issues within `CipherViewLikeUtils`
cipherView.type = type;
return cipherView;
};
describe("isCipherListView", () => {
it("returns true when the cipher is a CipherListView", () => {
const cipherListViewLogin = {
type: {
login: {},
},
} as CipherListView;
const cipherListViewSshKey = {
type: "sshKey",
} as CipherListView;
expect(CipherViewLikeUtils.isCipherListView(cipherListViewLogin)).toBe(true);
expect(CipherViewLikeUtils.isCipherListView(cipherListViewSshKey)).toBe(true);
});
it("returns false when the cipher is not a CipherListView", () => {
const cipherView = createCipherView();
cipherView.type = CipherType.SecureNote;
expect(CipherViewLikeUtils.isCipherListView(cipherView)).toBe(false);
});
});
describe("getLogin", () => {
it("returns null when the cipher is not a login", () => {
const cipherView = createCipherView(CipherType.SecureNote);
expect(CipherViewLikeUtils.getLogin(cipherView)).toBeNull();
expect(CipherViewLikeUtils.getLogin({ type: "identity" } as CipherListView)).toBeNull();
});
describe("CipherView", () => {
it("returns the login object", () => {
const cipherView = createCipherView(CipherType.Login);
expect(CipherViewLikeUtils.getLogin(cipherView)).toEqual(cipherView.login);
});
});
describe("CipherListView", () => {
it("returns the login object", () => {
const cipherListView = {
type: {
login: {
username: "testuser",
hasFido2: false,
},
},
} as CipherListView;
expect(CipherViewLikeUtils.getLogin(cipherListView)).toEqual(
(cipherListView.type as any).login,
);
});
});
});
describe("getCard", () => {
it("returns null when the cipher is not a card", () => {
const cipherView = createCipherView(CipherType.SecureNote);
expect(CipherViewLikeUtils.getCard(cipherView)).toBeNull();
expect(CipherViewLikeUtils.getCard({ type: "identity" } as CipherListView)).toBeNull();
});
describe("CipherView", () => {
it("returns the card object", () => {
const cipherView = createCipherView(CipherType.Card);
expect(CipherViewLikeUtils.getCard(cipherView)).toEqual(cipherView.card);
});
});
describe("CipherListView", () => {
it("returns the card object", () => {
const cipherListView = {
type: {
card: {
brand: "Visa",
},
},
} as CipherListView;
expect(CipherViewLikeUtils.getCard(cipherListView)).toEqual(
(cipherListView.type as any).card,
);
});
});
});
describe("isDeleted", () => {
it("returns true when the cipher is deleted", () => {
const cipherListView = { deletedDate: "2024-02-02", type: "identity" } as CipherListView;
const cipherView = createCipherView();
cipherView.deletedDate = new Date();
expect(CipherViewLikeUtils.isDeleted(cipherListView)).toBe(true);
expect(CipherViewLikeUtils.isDeleted(cipherView)).toBe(true);
});
it("returns false when the cipher is not deleted", () => {
const cipherListView = { deletedDate: undefined, type: "identity" } as CipherListView;
const cipherView = createCipherView();
expect(CipherViewLikeUtils.isDeleted(cipherListView)).toBe(false);
expect(CipherViewLikeUtils.isDeleted(cipherView)).toBe(false);
});
});
describe("canAssignToCollections", () => {
describe("CipherView", () => {
let cipherView: CipherView;
beforeEach(() => {
cipherView = createCipherView();
});
it("returns true when the cipher is not assigned to an organization", () => {
expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(true);
});
it("returns false when the cipher is assigned to an organization and cannot be edited", () => {
cipherView.organizationId = "org-id";
cipherView.edit = false;
cipherView.viewPassword = false;
expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(false);
});
it("returns true when the cipher is assigned to an organization and can be edited", () => {
cipherView.organizationId = "org-id";
cipherView.edit = true;
cipherView.viewPassword = true;
expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(true);
});
});
describe("CipherListView", () => {
let cipherListView: CipherListView;
beforeEach(() => {
cipherListView = {
organizationId: undefined,
edit: false,
viewPassword: false,
type: { login: {} },
} as CipherListView;
});
it("returns true when the cipher is not assigned to an organization", () => {
expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(true);
});
it("returns false when the cipher is assigned to an organization and cannot be edited", () => {
cipherListView.organizationId = "org-id";
expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(false);
});
it("returns true when the cipher is assigned to an organization and can be edited", () => {
cipherListView.organizationId = "org-id";
cipherListView.edit = true;
cipherListView.viewPassword = true;
expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(true);
});
});
});
describe("getType", () => {
describe("CipherView", () => {
it("returns the type of the cipher", () => {
const cipherView = createCipherView();
cipherView.type = CipherType.Login;
expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Login);
cipherView.type = CipherType.SecureNote;
expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.SecureNote);
cipherView.type = CipherType.SshKey;
expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.SshKey);
cipherView.type = CipherType.Identity;
expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Identity);
cipherView.type = CipherType.Card;
expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Card);
});
});
describe("CipherListView", () => {
it("converts the `CipherViewListType` to `CipherType`", () => {
const cipherListView = {
type: { login: {} },
} as CipherListView;
expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Login);
cipherListView.type = { card: { brand: "Visa" } };
expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Card);
cipherListView.type = "sshKey";
expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.SshKey);
cipherListView.type = "identity";
expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Identity);
cipherListView.type = "secureNote";
expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.SecureNote);
});
});
});
describe("subtitle", () => {
describe("CipherView", () => {
it("returns the subtitle of the cipher", () => {
const cipherView = createCipherView();
cipherView.login = new LoginView();
cipherView.login.username = "Test Username";
expect(CipherViewLikeUtils.subtitle(cipherView)).toBe("Test Username");
});
});
describe("CipherListView", () => {
it("returns the subtitle of the cipher", () => {
const cipherListView = {
subtitle: "Test Subtitle",
type: "identity",
} as CipherListView;
expect(CipherViewLikeUtils.subtitle(cipherListView)).toBe("Test Subtitle");
});
});
});
describe("hasAttachments", () => {
describe("CipherView", () => {
it("returns true when the cipher has attachments", () => {
const cipherView = createCipherView();
cipherView.attachments = [new AttachmentView({ id: "1" } as Attachment)];
expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(true);
});
it("returns false when the cipher has no attachments", () => {
const cipherView = new CipherView();
(cipherView.attachments as any) = null;
expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(false);
});
});
describe("CipherListView", () => {
it("returns true when there are attachments", () => {
const cipherListView = { attachments: 1, type: "secureNote" } as CipherListView;
expect(CipherViewLikeUtils.hasAttachments(cipherListView)).toBe(true);
});
it("returns false when there are no attachments", () => {
const cipherListView = { attachments: 0, type: "secureNote" } as CipherListView;
expect(CipherViewLikeUtils.hasAttachments(cipherListView)).toBe(false);
});
});
});
describe("canLaunch", () => {
it("returns false when the cipher is not a login", () => {
const cipherView = createCipherView(CipherType.SecureNote);
expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(false);
expect(CipherViewLikeUtils.canLaunch({ type: "identity" } as CipherListView)).toBe(false);
});
describe("CipherView", () => {
it("returns true when the login has URIs that can be launched", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
cipherView.login.uris = [{ uri: "https://example.com" } as LoginUriView];
expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(true);
});
it("returns true when the uri does not have a protocol", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
const uriView = new LoginUriView();
uriView.uri = "bitwarden.com";
cipherView.login.uris = [uriView];
expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(true);
});
it("returns false when the login has no URIs", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(false);
});
});
describe("CipherListView", () => {
it("returns true when the login has URIs that can be launched", () => {
const cipherListView = {
type: { login: { uris: [{ uri: "https://example.com" }] } },
} as CipherListView;
expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(true);
});
it("returns true when the uri does not have a protocol", () => {
const cipherListView = {
type: { login: { uris: [{ uri: "bitwarden.com" }] } },
} as CipherListView;
expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(true);
});
it("returns false when the login has no URIs", () => {
const cipherListView = { type: { login: {} } } as CipherListView;
expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(false);
});
});
});
describe("getLaunchUri", () => {
it("returns undefined when the cipher is not a login", () => {
const cipherView = createCipherView(CipherType.SecureNote);
expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBeUndefined();
expect(
CipherViewLikeUtils.getLaunchUri({ type: "identity" } as CipherListView),
).toBeUndefined();
});
describe("CipherView", () => {
it("returns the first launch-able URI", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
cipherView.login.uris = [
{ uri: "" } as LoginUriView,
{ uri: "https://example.com" } as LoginUriView,
{ uri: "https://another.com" } as LoginUriView,
];
expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBe("https://example.com");
});
it("returns undefined when there are no URIs", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBeUndefined();
});
it("appends protocol when there are none", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
const uriView = new LoginUriView();
uriView.uri = "bitwarden.com";
cipherView.login.uris = [uriView];
expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBe("http://bitwarden.com");
});
});
describe("CipherListView", () => {
it("returns the first launch-able URI", () => {
const cipherListView = {
type: { login: { uris: [{ uri: "" }, { uri: "https://example.com" }] } },
} as CipherListView;
expect(CipherViewLikeUtils.getLaunchUri(cipherListView)).toBe("https://example.com");
});
it("returns undefined when there are no URIs", () => {
const cipherListView = { type: { login: {} } } as CipherListView;
expect(CipherViewLikeUtils.getLaunchUri(cipherListView)).toBeUndefined();
});
});
});
describe("matchesUri", () => {
const emptySet = new Set<string>();
it("returns false when the cipher is not a login", () => {
const cipherView = createCipherView(CipherType.SecureNote);
expect(CipherViewLikeUtils.matchesUri(cipherView, "https://example.com", emptySet)).toBe(
false,
);
});
describe("CipherView", () => {
it("returns true when the URI matches", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
const uri = new LoginUriView();
uri.uri = "https://example.com";
cipherView.login.uris = [uri];
expect(CipherViewLikeUtils.matchesUri(cipherView, "https://example.com", emptySet)).toBe(
true,
);
});
it("returns false when the URI does not match", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
const uri = new LoginUriView();
uri.uri = "https://www.bitwarden.com";
cipherView.login.uris = [uri];
expect(
CipherViewLikeUtils.matchesUri(cipherView, "https://www.another.com", emptySet),
).toBe(false);
});
});
describe("CipherListView", () => {
it("returns true when the URI matches", () => {
const cipherListView = {
type: { login: { uris: [{ uri: "https://example.com" }] } },
} as CipherListView;
expect(
CipherViewLikeUtils.matchesUri(cipherListView, "https://example.com", emptySet),
).toBe(true);
});
it("returns false when the URI does not match", () => {
const cipherListView = {
type: { login: { uris: [{ uri: "https://bitwarden.com" }] } },
} as CipherListView;
expect(
CipherViewLikeUtils.matchesUri(cipherListView, "https://another.com", emptySet),
).toBe(false);
});
});
});
describe("hasCopyableValue", () => {
describe("CipherView", () => {
it("returns true for login fields", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
cipherView.login.username = "testuser";
cipherView.login.password = "testpass";
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "username")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "password")).toBe(true);
});
it("returns true for card fields", () => {
const cipherView = createCipherView(CipherType.Card);
cipherView.card = { number: "1234-5678-9012-3456", code: "123" } as any;
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "cardNumber")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "securityCode")).toBe(true);
});
it("returns true for identity fields", () => {
const cipherView = createCipherView(CipherType.Identity);
cipherView.identity = new IdentityView();
cipherView.identity.email = "example@bitwarden.com";
cipherView.identity.phone = "123-456-7890";
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "email")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "phone")).toBe(true);
});
it("returns false when values are not populated", () => {
const cipherView = createCipherView(CipherType.Login);
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "email")).toBe(false);
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "password")).toBe(false);
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "securityCode")).toBe(false);
expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "username")).toBe(false);
});
});
describe("CipherListView", () => {
it("returns true for copyable fields in a login cipher", () => {
const cipherListView = {
type: { login: { username: "testuser" } },
copyableFields: ["LoginUsername", "LoginPassword"],
} as CipherListView;
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "username")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "password")).toBe(true);
});
it("returns true for copyable fields in a card cipher", () => {
const cipherListView = {
type: { card: { brand: "MasterCard" } },
copyableFields: ["CardNumber", "CardSecurityCode"],
} as CipherListView;
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "cardNumber")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "securityCode")).toBe(true);
});
it("returns true for copyable fields in an sshKey ciphers", () => {
const cipherListView = {
type: "sshKey",
copyableFields: ["SshKey"],
} as CipherListView;
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "privateKey")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "publicKey")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "keyFingerprint")).toBe(true);
});
it("returns true for copyable fields in an identity cipher", () => {
const cipherListView = {
type: "identity",
copyableFields: ["IdentityUsername", "IdentityEmail", "IdentityPhone"],
} as CipherListView;
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "username")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "email")).toBe(true);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "phone")).toBe(true);
});
it("returns false for when missing a field", () => {
const cipherListView = {
type: { login: {} },
copyableFields: ["LoginUsername"],
} as CipherListView;
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "password")).toBe(false);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "phone")).toBe(false);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "address")).toBe(false);
expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "publicKey")).toBe(false);
});
});
});
describe("hasFido2Credentials", () => {
describe("CipherView", () => {
it("returns true when the login has FIDO2 credentials", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
cipherView.login.fido2Credentials = [new Fido2CredentialView()];
expect(CipherViewLikeUtils.hasFido2Credentials(cipherView)).toBe(true);
});
it("returns false when the login has no FIDO2 credentials", () => {
const cipherView = createCipherView(CipherType.Login);
cipherView.login = new LoginView();
expect(CipherViewLikeUtils.hasFido2Credentials(cipherView)).toBe(false);
});
});
describe("CipherListView", () => {
it("returns true when the login has FIDO2 credentials", () => {
const cipherListView = {
type: { login: { fido2Credentials: [{ credentialId: "fido2-1" }] } },
} as CipherListView;
expect(CipherViewLikeUtils.hasFido2Credentials(cipherListView)).toBe(true);
});
it("returns false when the login has no FIDO2 credentials", () => {
const cipherListView = { type: { login: {} } } as CipherListView;
expect(CipherViewLikeUtils.hasFido2Credentials(cipherListView)).toBe(false);
});
});
});
describe("decryptionFailure", () => {
it("returns true when the cipher has a decryption failure", () => {
const cipherView = createCipherView();
cipherView.decryptionFailure = true;
expect(CipherViewLikeUtils.decryptionFailure(cipherView)).toBe(true);
});
it("returns false when the cipher does not have a decryption failure", () => {
const cipherView = createCipherView();
cipherView.decryptionFailure = false;
expect(CipherViewLikeUtils.decryptionFailure(cipherView)).toBe(false);
});
it("returns false when the cipher is a CipherListView without decryptionFailure", () => {
const cipherListView = { type: "secureNote" } as CipherListView;
expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false);
});
});
});

View File

@@ -0,0 +1,301 @@
import {
UriMatchStrategy,
UriMatchStrategySetting,
} from "@bitwarden/common/models/domain/domain-service";
import {
CardListView,
CipherListView,
CopyableCipherFields,
LoginListView,
LoginUriView as LoginListUriView,
} from "@bitwarden/sdk-internal";
import { CipherType } from "../enums";
import { Cipher } from "../models/domain/cipher";
import { CardView } from "../models/view/card.view";
import { CipherView } from "../models/view/cipher.view";
import { LoginUriView } from "../models/view/login-uri.view";
import { LoginView } from "../models/view/login.view";
/**
* Type union of {@link CipherView} and {@link CipherListView}.
*/
export type CipherViewLike = CipherView | CipherListView;
/**
* Utility class for working with ciphers that can be either a {@link CipherView} or a {@link CipherListView}.
*/
export class CipherViewLikeUtils {
/** @returns true when the given cipher is an instance of {@link CipherListView}. */
static isCipherListView = (cipher: CipherViewLike | Cipher): cipher is CipherListView => {
return typeof cipher.type === "object" || typeof cipher.type === "string";
};
/** @returns The login object from the input cipher. If the cipher is not of type Login, returns null. */
static getLogin = (cipher: CipherViewLike): LoginListView | LoginView | null => {
if (this.isCipherListView(cipher)) {
if (typeof cipher.type !== "object") {
return null;
}
return "login" in cipher.type ? cipher.type.login : null;
}
return cipher.type === CipherType.Login ? cipher.login : null;
};
/** @returns The first URI for a login cipher. If the cipher is not of type Login or has no associated URIs, returns null. */
static uri = (cipher: CipherViewLike) => {
const login = this.getLogin(cipher);
if (!login) {
return null;
}
if ("uri" in login) {
return login.uri;
}
return login.uris?.length ? login.uris[0].uri : null;
};
/** @returns The login object from the input cipher. If the cipher is not of type Login, returns null. */
static getCard = (cipher: CipherViewLike): CardListView | CardView | null => {
if (this.isCipherListView(cipher)) {
if (typeof cipher.type !== "object") {
return null;
}
return "card" in cipher.type ? cipher.type.card : null;
}
return cipher.type === CipherType.Card ? cipher.card : null;
};
/** @returns `true` when the cipher has been deleted, `false` otherwise. */
static isDeleted = (cipher: CipherViewLike): boolean => {
if (this.isCipherListView(cipher)) {
return !!cipher.deletedDate;
}
return cipher.isDeleted;
};
/** @returns `true` when the user can assign the cipher to a collection, `false` otherwise. */
static canAssignToCollections = (cipher: CipherViewLike): boolean => {
if (this.isCipherListView(cipher)) {
if (!cipher.organizationId) {
return true;
}
return cipher.edit && cipher.viewPassword;
}
return cipher.canAssignToCollections;
};
/**
* Returns the type of the cipher.
* For consistency, when the given cipher is a {@link CipherListView} the {@link CipherType} equivalent will be returned.
*/
static getType = (cipher: CipherViewLike | Cipher): CipherType => {
if (!this.isCipherListView(cipher)) {
return cipher.type;
}
// CipherListViewType is a string, so we need to map it to CipherType.
switch (true) {
case cipher.type === "secureNote":
return CipherType.SecureNote;
case cipher.type === "sshKey":
return CipherType.SshKey;
case cipher.type === "identity":
return CipherType.Identity;
case typeof cipher.type === "object" && "card" in cipher.type:
return CipherType.Card;
case typeof cipher.type === "object" && "login" in cipher.type:
return CipherType.Login;
default:
throw new Error(`Unknown cipher type: ${cipher.type}`);
}
};
/** @returns The subtitle of the cipher. */
static subtitle = (cipher: CipherViewLike): string | undefined => {
if (!this.isCipherListView(cipher)) {
return cipher.subTitle;
}
return cipher.subtitle;
};
/** @returns `true` when the cipher has attachments, false otherwise. */
static hasAttachments = (cipher: CipherViewLike): boolean => {
if (this.isCipherListView(cipher)) {
return typeof cipher.attachments === "number" && cipher.attachments > 0;
}
return cipher.hasAttachments;
};
/**
* @returns `true` when one of the URIs for the cipher can be launched.
* When a non-login cipher is passed, it will return false.
*/
static canLaunch = (cipher: CipherViewLike): boolean => {
const login = this.getLogin(cipher);
if (!login) {
return false;
}
return !!login.uris?.map((u) => toLoginUriView(u)).some((uri) => uri.canLaunch);
};
/**
* @returns The first launch-able URI for the cipher.
* When a non-login cipher is passed or none of the URLs, it will return undefined.
*/
static getLaunchUri = (cipher: CipherViewLike): string | undefined => {
const login = this.getLogin(cipher);
if (!login) {
return undefined;
}
return login.uris?.map((u) => toLoginUriView(u)).find((uri) => uri.canLaunch)?.launchUri;
};
/**
* @returns `true` when the `targetUri` matches for any URI on the cipher.
* Uses the existing logic from `LoginView.matchesUri` for both `CipherView` and `CipherListView`
*/
static matchesUri = (
cipher: CipherViewLike,
targetUri: string,
equivalentDomains: Set<string>,
defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain,
): boolean => {
if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) {
return false;
}
if (!this.isCipherListView(cipher)) {
return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch);
}
const login = this.getLogin(cipher);
if (!login?.uris?.length) {
return false;
}
const loginUriViews = login.uris
.filter((u) => !!u.uri)
.map((u) => {
const view = new LoginUriView();
view.match = u.match ?? defaultUriMatch;
view.uri = u.uri!; // above `filter` ensures `u.uri` is not null or undefined
return view;
});
return loginUriViews.some((uriView) =>
uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch),
);
};
/** @returns true when the `copyField` is populated on the given cipher. */
static hasCopyableValue = (cipher: CipherViewLike, copyField: string): boolean => {
// `CipherListView` instances do not contain the values to be copied, but rather a list of copyable fields.
// When the copy action is performed on a `CipherListView`, the full cipher will need to be decrypted.
if (this.isCipherListView(cipher)) {
let _copyField = copyField;
if (_copyField === "username" && this.getType(cipher) === CipherType.Login) {
_copyField = "usernameLogin";
} else if (_copyField === "username" && this.getType(cipher) === CipherType.Identity) {
_copyField = "usernameIdentity";
}
return cipher.copyableFields.includes(copyActionToCopyableFieldMap[_copyField]);
}
// When the full cipher is available, check the specific field
switch (copyField) {
case "username":
return !!cipher.login?.username || !!cipher.identity?.username;
case "password":
return !!cipher.login?.password;
case "totp":
return !!cipher.login?.totp;
case "cardNumber":
return !!cipher.card?.number;
case "securityCode":
return !!cipher.card?.code;
case "email":
return !!cipher.identity?.email;
case "phone":
return !!cipher.identity?.phone;
case "address":
return !!cipher.identity?.fullAddressForCopy;
case "secureNote":
return !!cipher.notes;
case "privateKey":
return !!cipher.sshKey?.privateKey;
case "publicKey":
return !!cipher.sshKey?.publicKey;
case "keyFingerprint":
return !!cipher.sshKey?.keyFingerprint;
default:
return false;
}
};
/** @returns true when the cipher has fido2 credentials */
static hasFido2Credentials = (cipher: CipherViewLike): boolean => {
const login = this.getLogin(cipher);
return !!login?.fido2Credentials?.length;
};
/**
* Returns the `decryptionFailure` property from the cipher when available.
* TODO: https://bitwarden.atlassian.net/browse/PM-22515 - alter for `CipherListView` if needed
*/
static decryptionFailure = (cipher: CipherViewLike): boolean => {
return "decryptionFailure" in cipher ? cipher.decryptionFailure : false;
};
}
/**
* Mapping between the generic copy actions and the specific fields in a `CipherViewLike`.
*/
const copyActionToCopyableFieldMap: Record<string, CopyableCipherFields> = {
usernameLogin: "LoginUsername",
password: "LoginPassword",
totp: "LoginTotp",
cardNumber: "CardNumber",
securityCode: "CardSecurityCode",
usernameIdentity: "IdentityUsername",
email: "IdentityEmail",
phone: "IdentityPhone",
address: "IdentityAddress",
secureNote: "SecureNotes",
privateKey: "SshKey",
publicKey: "SshKey",
keyFingerprint: "SshKey",
};
/** Converts a `LoginListUriView` to a `LoginUriView`. */
const toLoginUriView = (uri: LoginListUriView | LoginUriView): LoginUriView => {
if (uri instanceof LoginUriView) {
return uri;
}
const loginUriView = new LoginUriView();
if (uri.match) {
loginUriView.match = uri.match;
}
if (uri.uri) {
loginUriView.uri = uri.uri;
}
return loginUriView;
};