1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 00:53:22 +00:00

Merge branch 'main' into km/pm-27331

This commit is contained in:
Thomas Avery
2026-02-09 17:49:11 -06:00
1113 changed files with 57381 additions and 15980 deletions

View File

@@ -3,10 +3,11 @@ import { PolicyType } from "../../enums";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Policy } from "../../models/domain/policy";
import { PolicyRequest } from "../../models/request/policy.request";
import { PolicyStatusResponse } from "../../models/response/policy-status.response";
import { PolicyResponse } from "../../models/response/policy.response";
export abstract class PolicyApiServiceAbstraction {
abstract getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
abstract getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyStatusResponse>;
abstract getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
abstract getPoliciesByToken: (

View File

@@ -121,13 +121,13 @@ export class CollectionAdminView extends CollectionView {
try {
view.name = await encryptService.decryptString(new EncString(view.name), orgKey);
} catch (e) {
view.name = "[error: cannot decrypt]";
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
// eslint-disable-next-line no-console
console.error(
"[CollectionAdminView/fromCollectionAccessDetails] Error decrypting collection name",
e,
);
throw e;
}
view.assigned = collection.assigned;
view.readOnly = collection.readOnly;

View File

@@ -126,7 +126,14 @@ export class CollectionView implements View, ITreeNodeObject {
): Promise<CollectionView> {
const view = new CollectionView({ ...collection, name: "" });
view.name = await encryptService.decryptString(collection.name, key);
try {
view.name = await encryptService.decryptString(collection.name, key);
} catch (e) {
view.name = "[error: cannot decrypt]";
// eslint-disable-next-line no-console
console.error("[CollectionView] Error decrypting collection name", e);
}
view.assigned = true;
view.externalId = collection.externalId;
view.readOnly = collection.readOnly;

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class ProviderUserConfirmRequest {
key: string;
protected key: string;
constructor(key: string) {
this.key = key;
}
}

View File

@@ -0,0 +1,19 @@
import { BaseResponse } from "../../../models/response/base.response";
import { PolicyType } from "../../enums";
export class PolicyStatusResponse extends BaseResponse {
organizationId: string;
type: PolicyType;
data: any;
enabled: boolean;
canToggleState: boolean;
constructor(response: any) {
super(response);
this.organizationId = this.getResponseProperty("OrganizationId");
this.type = this.getResponseProperty("Type");
this.data = this.getResponseProperty("Data");
this.enabled = this.getResponseProperty("Enabled");
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
}
}

View File

@@ -14,6 +14,7 @@ import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Policy } from "../../models/domain/policy";
import { PolicyRequest } from "../../models/request/policy.request";
import { PolicyStatusResponse } from "../../models/response/policy-status.response";
import { PolicyResponse } from "../../models/response/policy.response";
export class PolicyApiService implements PolicyApiServiceAbstraction {
@@ -23,7 +24,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
private accountService: AccountService,
) {}
async getPolicy(organizationId: string, type: PolicyType): Promise<PolicyResponse> {
async getPolicy(organizationId: string, type: PolicyType): Promise<PolicyStatusResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/policies/" + type,

View File

@@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
/**
* The IdTokenresponse only returns a single WebAuthn PRF option.
* To support immediate unlock after logging in with the same PRF passkey.
*/
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
constructor(response: IUserDecryptionOptionsServerResponse) {

View File

@@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response";
export interface IWebAuthnPrfDecryptionOptionServerResponse {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
}
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
encryptedPrivateKey: EncString;
encryptedUserKey: EncString;
credentialId: string;
transports: string[];
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
super(response);
if (response.EncryptedPrivateKey) {
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
if (encPrivateKey) {
this.encryptedPrivateKey = new EncString(encPrivateKey);
}
if (response.EncryptedUserKey) {
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
const encUserKey = this.getResponseProperty("EncryptedUserKey");
if (encUserKey) {
this.encryptedUserKey = new EncString(encUserKey);
}
this.credentialId = this.getResponseProperty("CredentialId");
this.transports = this.getResponseProperty("Transports") || [];
}
}

View File

@@ -64,14 +64,13 @@ describe("SendTokenService", () => {
"send_id_required",
"password_hash_b64_required",
"email_required",
"email_and_otp_required_otp_sent",
"email_and_otp_required",
"unknown",
];
const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [
"send_id_invalid",
"password_hash_b64_invalid",
"email_invalid",
"otp_invalid",
"otp_generation_failed",
"unknown",

View File

@@ -31,13 +31,6 @@ export function passwordHashB64Invalid(
return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid";
}
export type EmailInvalid = InvalidGrant & {
send_access_error_type: "email_invalid";
};
export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid";
}
export type OtpInvalid = InvalidGrant & {
send_access_error_type: "otp_invalid";
};

View File

@@ -39,16 +39,12 @@ export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailReq
return e.error === "invalid_request" && e.send_access_error_type === "email_required";
}
export type EmailAndOtpRequiredEmailSent = InvalidRequest & {
send_access_error_type: "email_and_otp_required_otp_sent";
export type EmailAndOtpRequired = InvalidRequest & {
send_access_error_type: "email_and_otp_required";
};
export function emailAndOtpRequiredEmailSent(
e: SendAccessTokenApiErrorResponse,
): e is EmailAndOtpRequiredEmailSent {
return (
e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent"
);
export function emailAndOtpRequired(e: SendAccessTokenApiErrorResponse): e is EmailAndOtpRequired {
return e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required";
}
export type UnknownInvalidRequest = InvalidRequest & {

View File

@@ -28,6 +28,41 @@ export const EVENTS = {
SUBMIT: "submit",
} as const;
/**
* HTML attributes observed by the MutationObserver for autofill form/field tracking.
* If you need to observe a new attribute, add it here.
*/
export const AUTOFILL_ATTRIBUTES = {
ACTION: "action",
ARIA_DESCRIBEDBY: "aria-describedby",
ARIA_DISABLED: "aria-disabled",
ARIA_HASPOPUP: "aria-haspopup",
ARIA_HIDDEN: "aria-hidden",
ARIA_LABEL: "aria-label",
ARIA_LABELLEDBY: "aria-labelledby",
AUTOCOMPLETE: "autocomplete",
AUTOCOMPLETE_TYPE: "autocompletetype",
X_AUTOCOMPLETE_TYPE: "x-autocompletetype",
CHECKED: "checked",
CLASS: "class",
DATA_LABEL: "data-label",
DATA_STRIPE: "data-stripe",
DISABLED: "disabled",
ID: "id",
MAXLENGTH: "maxlength",
METHOD: "method",
NAME: "name",
PLACEHOLDER: "placeholder",
POPOVER: "popover",
POPOVERTARGET: "popovertarget",
POPOVERTARGETACTION: "popovertargetaction",
READONLY: "readonly",
REL: "rel",
TABINDEX: "tabindex",
TITLE: "title",
TYPE: "type",
} as const;
export const ClearClipboardDelay = {
Never: null as null,
TenSeconds: 10,

View File

@@ -13,15 +13,20 @@ export enum FeatureFlag {
/* Admin Console Team */
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
DefaultUserCollectionRestore = "pm-30883-my-items-restored-users",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
SafariAccountSwitching = "pm-5594-safari-account-switching",
/* Autofill */
UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic",
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
SSHAgentV2 = "ssh-agent-v2",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
@@ -38,13 +43,14 @@ export enum FeatureFlag {
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
PasskeyUnlock = "pm-2035-passkey-unlock",
DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration",
/* Tools */
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
@@ -54,26 +60,28 @@ export enum FeatureFlag {
/* DIRT */
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
EventManagementForHuntress = "event-management-for-huntress",
PhishingDetection = "phishing-detection",
Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements",
/* Vault */
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
CipherKeyEncryption = "cipher-key-encryption",
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework",
/* Innovation */
PM19148_InnovationArchive = "pm-19148-innovation-archive",
/* Desktop */
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
/* UIF */
RouterFocusManagement = "router-focus-management",
@@ -99,12 +107,15 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
[FeatureFlag.DefaultUserCollectionRestore]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
/* Autofill */
[FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
/* Tools */
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
@@ -114,20 +125,23 @@ export const DefaultFeatureFlagValue = {
/* DIRT */
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
[FeatureFlag.EventManagementForHuntress]: FALSE,
[FeatureFlag.PhishingDetection]: FALSE,
[FeatureFlag.Milestone11AppPageImprovements]: FALSE,
/* Vault */
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
[FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
[FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE,
[FeatureFlag.SafariAccountSwitching]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
@@ -144,22 +158,24 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.PasskeyUnlock]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.ContentScriptIpcChannelFramework]: FALSE,
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
/* Desktop */
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
[FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
/* UIF */
[FeatureFlag.RouterFocusManagement]: FALSE,

View File

@@ -7,7 +7,7 @@ import { CsprngArray } from "../../../types/csprng";
export abstract class CryptoFunctionService {
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract pbkdf2(
@@ -17,7 +17,7 @@ export abstract class CryptoFunctionService {
iterations: number,
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hkdf(
@@ -28,7 +28,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha256" | "sha512",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hkdfExpand(
@@ -38,7 +38,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha256" | "sha512",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hash(
@@ -46,7 +46,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha1" | "sha256" | "sha512" | "md5",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hmacFast(
@@ -56,7 +56,7 @@ export abstract class CryptoFunctionService {
): Promise<Uint8Array | string>;
abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise<boolean>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract aesDecryptFastParameters(
@@ -66,7 +66,7 @@ export abstract class CryptoFunctionService {
key: SymmetricCryptoKey,
): CbcDecryptParameters<Uint8Array | string>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract aesDecryptFast({
@@ -76,7 +76,7 @@ export abstract class CryptoFunctionService {
| { mode: "cbc"; parameters: CbcDecryptParameters<Uint8Array | string> }
| { mode: "ecb"; parameters: EcbDecryptParameters<Uint8Array | string> }): Promise<string>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
*/
abstract aesDecrypt(
data: Uint8Array,
@@ -85,7 +85,7 @@ export abstract class CryptoFunctionService {
mode: "cbc" | "ecb",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract rsaEncrypt(
@@ -94,7 +94,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha1",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract rsaDecrypt(

View File

@@ -1,24 +1,24 @@
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { EncString } from "../models/enc-string";
export abstract class EncryptService {
/**
* A temporary init method to make the encrypt service listen to feature-flag changes.
* This will be removed once the feature flag has been rolled out.
*/
abstract init(configService: ConfigService): void;
/**
* Encrypts a string to an EncString
*
* @deprecated NOTE: For new use-cases, prefer using the DataEnvelope inside the SDK instead. This
* is both safer and more maintainable.
*
* @param plainValue - The value to encrypt
* @param key - The key to encrypt the value with
*/
abstract encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString>;
/**
* Encrypts bytes to an EncString
*
* @deprecated NOTE: You probably do not want to encrypt raw bytes. Please contact the Key-Management team if you think
* you need to.
*
* @param plainValue - The value to encrypt
* @param key - The key to encrypt the value with
* @deprecated Bytes are not the right abstraction to encrypt in. Use e.g. key wrapping or file encryption instead
@@ -137,6 +137,9 @@ export abstract class EncryptService {
* Encapsulates a symmetric key with an asymmetric public key
* Note: This does not establish sender authenticity
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
*
* @deprecated NOTE: You probably do not want to use this. Please contact the Key-Management team if you think you need to.
*
* @param sharedKey - The symmetric key that is to be shared
* @param encapsulationKey - The encapsulation key (public key) of the receiver that the key is shared with
*/

View File

@@ -27,7 +27,7 @@ export abstract class KeyGenerationService {
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869}
* for details.
*
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
* New functionality should not be built on top of it, and instead should be built in the sdk.
*
* @param bitLength Length of key material.
@@ -44,7 +44,7 @@ export abstract class KeyGenerationService {
/**
* Derives a 64 byte key from key material.
*
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
* New functionality should not be built on top of it, and instead should be built in the sdk.
*
* @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}.
@@ -63,7 +63,7 @@ export abstract class KeyGenerationService {
/**
* Derives a 32 byte key from a password using a key derivation function.
*
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
* New functionality should not be built on top of it, and instead should be built in the sdk.
*
* @param password Password to derive the key from.
@@ -80,7 +80,7 @@ export abstract class KeyGenerationService {
/**
* Derives a 64 byte key from a 32 byte key using a key derivation function.
*
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
* New functionality should not be built on top of it, and instead should be built in the sdk.
*
* @param key 32 byte key.

View File

@@ -1,9 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
@@ -15,28 +13,12 @@ import { PureCrypto } from "@bitwarden/sdk-internal";
import { EncryptService } from "../abstractions/encrypt.service";
export class EncryptServiceImplementation implements EncryptService {
private disableType0Decryption = false;
constructor(
protected cryptoFunctionService: CryptoFunctionService,
protected logService: LogService,
protected logMacFailures: boolean,
) {}
init(configService: ConfigService): void {
configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.setDisableType0Decryption(
newConfig.featureStates[FeatureFlag.PM25174_DisableType0Decryption] === true,
);
}
});
}
setDisableType0Decryption(disable: boolean): void {
this.disableType0Decryption = disable;
}
async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString> {
if (plainValue == null) {
this.logService.warning(
@@ -60,7 +42,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise<string> {
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -68,7 +50,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -76,7 +58,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (this.disableType0Decryption && encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
if (encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -148,10 +130,7 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
) {
if (wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -171,10 +150,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
) {
if (wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -194,10 +170,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64
) {
if (keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}

View File

@@ -163,7 +163,7 @@ describe("EncryptService", () => {
describe("decryptString", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("encrypted_string");
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_string");
const result = await encryptService.decryptString(encString, key);
expect(result).toEqual("decrypted_string");
expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith(
@@ -172,8 +172,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_string");
await expect(encryptService.decryptString(encString, key)).rejects.toThrow(
@@ -185,7 +184,7 @@ describe("EncryptService", () => {
describe("decryptBytes", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("encrypted_bytes");
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_bytes");
const result = await encryptService.decryptBytes(encString, key);
expect(result).toEqual(new Uint8Array(3));
expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith(
@@ -194,8 +193,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_bytes");
await expect(encryptService.decryptBytes(encString, key)).rejects.toThrow(
@@ -216,8 +214,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encBuffer = EncArrayBuffer.fromParts(
EncryptionType.AesCbc256_B64,
@@ -234,7 +231,10 @@ describe("EncryptService", () => {
describe("unwrapDecapsulationKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_decapsulation_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_decapsulation_key",
);
const result = await encryptService.unwrapDecapsulationKey(encString, key);
expect(result).toEqual(new Uint8Array(4));
expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith(
@@ -242,8 +242,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_decapsulation_key");
await expect(encryptService.unwrapDecapsulationKey(encString, key)).rejects.toThrow(
@@ -267,7 +266,10 @@ describe("EncryptService", () => {
describe("unwrapEncapsulationKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_encapsulation_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_encapsulation_key",
);
const result = await encryptService.unwrapEncapsulationKey(encString, key);
expect(result).toEqual(new Uint8Array(5));
expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith(
@@ -275,8 +277,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_encapsulation_key");
await expect(encryptService.unwrapEncapsulationKey(encString, key)).rejects.toThrow(
@@ -300,7 +301,10 @@ describe("EncryptService", () => {
describe("unwrapSymmetricKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_symmetric_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_symmetric_key",
);
const result = await encryptService.unwrapSymmetricKey(encString, key);
expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith(
@@ -308,8 +312,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_symmetric_key");
await expect(encryptService.unwrapSymmetricKey(encString, key)).rejects.toThrow(

View File

@@ -1,9 +1,15 @@
import { WebAuthnPrfDecryptionOptionResponse } from "../../../auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response";
export class UserDecryptionResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
/**
* The sync service returns an array of WebAuthn PRF options.
*/
webAuthnPrfOptions?: WebAuthnPrfDecryptionOptionResponse[];
constructor(response: unknown) {
super(response);
@@ -11,5 +17,12 @@ export class UserDecryptionResponse extends BaseResponse {
if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") {
this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock);
}
const webAuthnPrfOptions = this.getResponseProperty("WebAuthnPrfOptions");
if (webAuthnPrfOptions != null && Array.isArray(webAuthnPrfOptions)) {
this.webAuthnPrfOptions = webAuthnPrfOptions.map(
(option) => new WebAuthnPrfDecryptionOptionResponse(option),
);
}
}
}

View File

@@ -11,6 +11,20 @@ import { PinLockType } from "./pin-lock-type";
* The PinStateService manages the storage and retrieval of PIN-related state for user accounts.
*/
export abstract class PinStateServiceAbstraction {
/**
* Checks if a user is enrolled into PIN unlock
* @param userId The user's id
* @throws If the user id is not provided
*/
abstract pinSet$(userId: UserId): Observable<boolean>;
/**
* Gets the user's {@link PinLockType}
* @param userId The user's id
* @throws If the user id is not provided
*/
abstract pinLockType$(userId: UserId): Observable<PinLockType>;
/**
* Gets the user's UserKey encrypted PIN
* @deprecated - This is not a public API. DO NOT USE IT
@@ -21,17 +35,12 @@ export abstract class PinStateServiceAbstraction {
/**
* Gets the user's {@link PinLockType}
* @deprecated Use {@link pinLockType$} instead
* @param userId The user's id
* @throws If the user id is not provided
*/
abstract getPinLockType(userId: UserId): Promise<PinLockType>;
/**
* Checks if a user is enrolled into PIN unlock
* @param userId The user's id
*/
abstract isPinSet(userId: UserId): Promise<boolean>;
/**
* Gets the user's PIN-protected UserKey envelope, either persistent or ephemeral based on the provided PinLockType
* @deprecated - This is not a public API. DO NOT USE IT

View File

@@ -1,4 +1,4 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { combineLatest, firstValueFrom, map, Observable } from "rxjs";
import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
import { StateProvider } from "@bitwarden/state";
@@ -26,27 +26,36 @@ export class PinStateService implements PinStateServiceAbstraction {
.pipe(map((value) => (value ? new EncString(value) : null)));
}
async isPinSet(userId: UserId): Promise<boolean> {
pinSet$(userId: UserId): Observable<boolean> {
assertNonNullish(userId, "userId");
return (await this.getPinLockType(userId)) !== "DISABLED";
return this.pinLockType$(userId).pipe(map((pinLockType) => pinLockType !== "DISABLED"));
}
pinLockType$(userId: UserId): Observable<PinLockType> {
assertNonNullish(userId, "userId");
return combineLatest([
this.pinProtectedUserKeyEnvelope$(userId, "PERSISTENT").pipe(map((key) => key != null)),
this.stateProvider
.getUserState$(USER_KEY_ENCRYPTED_PIN, userId)
.pipe(map((key) => key != null)),
]).pipe(
map(([isPersistentPinSet, isPinSet]) => {
if (isPersistentPinSet) {
return "PERSISTENT";
} else if (isPinSet) {
return "EPHEMERAL";
} else {
return "DISABLED";
}
}),
);
}
async getPinLockType(userId: UserId): Promise<PinLockType> {
assertNonNullish(userId, "userId");
const isPersistentPinSet =
(await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null;
const isPinSet =
(await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) !=
null;
if (isPersistentPinSet) {
return "PERSISTENT";
} else if (isPinSet) {
return "EPHEMERAL";
} else {
return "DISABLED";
}
return await firstValueFrom(this.pinLockType$(userId));
}
async getPinProtectedUserKeyEnvelope(
@@ -55,17 +64,7 @@ export class PinStateService implements PinStateServiceAbstraction {
): Promise<PasswordProtectedKeyEnvelope | null> {
assertNonNullish(userId, "userId");
if (pinLockType === "EPHEMERAL") {
return await firstValueFrom(
this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId),
);
} else if (pinLockType === "PERSISTENT") {
return await firstValueFrom(
this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId),
);
} else {
throw new Error(`Unsupported PinLockType: ${pinLockType}`);
}
return await firstValueFrom(this.pinProtectedUserKeyEnvelope$(userId, pinLockType));
}
async setPinState(
@@ -110,4 +109,19 @@ export class PinStateService implements PinStateServiceAbstraction {
await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId);
}
private pinProtectedUserKeyEnvelope$(
userId: UserId,
pinLockType: PinLockType,
): Observable<PasswordProtectedKeyEnvelope | null> {
assertNonNullish(userId, "userId");
if (pinLockType === "EPHEMERAL") {
return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId);
} else if (pinLockType === "PERSISTENT") {
return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId);
} else {
throw new Error(`Unsupported PinLockType: ${pinLockType}`);
}
}
}

View File

@@ -1,4 +1,4 @@
import { firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
@@ -94,14 +94,50 @@ describe("PinStateService", () => {
});
});
describe("getPinLockType()", () => {
describe("pinSet$", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should throw an error if userId is null", async () => {
// Act & Assert
await expect(sut.getPinLockType(null as any)).rejects.toThrow("userId");
expect(() => sut.pinSet$(null as any)).toThrow("userId");
});
it("should return false when pin lock type is DISABLED", async () => {
// Arrange
jest.spyOn(sut, "pinLockType$").mockReturnValue(of("DISABLED"));
// Act
const result = await firstValueFrom(sut.pinSet$(mockUserId));
// Assert
expect(result).toBe(false);
});
it.each([["PERSISTENT" as PinLockType], ["EPHEMERAL" as PinLockType]])(
"should return true when pin lock type is %s",
async (pinLockType) => {
// Arrange
jest.spyOn(sut, "pinLockType$").mockReturnValue(of(pinLockType));
// Act
const result = await firstValueFrom(sut.pinSet$(mockUserId));
// Assert
expect(result).toBe(true);
},
);
});
describe("pinLockType$", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should throw an error if userId is null", async () => {
// Act & Assert
expect(() => sut.pinLockType$(null as any)).toThrow("userId");
});
it("should return 'PERSISTENT' if a pin protected user key (persistent) is found", async () => {
@@ -114,7 +150,7 @@ describe("PinStateService", () => {
);
// Act
const result = await sut.getPinLockType(mockUserId);
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
// Assert
expect(result).toBe("PERSISTENT");
@@ -125,7 +161,7 @@ describe("PinStateService", () => {
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId);
// Act
const result = await sut.getPinLockType(mockUserId);
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
// Assert
expect(result).toBe("EPHEMERAL");
@@ -135,7 +171,7 @@ describe("PinStateService", () => {
// Arrange - don't set any PIN-related state
// Act
const result = await sut.getPinLockType(mockUserId);
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
// Assert
expect(result).toBe("DISABLED");
@@ -151,7 +187,7 @@ describe("PinStateService", () => {
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId);
// Act
const result = await sut.getPinLockType(mockUserId);
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
// Assert
expect(result).toBe("DISABLED");

View File

@@ -20,10 +20,9 @@ export abstract class VaultTimeoutSettingsService {
/**
* Get the available vault timeout actions for the current user
*
* **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes
* @param userId The user id to check. If not provided, the current user is used
*/
abstract availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]>;
abstract availableVaultTimeoutActions$(userId?: UserId): Observable<VaultTimeoutAction[]>;
/**
* Evaluates the user's available vault timeout actions and returns a boolean representing
@@ -55,5 +54,5 @@ export abstract class VaultTimeoutSettingsService {
* @param userId The user id to check. If not provided, the current user is used
* @returns boolean true if biometric lock is set
*/
abstract isBiometricLockSet(userId?: string): Promise<boolean>;
abstract isBiometricLockSet(userId?: UserId): Promise<boolean>;
}

View File

@@ -78,7 +78,8 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout);
biometricStateService.biometricUnlockEnabled$ = of(false);
pinStateService.pinSet$.mockReturnValue(of(false));
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
});
afterEach(() => {
@@ -86,72 +87,121 @@ describe("VaultTimeoutSettingsService", () => {
});
describe("availableVaultTimeoutActions$", () => {
it("always returns LogOut", async () => {
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
describe("when no userId provided (active user)", () => {
it("always returns LogOut", async () => {
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
expect(result).toContain(VaultTimeoutAction.LogOut);
expect(result).toContain(VaultTimeoutAction.LogOut);
});
it("contains Lock when the user has a master password", async () => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith(
mockUserId,
);
expect(result).toContain(VaultTimeoutAction.Lock);
});
it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => {
pinStateService.pinSet$.mockReturnValue(of(true));
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
expect(result).toContain(VaultTimeoutAction.Lock);
});
it("contains Lock when the user has biometrics configured", async () => {
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true));
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
expect(result).toContain(VaultTimeoutAction.Lock);
});
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
pinStateService.pinSet$.mockReturnValue(of(false));
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
expect(result).not.toContain(VaultTimeoutAction.Lock);
});
it("should throw error when activeAccount$ is null", async () => {
accountService.activeAccountSubject.next(null);
const result$ = vaultTimeoutSettingsService.availableVaultTimeoutActions$();
await expect(firstValueFrom(result$)).rejects.toThrow("Null or undefined account");
});
});
it("contains Lock when the user has a master password", async () => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
describe("with explicit userId parameter", () => {
it("should return Lock and LogOut when provided user has master password", async () => {
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
);
expect(result).toContain(VaultTimeoutAction.Lock);
});
expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith(
mockUserId,
);
expect(result).toContain(VaultTimeoutAction.Lock);
expect(result).toContain(VaultTimeoutAction.LogOut);
});
it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => {
pinStateService.isPinSet.mockResolvedValue(true);
it("should return Lock and LogOut when provided user has PIN configured", async () => {
pinStateService.pinSet$.mockReturnValue(of(true));
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
);
expect(result).toContain(VaultTimeoutAction.Lock);
});
expect(pinStateService.pinSet$).toHaveBeenCalledWith(mockUserId);
expect(result).toContain(VaultTimeoutAction.Lock);
expect(result).toContain(VaultTimeoutAction.LogOut);
});
it("contains Lock when the user has biometrics configured", async () => {
biometricStateService.biometricUnlockEnabled$ = of(true);
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
it("should return Lock and LogOut when provided user has biometrics configured", async () => {
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true));
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
);
expect(result).toContain(VaultTimeoutAction.Lock);
});
expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(mockUserId);
expect(result).toContain(VaultTimeoutAction.Lock);
expect(result).toContain(VaultTimeoutAction.LogOut);
});
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
pinStateService.isPinSet.mockResolvedValue(false);
biometricStateService.biometricUnlockEnabled$ = of(false);
it("should not return Lock when provided user has no unlock methods", async () => {
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
pinStateService.pinSet$.mockReturnValue(of(false));
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
);
expect(result).not.toContain(VaultTimeoutAction.Lock);
});
it("should return only LogOut when userId is not provided and there is no active account", async () => {
// Set up accountService to return null for activeAccount
accountService.activeAccount$ = of(null);
pinStateService.isPinSet.mockResolvedValue(false);
biometricStateService.biometricUnlockEnabled$ = of(false);
// Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
// Since there's no active account, userHasMasterPassword returns false,
// meaning no master password is available, so Lock should not be available
expect(result).toEqual([VaultTimeoutAction.LogOut]);
expect(result).not.toContain(VaultTimeoutAction.Lock);
expect(result).not.toContain(VaultTimeoutAction.Lock);
expect(result).toContain(VaultTimeoutAction.LogOut);
});
});
});
@@ -237,8 +287,8 @@ describe("VaultTimeoutSettingsService", () => {
`(
"returns $expected when policy is $policy, has PIN unlock method: $hasPinUnlock or Biometric unlock method: $hasBiometricUnlock, and user preference is $userPreference",
async ({ hasPinUnlock, hasBiometricUnlock, policy, userPreference, expected }) => {
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(hasBiometricUnlock);
pinStateService.isPinSet.mockResolvedValue(hasPinUnlock);
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(hasBiometricUnlock));
pinStateService.pinSet$.mockReturnValue(of(hasPinUnlock));
userDecryptionOptionsSubject.next(
new UserDecryptionOptions({ hasMasterPassword: false }),
@@ -260,6 +310,13 @@ describe("VaultTimeoutSettingsService", () => {
});
describe("getVaultTimeoutByUserId$", () => {
beforeEach(() => {
// Return the input value unchanged
sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation(
async (timeout) => timeout,
);
});
it("should throw an error if no user id is provided", async () => {
expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow(
"User id required. Cannot get vault timeout.",
@@ -277,6 +334,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
defaultVaultTimeout,
);
expect(result).toBe(defaultVaultTimeout);
});
@@ -299,8 +359,31 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
vaultTimeout,
);
expect(result).toBe(vaultTimeout);
});
it("promotes timeout when unavailable on client", async () => {
const determinedTimeout = VaultTimeoutNumberType.OnMinute;
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(of([]));
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
determinedTimeout,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: custom", () => {
@@ -327,6 +410,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
policyMinutes,
);
expect(result).toBe(policyMinutes);
},
);
@@ -345,6 +431,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
vaultTimeout,
);
expect(result).toBe(vaultTimeout);
},
);
@@ -365,8 +454,36 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(VaultTimeoutNumberType.Immediately);
});
it("promotes policy minutes when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutNumberType.EightHours,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
policyMinutes,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: immediately", () => {
@@ -383,7 +500,6 @@ describe("VaultTimeoutSettingsService", () => {
"when current timeout is %s, returns immediately or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutNumberType.Immediately;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
@@ -400,6 +516,26 @@ describe("VaultTimeoutSettingsService", () => {
expect(result).toBe(expectedTimeout);
},
);
it("promotes immediately when unavailable on client", async () => {
const promotedValue = VaultTimeoutNumberType.OnMinute;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: onSystemLock", () => {
@@ -413,7 +549,6 @@ describe("VaultTimeoutSettingsService", () => {
"when current timeout is %s, returns onLocked or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutStringType.OnLocked;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
@@ -446,9 +581,31 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
it("promotes onLocked when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnLocked,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: onAppRestart", () => {
@@ -468,7 +625,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnRestart,
);
expect(result).toBe(VaultTimeoutStringType.OnRestart);
});
@@ -488,32 +647,40 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
});
describe("policy type: never", () => {
it("when current timeout is never, returns never or promoted value", async () => {
const expectedTimeout = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
it("promotes onRestart when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutStringType.OnLocked,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
);
expect(result).toBe(expectedTimeout);
expect(result).toBe(promotedValue);
});
});
describe("policy type: never", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
@@ -532,9 +699,32 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
it("promotes timeout when unavailable on client", async () => {
const determinedTimeout = VaultTimeoutStringType.Never;
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
determinedTimeout,
);
expect(result).toBe(promotedValue);
});
});
});

View File

@@ -3,16 +3,15 @@
import {
catchError,
combineLatest,
defer,
distinctUntilChanged,
EMPTY,
firstValueFrom,
from,
map,
of,
Observable,
shareReplay,
switchMap,
tap,
concatMap,
} from "rxjs";
@@ -28,6 +27,7 @@ import { PolicyType } from "../../../admin-console/enums";
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { getUserId } from "../../../auth/services/account.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
@@ -101,8 +101,29 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
await this.keyService.refreshAdditionalKeys(userId);
}
availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> {
return defer(() => this.getAvailableVaultTimeoutActions(userId));
availableVaultTimeoutActions$(userId?: UserId): Observable<VaultTimeoutAction[]> {
const userId$ =
userId != null
? of(userId)
: // TODO remove with https://bitwarden.atlassian.net/browse/PM-10647
getUserId(this.accountService.activeAccount$);
return userId$.pipe(
switchMap((userId) =>
combineLatest([
this.userDecryptionOptionsService.hasMasterPasswordById$(userId),
this.biometricStateService.biometricUnlockEnabled$(userId),
this.pinStateService.pinSet$(userId),
]),
),
map(([haveMasterPassword, biometricUnlockEnabled, isPinSet]) => {
const canLock = haveMasterPassword || biometricUnlockEnabled || isPinSet;
if (canLock) {
return [VaultTimeoutAction.LogOut, VaultTimeoutAction.Lock];
}
return [VaultTimeoutAction.LogOut];
}),
);
}
async canLock(userId: UserId): Promise<boolean> {
@@ -112,12 +133,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return availableVaultTimeoutActions?.includes(VaultTimeoutAction.Lock) || false;
}
async isBiometricLockSet(userId?: string): Promise<boolean> {
const biometricUnlockPromise =
userId == null
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
: this.biometricStateService.getBiometricUnlockEnabled(userId as UserId);
return await biometricUnlockPromise;
async isBiometricLockSet(userId?: UserId): Promise<boolean> {
return await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId));
}
private async setVaultTimeout(userId: UserId, timeout: VaultTimeout): Promise<void> {
@@ -179,7 +196,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private async determineVaultTimeout(
currentVaultTimeout: VaultTimeout | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout | null> {
): Promise<VaultTimeout> {
const determinedTimeout = await this.determineVaultTimeoutInternal(
currentVaultTimeout,
maxSessionTimeoutPolicyData,
);
// Ensures the timeout is available on this client
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout);
}
private async determineVaultTimeoutInternal(
currentVaultTimeout: VaultTimeout | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout> {
// if current vault timeout is null, apply the client specific default
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
@@ -190,9 +220,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
switch (maxSessionTimeoutPolicyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
return VaultTimeoutNumberType.Immediately;
case "custom":
case null:
case undefined:
@@ -211,9 +239,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
return VaultTimeoutStringType.OnLocked;
}
break;
case "onAppRestart":
@@ -227,11 +253,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
break;
case "never":
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.Never,
);
}
// Policy doesn't override user preference for "never"
break;
}
return currentVaultTimeout;
@@ -257,45 +279,45 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return combineLatest([
this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId),
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
this.availableVaultTimeoutActions$(userId),
]).pipe(
switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => {
return from(
this.determineVaultTimeoutAction(
userId,
concatMap(
async ([
currentVaultTimeoutAction,
maxSessionTimeoutPolicyData,
availableVaultTimeoutActions,
]) => {
const vaultTimeoutAction = this.determineVaultTimeoutAction(
availableVaultTimeoutActions,
currentVaultTimeoutAction,
maxSessionTimeoutPolicyData,
),
).pipe(
tap((vaultTimeoutAction: VaultTimeoutAction) => {
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
// We want to avoid having a null timeout action always so we set it to the default if it is null
// and if the user becomes subject to a policy that requires a specific action, we set it to that
if (vaultTimeoutAction !== currentVaultTimeoutAction) {
return this.stateProvider.setUserState(
VAULT_TIMEOUT_ACTION,
vaultTimeoutAction,
userId,
);
}
}),
catchError((error: unknown) => {
// Protect outer observable from canceling on error by catching and returning EMPTY
this.logService.error(`Error getting vault timeout: ${error}`);
return EMPTY;
}),
);
);
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
// We want to avoid having a null timeout action always so we set it to the default if it is null
// and if the user becomes subject to a policy that requires a specific action, we set it to that
if (vaultTimeoutAction !== currentVaultTimeoutAction) {
await this.stateProvider.setUserState(VAULT_TIMEOUT_ACTION, vaultTimeoutAction, userId);
}
return vaultTimeoutAction;
},
),
catchError((error: unknown) => {
// Protect outer observable from canceling on error by catching and returning EMPTY
this.logService.error(`Error getting vault timeout: ${error}`);
return EMPTY;
}),
distinctUntilChanged(), // Avoid having the set side effect trigger a new emission of the same action
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
private async determineVaultTimeoutAction(
userId: string,
private determineVaultTimeoutAction(
availableVaultTimeoutActions: VaultTimeoutAction[],
currentVaultTimeoutAction: VaultTimeoutAction | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeoutAction> {
const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId);
): VaultTimeoutAction {
if (availableVaultTimeoutActions.length === 1) {
return availableVaultTimeoutActions[0];
}
@@ -334,38 +356,4 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
);
}
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
const availableActions = [VaultTimeoutAction.LogOut];
const canLock =
(await this.userHasMasterPassword(userId)) ||
(await this.pinStateService.isPinSet(userId as UserId)) ||
(await this.isBiometricLockSet(userId));
if (canLock) {
availableActions.push(VaultTimeoutAction.Lock);
}
return availableActions;
}
private async userHasMasterPassword(userId: string): Promise<boolean> {
let resolvedUserId: UserId;
if (userId) {
resolvedUserId = userId as UserId;
} else {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!activeAccount) {
return false; // No account, can't have master password
}
resolvedUserId = activeAccount.id;
}
return await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId),
);
}
}

View File

@@ -75,7 +75,7 @@ export class Fido2CredentialExport {
domain.userDisplayName =
req.userDisplayName != null ? new EncString(req.userDisplayName) : null;
domain.discoverable = req.discoverable != null ? new EncString(req.discoverable) : null;
domain.creationDate = req.creationDate;
domain.creationDate = req.creationDate != null ? new Date(req.creationDate) : null;
return domain;
}
@@ -111,10 +111,12 @@ export class Fido2CredentialExport {
this.rpId = safeGetString(o.rpId);
this.userHandle = safeGetString(o.userHandle);
this.userName = safeGetString(o.userName);
this.counter = safeGetString(String(o.counter));
this.counter = safeGetString(o instanceof Fido2CredentialView ? String(o.counter) : o.counter);
this.rpName = safeGetString(o.rpName);
this.userDisplayName = safeGetString(o.userDisplayName);
this.discoverable = safeGetString(String(o.discoverable));
this.discoverable = safeGetString(
o instanceof Fido2CredentialView ? String(o.discoverable) : o.discoverable,
);
this.creationDate = o.creationDate;
}
}

View File

@@ -39,7 +39,11 @@ export class LoginExport {
domain.username = req.username != null ? new EncString(req.username) : null;
domain.password = req.password != null ? new EncString(req.password) : null;
domain.totp = req.totp != null ? new EncString(req.totp) : null;
// Fido2credentials are currently not supported for exports.
if (req.fido2Credentials != null) {
domain.fido2Credentials = req.fido2Credentials.map((f2) =>
Fido2CredentialExport.toDomain(f2),
);
}
return domain;
}

View File

@@ -42,6 +42,7 @@ export class Utils {
static readonly validHosts: string[] = ["localhost"];
static readonly originalMinimumPasswordLength = 8;
static readonly minimumPasswordLength = 12;
static readonly maximumPasswordLength = 128;
static readonly DomainMatchBlacklist = new Map<string, Set<string>>([
["google.com", new Set(["script.google.com"])],
]);

View File

@@ -2,6 +2,18 @@ import { isValidRpId } from "./domain-utils";
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm.
describe("validateRpId", () => {
it("should not be valid when rpId is null", () => {
const origin = "example.com";
expect(isValidRpId(null, origin)).toBe(false);
});
it("should not be valid when origin is null", () => {
const rpId = "example.com";
expect(isValidRpId(rpId, null)).toBe(false);
});
it("should not be valid when rpId is more specific than origin", () => {
const rpId = "sub.login.bitwarden.com";
const origin = "https://login.bitwarden.com:1337";
@@ -25,7 +37,7 @@ describe("validateRpId", () => {
it("should not be valid when rpId and origin are both different TLD", () => {
const rpId = "bitwarden";
const origin = "localhost";
const origin = "https://localhost";
expect(isValidRpId(rpId, origin)).toBe(false);
});
@@ -34,14 +46,14 @@ describe("validateRpId", () => {
// adding support for ip-addresses and other TLDs
it("should not be valid when rpId and origin are both the same TLD", () => {
const rpId = "bitwarden";
const origin = "bitwarden";
const origin = "https://bitwarden";
expect(isValidRpId(rpId, origin)).toBe(false);
});
it("should not be valid when rpId and origin are ip-addresses", () => {
const rpId = "127.0.0.1";
const origin = "127.0.0.1";
const origin = "https://127.0.0.1";
expect(isValidRpId(rpId, origin)).toBe(false);
});
@@ -80,4 +92,11 @@ describe("validateRpId", () => {
expect(isValidRpId(rpId, origin)).toBe(true);
});
it("should not be valid for a partial match of a subdomain", () => {
const rpId = "accounts.example.com";
const origin = "https://evilaccounts.example.com";
expect(isValidRpId(rpId, origin)).toBe(false);
});
});

View File

@@ -1,17 +1,78 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { parse } from "tldts";
/**
* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications.
*
* The validation enforces the following rules:
* - The origin must use the HTTPS scheme
* - Both rpId and origin must be valid domain names (not IP addresses)
* - Both must have the same registrable domain (e.g., example.com)
* - The origin must either exactly match the rpId or be a subdomain of it
* - Single-label domains are rejected unless they are 'localhost'
* - Localhost is always valid when both rpId and origin are localhost
*
* @param rpId - The Relying Party identifier to validate
* @param origin - The origin URL to validate against (must start with https://)
* @returns `true` if the rpId is valid for the given origin, `false` otherwise
*
*/
export function isValidRpId(rpId: string, origin: string) {
if (!rpId || !origin) {
return false;
}
const parsedOrigin = parse(origin, { allowPrivateDomains: true });
const parsedRpId = parse(rpId, { allowPrivateDomains: true });
return (
(parsedOrigin.domain == null &&
parsedOrigin.hostname == parsedRpId.hostname &&
parsedOrigin.hostname == "localhost") ||
(parsedOrigin.domain != null &&
parsedOrigin.domain == parsedRpId.domain &&
parsedOrigin.subdomain.endsWith(parsedRpId.subdomain))
);
if (!parsedRpId || !parsedOrigin) {
return false;
}
// Special case: localhost is always valid when both match
if (parsedRpId.hostname === "localhost" && parsedOrigin.hostname === "localhost") {
return true;
}
// The origin's scheme must be https.
if (!origin.startsWith("https://")) {
return false;
}
// Reject IP addresses (both must be domain names)
if (parsedRpId.isIp || parsedOrigin.isIp) {
return false;
}
// Reject single-label domains (TLDs) unless it's localhost
// This ensures we have proper domains like "example.com" not just "example"
if (rpId !== "localhost" && !rpId.includes(".")) {
return false;
}
if (
parsedOrigin.hostname != null &&
parsedOrigin.hostname !== "localhost" &&
!parsedOrigin.hostname.includes(".")
) {
return false;
}
// The registrable domains must match
// This ensures a.example.com and b.example.com share base domain
if (parsedRpId.domain !== parsedOrigin.domain) {
return false;
}
// Check exact match
if (parsedOrigin.hostname === rpId) {
return true;
}
// Check if origin is a subdomain of rpId
// This prevents "evilaccounts.example.com" from matching "accounts.example.com"
if (parsedOrigin.hostname != null && parsedOrigin.hostname.endsWith("." + rpId)) {
return true;
}
return false;
}

View File

@@ -254,17 +254,17 @@ describe("FidoAuthenticatorService", () => {
}
it("should save credential to vault if request confirmed by user", async () => {
const encryptedCipher = Symbol();
userInterfaceSession.confirmNewCredential.mockResolvedValue({
cipherId: existingCipher.id,
userVerified: false,
});
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
await authenticator.makeCredential(params, windowReference);
const saved = cipherService.encrypt.mock.lastCall?.[0];
expect(saved).toEqual(
const savedCipher = cipherService.updateWithServer.mock.lastCall?.[0];
const actualUserId = cipherService.updateWithServer.mock.lastCall?.[1];
expect(actualUserId).toEqual(userId);
expect(savedCipher).toEqual(
expect.objectContaining({
type: CipherType.Login,
name: existingCipher.name,
@@ -288,7 +288,6 @@ describe("FidoAuthenticatorService", () => {
}),
}),
);
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher);
});
/** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */
@@ -361,17 +360,14 @@ describe("FidoAuthenticatorService", () => {
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
cipherService.decrypt.mockResolvedValue(cipher);
cipherService.encrypt.mockImplementation(async (cipher) => {
cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
return { cipher: {} as any as Cipher, encryptedFor: userId };
});
cipherService.createWithServer.mockImplementation(async ({ cipher }) => {
cipher.id = cipherId;
cipherService.createWithServer.mockImplementation(async (cipherView, _userId) => {
cipherView.id = cipherId;
return cipher;
});
cipherService.updateWithServer.mockImplementation(async ({ cipher }) => {
cipher.id = cipherId;
return cipher;
cipherService.updateWithServer.mockImplementation(async (cipherView, _userId) => {
cipherView.id = cipherId;
cipherView.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
return cipherView;
});
});
@@ -701,14 +697,11 @@ describe("FidoAuthenticatorService", () => {
/** Spec: Increment the credential associated signature counter */
it("should increment counter and save to server when stored counter is larger than zero", async () => {
const encrypted = Symbol();
cipherService.encrypt.mockResolvedValue(encrypted as any);
ciphers[0].login.fido2Credentials[0].counter = 9000;
await authenticator.getAssertion(params, windowReference);
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted);
expect(cipherService.encrypt).toHaveBeenCalledWith(
expect(cipherService.updateWithServer).toHaveBeenCalledWith(
expect.objectContaining({
id: ciphers[0].id,
login: expect.objectContaining({
@@ -725,8 +718,6 @@ describe("FidoAuthenticatorService", () => {
/** Spec: Authenticators that do not implement a signature counter leave the signCount in the authenticator data constant at zero. */
it("should not save to server when stored counter is zero", async () => {
const encrypted = Symbol();
cipherService.encrypt.mockResolvedValue(encrypted as any);
ciphers[0].login.fido2Credentials[0].counter = 0;
await authenticator.getAssertion(params, windowReference);

View File

@@ -187,8 +187,7 @@ export class Fido2AuthenticatorService<
if (Utils.isNullOrEmpty(cipher.login.username)) {
cipher.login.username = fido2Credential.userName;
}
const reencrypted = await this.cipherService.encrypt(cipher, activeUserId);
await this.cipherService.updateWithServer(reencrypted);
await this.cipherService.updateWithServer(cipher, activeUserId);
await this.cipherService.clearCache(activeUserId);
credentialId = fido2Credential.credentialId;
} catch (error) {
@@ -328,8 +327,7 @@ export class Fido2AuthenticatorService<
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const encrypted = await this.cipherService.encrypt(selectedCipher, activeUserId);
await this.cipherService.updateWithServer(encrypted);
await this.cipherService.updateWithServer(selectedCipher, activeUserId);
await this.cipherService.clearCache(activeUserId);
}

View File

@@ -146,17 +146,44 @@ describe("DefaultSdkService", () => {
});
it("destroys the internal SDK client when all subscriptions are closed", async () => {
jest.useFakeTimers();
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
await new Promise(process.nextTick);
await jest.advanceTimersByTimeAsync(0);
subscription_1.unsubscribe();
subscription_2.unsubscribe();
await new Promise(process.nextTick);
await jest.advanceTimersByTimeAsync(0);
expect(mockClient.free).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
expect(mockClient.free).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
it("does not destroy the internal SDK client if resubscribed within 1 second", async () => {
jest.useFakeTimers();
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
await jest.advanceTimersByTimeAsync(0);
subscription_1.unsubscribe();
await jest.advanceTimersByTimeAsync(500);
expect(mockClient.free).not.toHaveBeenCalled();
// Resubscribe before the 1 second delay
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
await jest.advanceTimersByTimeAsync(1000);
// Client should not be freed since we resubscribed
expect(mockClient.free).not.toHaveBeenCalled();
expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1);
subscription_2.unsubscribe();
jest.useRealTimers();
});
it("destroys the internal SDK client when the userKey is unset (i.e. lock or logout)", async () => {
@@ -221,6 +248,7 @@ describe("DefaultSdkService", () => {
});
it("destroys the internal client when an override is set", async () => {
jest.useFakeTimers();
const mockInternalClient = createMockClient();
const mockOverrideClient = createMockClient();
sdkClientFactory.createSdkClient.mockResolvedValue(mockInternalClient);
@@ -230,7 +258,10 @@ describe("DefaultSdkService", () => {
service.setClient(userId, mockOverrideClient);
await userClientTracker.pauseUntilReceived(2);
expect(mockInternalClient.free).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
expect(mockInternalClient.free).toHaveBeenCalled();
jest.useRealTimers();
});
it("destroys the override client when explicitly setting the client to undefined", async () => {

View File

@@ -2,7 +2,10 @@ import {
combineLatest,
concatMap,
Observable,
share,
shareReplay,
ReplaySubject,
timer,
map,
distinctUntilChanged,
tap,
@@ -80,7 +83,7 @@ export class DefaultSdkService implements SdkService {
client$ = this.environmentService.environment$.pipe(
concatMap(async (env) => {
await SdkLoadService.Ready;
const settings = this.toSettings(env);
const settings = await this.toSettings(env);
const client = await this.sdkClientFactory.createSdkClient(
new JsTokenProvider(this.apiService),
settings,
@@ -194,7 +197,7 @@ export class DefaultSdkService implements SdkService {
return undefined;
}
const settings = this.toSettings(env);
const settings = await this.toSettings(env);
const client = await this.sdkClientFactory.createSdkClient(
new JsTokenProvider(this.apiService, userId),
settings,
@@ -228,7 +231,10 @@ export class DefaultSdkService implements SdkService {
});
}),
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
shareReplay({ refCount: true, bufferSize: 1 }),
share({
connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(1000),
}),
);
this.sdkClientCache.set(userId, client$);
@@ -287,11 +293,12 @@ export class DefaultSdkService implements SdkService {
client.platform().load_flags(featureFlagMap);
}
private toSettings(env: Environment): ClientSettings {
private async toSettings(env: Environment): Promise<ClientSettings> {
return {
apiUrl: env.getApiUrl(),
identityUrl: env.getIdentityUrl(),
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(),
userAgent: this.userAgent ?? navigator.userAgent,
};
}

View File

@@ -62,7 +62,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
client$ = this.environmentService.environment$.pipe(
concatMap(async (env) => {
await SdkLoadService.Ready;
const settings = this.toSettings(env);
const settings = await this.toSettings(env);
const client = await this.sdkClientFactory.createSdkClient(
new JsTokenProvider(this.apiService),
settings,
@@ -137,7 +137,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
return undefined;
}
const settings = this.toSettings(env);
const settings = await this.toSettings(env);
const client = await this.sdkClientFactory.createSdkClient(
new JsTokenProvider(this.apiService, userId),
settings,
@@ -185,12 +185,13 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
client.platform().load_flags(featureFlagMap);
}
private toSettings(env: Environment): ClientSettings {
private async toSettings(env: Environment): Promise<ClientSettings> {
return {
apiUrl: env.getApiUrl(),
identityUrl: env.getIdentityUrl(),
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
userAgent: this.userAgent ?? navigator.userAgent,
bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(),
};
}
}

View File

@@ -9,7 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import {
LogoutReason,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
InternalUserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
// 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
@@ -68,7 +68,7 @@ describe("DefaultSyncService", () => {
let folderApiService: MockProxy<FolderApiServiceAbstraction>;
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
let sendApiService: MockProxy<SendApiService>;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let avatarService: MockProxy<AvatarService>;
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: UserId]>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;

View File

@@ -6,8 +6,8 @@ import { firstValueFrom, map } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionDetailsResponse,
CollectionData,
CollectionDetailsResponse,
} from "@bitwarden/common/admin-console/models/collections";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
@@ -16,9 +16,13 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security-
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { EncString as SdkEncString } from "@bitwarden/sdk-internal";
// FIXME: remove `src` and fix import
// 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 { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions";
import {
InternalUserDecryptionOptionsServiceAbstraction,
UserDecryptionOptions,
WebAuthnPrfUserDecryptionOption,
} from "../../../../auth/src/common";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "../../../../auth/src/common/types";
@@ -94,7 +98,7 @@ export class DefaultSyncService extends CoreSyncService {
folderApiService: FolderApiServiceAbstraction,
private organizationService: InternalOrganizationServiceAbstraction,
sendApiService: SendApiService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private avatarService: AvatarService,
private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
@@ -437,5 +441,43 @@ export class DefaultSyncService extends CoreSyncService {
);
await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf);
}
// Update WebAuthn PRF options if present
if (userDecryption.webAuthnPrfOptions != null && userDecryption.webAuthnPrfOptions.length > 0) {
try {
// Only update if this is the active user, since setUserDecryptionOptions()
// operates on the active user's state
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount?.id !== userId) {
return;
}
// Get current options without blocking if they don't exist yet
const currentUserDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
).catch((): UserDecryptionOptions | null => {
return null;
});
if (currentUserDecryptionOptions != null) {
// Update the PRF options while preserving other decryption options
const updatedOptions = Object.assign(
new UserDecryptionOptions(),
currentUserDecryptionOptions,
);
updatedOptions.webAuthnPrfOptions = userDecryption.webAuthnPrfOptions
.map((option) => WebAuthnPrfUserDecryptionOption.fromResponse(option))
.filter((option) => option !== undefined);
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
activeAccount.id,
updatedOptions,
);
}
} catch (error) {
this.logService.error("[Sync] Failed to update WebAuthn PRF options:", error);
}
}
}
}

View File

@@ -11,7 +11,6 @@ export class SendData {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: string;
notes: string;
file: SendFileData;
@@ -26,6 +25,7 @@ export class SendData {
emails: string;
disabled: boolean;
hideEmail: boolean;
authType: AuthType;
constructor(response?: SendResponse) {
if (response == null) {
@@ -48,6 +48,7 @@ export class SendData {
this.emails = response.emails;
this.disabled = response.disable;
this.hideEmail = response.hideEmail;
this.authType = response.authType;
switch (this.type) {
case SendType.Text:

View File

@@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { emptyGuid, UserId } from "@bitwarden/common/types/guid";
// 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
@@ -15,7 +16,6 @@ import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { SendData } from "../data/send.data";
import { Send } from "./send";
import { SendText } from "./send-text";
describe("Send", () => {
@@ -26,7 +26,6 @@ describe("Send", () => {
id: "id",
accessId: "accessId",
type: SendType.Text,
authType: AuthType.None,
name: "encName",
notes: "encNotes",
text: {
@@ -41,9 +40,10 @@ describe("Send", () => {
expirationDate: "2022-01-31T12:00:00.000Z",
deletionDate: "2022-01-31T12:00:00.000Z",
password: "password",
emails: null!,
emails: "",
disabled: false,
hideEmail: true,
authType: AuthType.None,
};
mockContainerService();
@@ -69,6 +69,7 @@ describe("Send", () => {
expirationDate: null,
deletionDate: null,
password: undefined,
emails: undefined,
disabled: undefined,
hideEmail: undefined,
});
@@ -81,7 +82,6 @@ describe("Send", () => {
id: "id",
accessId: "accessId",
type: SendType.Text,
authType: AuthType.None,
name: { encryptedString: "encName", encryptionType: 0 },
notes: { encryptedString: "encNotes", encryptionType: 0 },
text: {
@@ -95,9 +95,10 @@ describe("Send", () => {
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
password: "password",
emails: null!,
emails: "",
disabled: false,
hideEmail: true,
authType: AuthType.None,
});
});
@@ -121,14 +122,22 @@ describe("Send", () => {
send.expirationDate = new Date("2022-01-31T12:00:00.000Z");
send.deletionDate = new Date("2022-01-31T12:00:00.000Z");
send.password = "password";
send.emails = null;
send.disabled = false;
send.hideEmail = true;
send.authType = AuthType.None;
const encryptService = mock<EncryptService>();
const keyService = mock<KeyService>();
encryptService.decryptBytes
.calledWith(send.key, userKey)
.mockResolvedValue(makeStaticByteArray(32));
encryptService.decryptString
.calledWith(send.name, "cryptoKey" as any)
.mockResolvedValue("name");
encryptService.decryptString
.calledWith(send.notes, "cryptoKey" as any)
.mockResolvedValue("notes");
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey));
@@ -137,12 +146,6 @@ describe("Send", () => {
const view = await send.decrypt(userId);
expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey");
expect(send.name.decrypt).toHaveBeenNthCalledWith(
1,
null,
"cryptoKey",
"Property: name; ObjectContext: No Domain Context",
);
expect(view).toMatchObject({
id: "id",
@@ -150,7 +153,6 @@ describe("Send", () => {
name: "name",
notes: "notes",
type: 0,
authType: 2,
key: expect.anything(),
cryptoKey: "cryptoKey",
file: expect.anything(),
@@ -161,8 +163,175 @@ describe("Send", () => {
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
password: "password",
emails: [],
disabled: false,
hideEmail: true,
authType: AuthType.None,
});
});
describe("Email parsing", () => {
let encryptService: jest.Mocked<EncryptService>;
let keyService: jest.Mocked<KeyService>;
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const userId = emptyGuid as UserId;
beforeEach(() => {
encryptService = mock<EncryptService>();
keyService = mock<KeyService>();
encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32));
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
keyService.userKey$.mockReturnValue(of(userKey));
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
});
it("should parse single email", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = "test@example.com";
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.emails).toEqual(["test@example.com"]);
});
it("should parse multiple emails", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = "test@example.com,user@test.com,admin@domain.com";
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]);
});
it("should trim whitespace from emails", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = " test@example.com , user@test.com ";
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.emails).toEqual(["test@example.com", "user@test.com"]);
});
it("should return empty array when emails is null", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = null;
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.emails).toEqual([]);
expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey");
});
it("should return empty array when emails is empty string", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = "";
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.emails).toEqual([]);
});
});
describe("Null handling for name and notes decryption", () => {
let encryptService: jest.Mocked<EncryptService>;
let keyService: jest.Mocked<KeyService>;
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const userId = emptyGuid as UserId;
beforeEach(() => {
encryptService = mock<EncryptService>();
keyService = mock<KeyService>();
encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32));
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
keyService.userKey$.mockReturnValue(of(userKey));
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
});
it("should return null for name when name is null", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = null;
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = null;
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.name).toBeNull();
expect(encryptService.decryptString).not.toHaveBeenCalledWith(null, expect.anything());
});
it("should return null for notes when notes is null", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = null;
send.key = mockEnc("key");
send.emails = null;
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
const view = await send.decrypt(userId);
expect(view.notes).toBeNull();
});
it("should decrypt non-null name and notes", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("Test Name");
send.notes = mockEnc("Test Notes");
send.key = mockEnc("key");
send.emails = null;
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.name) {
return Promise.resolve("Test Name");
}
if (encString === send.notes) {
return Promise.resolve("Test Notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.name).toBe("Test Name");
expect(view.notes).toBe("Test Notes");
});
});
});

View File

@@ -20,7 +20,6 @@ export class Send extends Domain {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: EncString;
notes: EncString;
file: SendFile;
@@ -35,6 +34,7 @@ export class Send extends Domain {
emails: string;
disabled: boolean;
hideEmail: boolean;
authType: AuthType;
constructor(obj?: SendData) {
super();
@@ -60,12 +60,13 @@ export class Send extends Domain {
this.maxAccessCount = obj.maxAccessCount;
this.accessCount = obj.accessCount;
this.password = obj.password;
this.emails = obj.emails;
this.disabled = obj.disabled;
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null;
this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null;
this.hideEmail = obj.hideEmail;
this.authType = obj.authType;
this.emails = obj.emails;
switch (this.type) {
case SendType.Text:
@@ -91,8 +92,16 @@ export class Send extends Domain {
// model.key is a seed used to derive a key, not a SymmetricCryptoKey
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
model.cryptoKey = await keyService.makeSendKey(model.key);
model.name =
this.name != null ? await encryptService.decryptString(this.name, model.cryptoKey) : null;
model.notes =
this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null;
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], model.cryptoKey);
if (this.emails != null) {
model.emails = this.emails ? this.emails.split(",").map((e) => e.trim()) : [];
} else {
model.emails = [];
}
switch (this.type) {
case SendType.File:
@@ -121,6 +130,7 @@ export class Send extends Domain {
key: EncString.fromJSON(obj.key),
name: EncString.fromJSON(obj.name),
notes: EncString.fromJSON(obj.notes),
emails: obj.emails,
text: SendText.fromJSON(obj.text),
file: SendFile.fromJSON(obj.file),
revisionDate,

View File

@@ -0,0 +1,82 @@
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { EncString } from "../../../../key-management/crypto/models/enc-string";
import { SendType } from "../../types/send-type";
import { SendText } from "../domain/send-text";
import { SendRequest } from "./send.request";
describe("SendRequest", () => {
describe("constructor", () => {
it("should set emails to null when Send.emails is null", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = null;
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.emails).toBeNull();
});
it("should handle name being null", () => {
const send = new Send();
send.type = SendType.Text;
send.name = null;
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = null;
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.name).toBeNull();
});
it("should handle notes being null", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.notes = null;
send.key = new EncString("encryptedKey");
send.emails = null;
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.notes).toBeNull();
});
it("should include fileLength when provided for text send", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = null;
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send, 1024);
expect(request.fileLength).toBe(1024);
});
});
});

View File

@@ -1,8 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { BaseResponse } from "../../../../models/response/base.response";
import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { SendFileApi } from "../api/send-file.api";
import { SendTextApi } from "../api/send-text.api";
@@ -10,7 +11,6 @@ export class SendResponse extends BaseResponse {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: string;
notes: string;
file: SendFileApi;
@@ -25,6 +25,7 @@ export class SendResponse extends BaseResponse {
emails: string;
disable: boolean;
hideEmail: boolean;
authType: AuthType;
constructor(response: any) {
super(response);
@@ -44,6 +45,7 @@ export class SendResponse extends BaseResponse {
this.emails = this.getResponseProperty("Emails");
this.disable = this.getResponseProperty("Disabled") || false;
this.hideEmail = this.getResponseProperty("HideEmail") || false;
this.authType = this.getResponseProperty("AuthType");
const text = this.getResponseProperty("Text");
if (text != null) {

View File

@@ -19,7 +19,6 @@ export class SendView implements View {
key: Uint8Array;
cryptoKey: SymmetricCryptoKey;
type: SendType = null;
authType: AuthType = null;
text = new SendTextView();
file = new SendFileView();
maxAccessCount?: number = null;
@@ -31,6 +30,7 @@ export class SendView implements View {
emails: string[] = [];
disabled = false;
hideEmail = false;
authType: AuthType = null;
constructor(s?: Send) {
if (!s) {
@@ -49,6 +49,7 @@ export class SendView implements View {
this.disabled = s.disabled;
this.password = s.password;
this.hideEmail = s.hideEmail;
this.authType = s.authType;
}
get urlB64Key(): string {

View File

@@ -1,3 +1,5 @@
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
import { ListResponse } from "../../../models/response/list.response";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { Send } from "../models/domain/send";
@@ -16,6 +18,10 @@ export abstract class SendApiService {
request: SendAccessRequest,
apiUrl?: string,
): Promise<SendAccessResponse>;
abstract postSendAccessV2(
accessToken: SendAccessToken,
apiUrl?: string,
): Promise<SendAccessResponse>;
abstract getSends(): Promise<ListResponse<SendResponse>>;
abstract postSend(request: SendRequest): Promise<SendResponse>;
abstract postFileTypeSend(request: SendRequest): Promise<SendFileUploadDataResponse>;
@@ -28,6 +34,11 @@ export abstract class SendApiService {
request: SendAccessRequest,
apiUrl?: string,
): Promise<SendFileDownloadDataResponse>;
abstract getSendFileDownloadDataV2(
send: SendAccessView,
accessToken: SendAccessToken,
apiUrl?: string,
): Promise<SendFileDownloadDataResponse>;
abstract renewSendFileUploadUrl(
sendId: string,
fileId: string,

View File

@@ -1,3 +1,5 @@
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
import { ApiService } from "../../../abstractions/api.service";
import { ErrorResponse } from "../../../models/response/error.response";
import { ListResponse } from "../../../models/response/list.response";
@@ -52,6 +54,25 @@ export class SendApiService implements SendApiServiceAbstraction {
return new SendAccessResponse(r);
}
async postSendAccessV2(
accessToken: SendAccessToken,
apiUrl?: string,
): Promise<SendAccessResponse> {
const setAuthTokenHeader = (headers: Headers) => {
headers.set("Authorization", "Bearer " + accessToken.token);
};
const r = await this.apiService.send(
"POST",
"/sends/access",
null,
false,
true,
apiUrl,
setAuthTokenHeader,
);
return new SendAccessResponse(r);
}
async getSendFileDownloadData(
send: SendAccessView,
request: SendAccessRequest,
@@ -72,6 +93,26 @@ export class SendApiService implements SendApiServiceAbstraction {
return new SendFileDownloadDataResponse(r);
}
async getSendFileDownloadDataV2(
send: SendAccessView,
accessToken: SendAccessToken,
apiUrl?: string,
): Promise<SendFileDownloadDataResponse> {
const setAuthTokenHeader = (headers: Headers) => {
headers.set("Authorization", "Bearer " + accessToken.token);
};
const r = await this.apiService.send(
"POST",
"/sends/access/file/" + send.file.id,
null,
true,
true,
apiUrl,
setAuthTokenHeader,
);
return new SendFileDownloadDataResponse(r);
}
async getSends(): Promise<ListResponse<SendResponse>> {
const r = await this.apiService.send("GET", "/sends", null, true, true);
return new ListResponse(r, SendResponse);
@@ -148,6 +189,7 @@ export class SendApiService implements SendApiServiceAbstraction {
private async upload(sendData: [Send, EncArrayBuffer]): Promise<SendResponse> {
const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength);
let response: SendResponse;
if (sendData[0].id == null) {
if (sendData[0].type === SendType.Text) {

View File

@@ -16,6 +16,7 @@ import {
import { KeyGenerationService } from "../../../key-management/crypto";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { EnvironmentService } from "../../../platform/abstractions/environment.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { Utils } from "../../../platform/misc/utils";
@@ -29,6 +30,7 @@ import { SendTextApi } from "../models/api/send-text.api";
import { SendFileData } from "../models/data/send-file.data";
import { SendTextData } from "../models/data/send-text.data";
import { SendData } from "../models/data/send.data";
import { SendTextView } from "../models/view/send-text.view";
import { SendView } from "../models/view/send.view";
import { SendType } from "../types/send-type";
@@ -48,7 +50,7 @@ describe("SendService", () => {
const keyGenerationService = mock<KeyGenerationService>();
const encryptService = mock<EncryptService>();
const environmentService = mock<EnvironmentService>();
const configService = mock<ConfigService>();
let sendStateProvider: SendStateProvider;
let sendService: SendService;
@@ -94,6 +96,7 @@ describe("SendService", () => {
keyGenerationService,
sendStateProvider,
encryptService,
configService,
);
});
@@ -573,4 +576,188 @@ describe("SendService", () => {
expect(sendsAfterDelete.length).toBe(0);
});
});
describe("encrypt", () => {
let sendView: SendView;
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const mockCryptoKey = new SymmetricCryptoKey(new Uint8Array(32));
beforeEach(() => {
sendView = new SendView();
sendView.id = "sendId";
sendView.type = SendType.Text;
sendView.name = "Test Send";
sendView.notes = "Test Notes";
const sendTextView = new SendTextView();
sendTextView.text = "test text";
sendTextView.hidden = false;
sendView.text = sendTextView;
sendView.key = new Uint8Array(16);
sendView.cryptoKey = mockCryptoKey;
sendView.maxAccessCount = 5;
sendView.disabled = false;
sendView.hideEmail = false;
sendView.deletionDate = new Date("2024-12-31");
sendView.expirationDate = null;
keyService.userKey$.mockReturnValue(of(userKey));
keyService.makeSendKey.mockResolvedValue(mockCryptoKey);
encryptService.encryptBytes.mockResolvedValue({ encryptedString: "encryptedKey" } as any);
encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any);
});
describe("when SendEmailOTP feature flag is ON", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
});
describe("email processing", () => {
it("should create a comma separated string when an email list is provided", async () => {
sendView.emails = ["test@example.com", "user@test.com"];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toEqual("test@example.com,user@test.com");
expect(send.password).toBeNull();
});
it("should set emails to null when email list is empty", async () => {
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
});
it("should set emails to null when email list is null", async () => {
sendView.emails = null;
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
});
it("should set emails to null when email list is undefined", async () => {
sendView.emails = undefined;
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
});
it("should process multiple emails and return comma-separated string", async () => {
sendView.emails = ["test@example.com", "user@test.com"];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBe("test@example.com,user@test.com");
});
it("should trim and lowercase emails", async () => {
sendView.emails = [" Test@Example.COM ", "USER@test.com"];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBe("test@example.com,user@test.com");
});
it("should handle single email correctly", async () => {
sendView.emails = ["single@test.com"];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBe("single@test.com");
});
});
describe("emails and password mutual exclusivity", () => {
it("should set password to null when emails are provided", async () => {
sendView.emails = ["test@example.com"];
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeDefined();
expect(send.password).toBeNull();
});
it("should set password when no emails are provided", async () => {
sendView.emails = [];
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
keyB64: "hashedPassword",
} as any);
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeNull();
expect(send.password).toBe("hashedPassword");
});
});
});
describe("when SendEmailOTP feature flag is OFF", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
});
it("should NOT encrypt emails even when provided", async () => {
sendView.emails = ["test@example.com"];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
});
it("should use password when provided and flag is OFF", async () => {
sendView.emails = [];
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
keyB64: "hashedPassword",
} as any);
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeNull();
expect(send.password).toBe("hashedPassword");
});
it("should ignore emails and use password when both provided", async () => {
sendView.emails = ["test@example.com"];
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
keyB64: "hashedPassword",
} as any);
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeNull();
expect(send.password).toBe("hashedPassword");
});
it("should set emails and password to null when neither provided", async () => {
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.password).toBeUndefined();
});
});
describe("null handling for name and notes", () => {
it("should handle null name correctly", async () => {
sendView.name = null;
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.name).toBeNull();
});
it("should handle null notes correctly", async () => {
sendView.notes = null;
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.notes).toBeNull();
});
it("should encrypt non-null name and notes", async () => {
sendView.name = "Test Name";
sendView.notes = "Test Notes";
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(encryptService.encryptString).toHaveBeenCalledWith("Test Name", mockCryptoKey);
expect(encryptService.encryptString).toHaveBeenCalledWith("Test Notes", mockCryptoKey);
expect(send.name).toEqual({ encryptedString: "encrypted" });
expect(send.notes).toEqual({ encryptedString: "encrypted" });
});
});
});
});

View File

@@ -7,9 +7,11 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
// eslint-disable-next-line no-restricted-imports
import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { KeyGenerationService } from "../../../key-management/crypto";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { Utils } from "../../../platform/misc/utils";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
@@ -51,6 +53,7 @@ export class SendService implements InternalSendServiceAbstraction {
private keyGenerationService: KeyGenerationService,
private stateProvider: SendStateProvider,
private encryptService: EncryptService,
private configService: ConfigService,
) {}
async encrypt(
@@ -80,19 +83,30 @@ export class SendService implements InternalSendServiceAbstraction {
model.cryptoKey = key.derivedKey;
}
// Check feature flag for email OTP authentication
const sendEmailOTPEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
const hasEmails = (model.emails?.length ?? 0) > 0;
if (hasEmails) {
send.emails = model.emails.join(",");
if (sendEmailOTPEnabled && hasEmails) {
send.emails = model.emails
.map((e) => e.trim())
.join(",")
.toLocaleLowerCase();
send.password = null;
} else if (password != null) {
// Note: Despite being called key, the passwordKey is not used for encryption.
// It is used as a static proof that the client knows the password, and has the encryption key.
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
password,
model.key,
new PBKDF2KdfConfig(SEND_KDF_ITERATIONS),
);
send.password = passwordKey.keyB64;
} else {
send.emails = null;
if (password != null) {
// Note: Despite being called key, the passwordKey is not used for encryption.
// It is used as a static proof that the client knows the password, and has the encryption key.
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
password,
model.key,
new PBKDF2KdfConfig(SEND_KDF_ITERATIONS),
);
send.password = passwordKey.keyB64;
}
}
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
if (userKey == null) {
@@ -100,10 +114,14 @@ export class SendService implements InternalSendServiceAbstraction {
}
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
send.key = await this.encryptService.encryptBytes(model.key, userKey);
// FIXME: model.name can be null. encryptString should not be called with null values.
send.name = await this.encryptService.encryptString(model.name, model.cryptoKey);
// FIXME: model.notes can be null. encryptString should not be called with null values.
send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey);
send.name =
model.name != null
? await this.encryptService.encryptString(model.name, model.cryptoKey)
: null;
send.notes =
model.notes != null
? await this.encryptService.encryptString(model.notes, model.cryptoKey)
: null;
if (send.type === SendType.Text) {
send.text = new SendText();
// FIXME: model.text.text can be null. encryptString should not be called with null values.
@@ -127,6 +145,8 @@ export class SendService implements InternalSendServiceAbstraction {
}
}
send.authType = model.authType;
return [send, fileData];
}

View File

@@ -20,6 +20,7 @@ export function testSendViewData(id: string, name: string) {
data.deletionDate = null;
data.notes = "Notes!!";
data.key = null;
data.emails = [];
return data;
}
@@ -39,6 +40,7 @@ export function createSendData(value: Partial<SendData> = {}) {
expirationDate: "2024-09-04",
deletionDate: "2024-09-04",
password: "password",
emails: "",
disabled: false,
hideEmail: false,
};
@@ -62,6 +64,7 @@ export function testSendData(id: string, name: string) {
data.deletionDate = null;
data.notes = "Notes!!";
data.key = null;
data.emails = "";
return data;
}
@@ -77,5 +80,6 @@ export function testSend(id: string, name: string) {
data.deletionDate = null;
data.notes = new EncString("Notes!!");
data.key = null;
data.emails = "";
return data;
}

View File

@@ -0,0 +1,109 @@
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
/**
* Service responsible for cipher operations using the SDK.
*/
export abstract class CipherSdkService {
/**
* Creates a new cipher on the server using the SDK.
*
* @param cipherView The cipher view to create
* @param userId The user ID to use for SDK client
* @param orgAdmin Whether this is an organization admin operation
* @returns A promise that resolves to the created cipher view
*/
abstract createWithServer(
cipherView: CipherView,
userId: UserId,
orgAdmin?: boolean,
): Promise<CipherView | undefined>;
/**
* Updates a cipher on the server using the SDK.
*
* @param cipher The cipher view to update
* @param userId The user ID to use for SDK client
* @param originalCipherView The original cipher view before changes (optional, used for admin operations)
* @param orgAdmin Whether this is an organization admin operation
* @returns A promise that resolves to the updated cipher view
*/
abstract updateWithServer(
cipher: CipherView,
userId: UserId,
originalCipherView?: CipherView,
orgAdmin?: boolean,
): Promise<CipherView | undefined>;
/**
* Deletes a cipher on the server using the SDK.
*
* @param id The cipher ID to delete
* @param userId The user ID to use for SDK client
* @param asAdmin Whether this is an organization admin operation
* @returns A promise that resolves when the cipher is deleted
*/
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
/**
* Deletes multiple ciphers on the server using the SDK.
*
* @param ids The cipher IDs to delete
* @param userId The user ID to use for SDK client
* @param asAdmin Whether this is an organization admin operation
* @param orgId The organization ID (required when asAdmin is true)
* @returns A promise that resolves when the ciphers are deleted
*/
abstract deleteManyWithServer(
ids: string[],
userId: UserId,
asAdmin?: boolean,
orgId?: OrganizationId,
): Promise<void>;
/**
* Soft deletes a cipher on the server using the SDK.
*
* @param id The cipher ID to soft delete
* @param userId The user ID to use for SDK client
* @param asAdmin Whether this is an organization admin operation
* @returns A promise that resolves when the cipher is soft deleted
*/
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
/**
* Soft deletes multiple ciphers on the server using the SDK.
*
* @param ids The cipher IDs to soft delete
* @param userId The user ID to use for SDK client
* @param asAdmin Whether this is an organization admin operation
* @param orgId The organization ID (required when asAdmin is true)
* @returns A promise that resolves when the ciphers are soft deleted
*/
abstract softDeleteManyWithServer(
ids: string[],
userId: UserId,
asAdmin?: boolean,
orgId?: OrganizationId,
): Promise<void>;
/**
* Restores a soft-deleted cipher on the server using the SDK.
*
* @param id The cipher ID to restore
* @param userId The user ID to use for SDK client
* @param asAdmin Whether this is an organization admin operation
* @returns A promise that resolves when the cipher is restored
*/
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
/**
* Restores multiple soft-deleted ciphers on the server using the SDK.
*
* @param ids The cipher IDs to restore
* @param userId The user ID to use for SDK client
* @param orgId The organization ID (determines whether to use admin API)
* @returns A promise that resolves when the ciphers are restored
*/
abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void>;
}

View File

@@ -119,9 +119,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* @returns A promise that resolves to the created cipher
*/
abstract createWithServer(
{ cipher, encryptedFor }: EncryptionContext,
cipherView: CipherView,
userId: UserId,
orgAdmin?: boolean,
): Promise<Cipher>;
): Promise<CipherView>;
/**
* Update a cipher with the server
* @param cipher The cipher to update
@@ -131,10 +133,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* @returns A promise that resolves to the updated cipher
*/
abstract updateWithServer(
{ cipher, encryptedFor }: EncryptionContext,
cipherView: CipherView,
userId: UserId,
originalCipherView?: CipherView,
orgAdmin?: boolean,
isNotClone?: boolean,
): Promise<Cipher>;
): Promise<CipherView>;
/**
* Move a cipher to an organization by re-encrypting its keys with the organization's key.
@@ -227,8 +230,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
abstract clear(userId?: string): Promise<void>;
abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any>;
abstract delete(id: string | string[], userId: UserId): Promise<any>;
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
abstract deleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
abstract deleteManyWithServer(
ids: string[],
userId: UserId,
asAdmin?: boolean,
orgId?: OrganizationId,
): Promise<void>;
abstract deleteAttachment(
id: string,
revisionDate: string,
@@ -244,14 +252,19 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
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>;
abstract softDelete(id: string | string[], userId: UserId): Promise<void>;
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
abstract softDeleteManyWithServer(
ids: string[],
userId: UserId,
asAdmin?: boolean,
orgId?: OrganizationId,
): Promise<void>;
abstract restore(
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[],
userId: UserId,
): Promise<any>;
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
): Promise<void>;
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void>;
abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<any>;
abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise<void>;
@@ -272,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
abstract getNextIdentityCipher(userId: UserId): Promise<CipherView>;
/**
* Decrypts a cipher using either the SDK or the legacy method based on the feature flag.
* Decrypts a cipher using either the use-sdk-cipheroperationsSDK or the legacy method based on the feature flag.
* @param cipher The cipher to decrypt.
* @param userId The user ID to use for decryption.
* @returns A promise that resolves to the decrypted cipher view.

View File

@@ -353,4 +353,366 @@ describe("CipherView", () => {
});
});
});
// Note: These tests use jest.requireActual() because the file has jest.mock() calls
// at the top that mock LoginView, FieldView, etc. Those mocks are needed for other tests
// but interfere with these tests which need the real implementations.
describe("toSdkCreateCipherRequest", () => {
it("maps all properties correctly for a login cipher", () => {
const { FieldView: RealFieldView } = jest.requireActual("./field.view");
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
const cipherView = new CipherView();
cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c";
cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f";
cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"];
cipherView.name = "Test Login";
cipherView.notes = "Test notes";
cipherView.type = CipherType.Login;
cipherView.favorite = true;
cipherView.reprompt = CipherRepromptType.Password;
const field = new RealFieldView();
field.name = "testField";
field.value = "testValue";
field.type = SdkFieldType.Text;
cipherView.fields = [field];
cipherView.login = new RealLoginView();
cipherView.login.username = "testuser";
cipherView.login.password = "testpass";
const result = cipherView.toSdkCreateCipherRequest();
expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c"));
expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f"));
expect(result.collectionIds).toEqual([asUuid("b0473506-3c3c-4260-a734-dfaaf833ab6f")]);
expect(result.name).toBe("Test Login");
expect(result.notes).toBe("Test notes");
expect(result.favorite).toBe(true);
expect(result.reprompt).toBe(CipherRepromptType.Password);
expect(result.fields).toHaveLength(1);
expect(result.fields![0]).toMatchObject({
name: "testField",
value: "testValue",
type: SdkFieldType.Text,
});
expect(result.type).toHaveProperty("login");
expect((result.type as any).login).toMatchObject({
username: "testuser",
password: "testpass",
});
});
it("handles undefined organizationId and folderId", () => {
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
cipherView.type = CipherType.SecureNote;
cipherView.secureNote = new RealSecureNoteView();
const result = cipherView.toSdkCreateCipherRequest();
expect(result.organizationId).toBeUndefined();
expect(result.folderId).toBeUndefined();
expect(result.name).toBe("Test Cipher");
});
it("handles empty collectionIds array", () => {
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
cipherView.collectionIds = [];
cipherView.type = CipherType.Login;
cipherView.login = new RealLoginView();
const result = cipherView.toSdkCreateCipherRequest();
expect(result.collectionIds).toEqual([]);
});
it("defaults favorite to false when undefined", () => {
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
cipherView.favorite = undefined as any;
cipherView.type = CipherType.Login;
cipherView.login = new RealLoginView();
const result = cipherView.toSdkCreateCipherRequest();
expect(result.favorite).toBe(false);
});
it("defaults reprompt to None when undefined", () => {
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
cipherView.reprompt = undefined as any;
cipherView.type = CipherType.Login;
cipherView.login = new RealLoginView();
const result = cipherView.toSdkCreateCipherRequest();
expect(result.reprompt).toBe(CipherRepromptType.None);
});
test.each([
["Login", CipherType.Login, "login.view", "LoginView"],
["Card", CipherType.Card, "card.view", "CardView"],
["Identity", CipherType.Identity, "identity.view", "IdentityView"],
["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"],
["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"],
])(
"creates correct type property for %s cipher",
(typeName: string, cipherType: CipherType, moduleName: string, className: string) => {
const module = jest.requireActual(`./${moduleName}`);
const ViewClass = module[className];
const cipherView = new CipherView();
cipherView.name = `Test ${typeName}`;
cipherView.type = cipherType;
// Set the appropriate view property
const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
(cipherView as any)[viewPropertyName] = new ViewClass();
const result = cipherView.toSdkCreateCipherRequest();
const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1);
expect(result.type).toHaveProperty(typeKey);
},
);
});
describe("toSdkUpdateCipherRequest", () => {
it("maps all properties correctly for an update request", () => {
const { FieldView: RealFieldView } = jest.requireActual("./field.view");
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
const cipherView = new CipherView();
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c";
cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f";
cipherView.name = "Updated Login";
cipherView.notes = "Updated notes";
cipherView.type = CipherType.Login;
cipherView.favorite = true;
cipherView.reprompt = CipherRepromptType.Password;
cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z");
cipherView.archivedDate = new Date("2022-01-03T12:00:00.000Z");
cipherView.key = new EncString("cipher-key");
const mockField = new RealFieldView();
mockField.name = "testField";
mockField.value = "testValue";
cipherView.fields = [mockField];
cipherView.login = new RealLoginView();
cipherView.login.username = "testuser";
const result = cipherView.toSdkUpdateCipherRequest();
expect(result.id).toEqual(asUuid("0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"));
expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c"));
expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f"));
expect(result.name).toBe("Updated Login");
expect(result.notes).toBe("Updated notes");
expect(result.favorite).toBe(true);
expect(result.reprompt).toBe(CipherRepromptType.Password);
expect(result.revisionDate).toBe("2022-01-02T12:00:00.000Z");
expect(result.archivedDate).toBe("2022-01-03T12:00:00.000Z");
expect(result.fields).toHaveLength(1);
expect(result.fields![0]).toMatchObject({
name: "testField",
value: "testValue",
});
expect(result.type).toHaveProperty("login");
expect((result.type as any).login).toMatchObject({
username: "testuser",
});
expect(result.key).toBeDefined();
});
it("handles undefined optional properties", () => {
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
const cipherView = new CipherView();
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
cipherView.name = "Test Cipher";
cipherView.type = CipherType.SecureNote;
cipherView.secureNote = new RealSecureNoteView();
cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z");
const result = cipherView.toSdkUpdateCipherRequest();
expect(result.organizationId).toBeUndefined();
expect(result.folderId).toBeUndefined();
expect(result.archivedDate).toBeUndefined();
expect(result.key).toBeUndefined();
});
it("converts dates to ISO strings", () => {
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
const cipherView = new CipherView();
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
cipherView.name = "Test Cipher";
cipherView.type = CipherType.Login;
cipherView.login = new RealLoginView();
cipherView.revisionDate = new Date("2022-05-15T10:30:00.000Z");
cipherView.archivedDate = new Date("2022-06-20T14:45:00.000Z");
const result = cipherView.toSdkUpdateCipherRequest();
expect(result.revisionDate).toBe("2022-05-15T10:30:00.000Z");
expect(result.archivedDate).toBe("2022-06-20T14:45:00.000Z");
});
it("includes attachments when present", () => {
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
const { AttachmentView: RealAttachmentView } = jest.requireActual("./attachment.view");
const cipherView = new CipherView();
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
cipherView.name = "Test Cipher";
cipherView.type = CipherType.Login;
cipherView.login = new RealLoginView();
const attachment1 = new RealAttachmentView();
attachment1.id = "attachment-id-1";
attachment1.fileName = "file1.txt";
const attachment2 = new RealAttachmentView();
attachment2.id = "attachment-id-2";
attachment2.fileName = "file2.pdf";
cipherView.attachments = [attachment1, attachment2];
const result = cipherView.toSdkUpdateCipherRequest();
expect(result.attachments).toHaveLength(2);
});
test.each([
["Login", CipherType.Login, "login.view", "LoginView"],
["Card", CipherType.Card, "card.view", "CardView"],
["Identity", CipherType.Identity, "identity.view", "IdentityView"],
["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"],
["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"],
])(
"creates correct type property for %s cipher",
(typeName: string, cipherType: CipherType, moduleName: string, className: string) => {
const module = jest.requireActual(`./${moduleName}`);
const ViewClass = module[className];
const cipherView = new CipherView();
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
cipherView.name = `Test ${typeName}`;
cipherView.type = cipherType;
// Set the appropriate view property
const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
(cipherView as any)[viewPropertyName] = new ViewClass();
const result = cipherView.toSdkUpdateCipherRequest();
const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1);
expect(result.type).toHaveProperty(typeKey);
},
);
});
describe("getSdkCipherViewType", () => {
it("returns login type for Login cipher", () => {
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
const cipherView = new CipherView();
cipherView.type = CipherType.Login;
cipherView.login = new RealLoginView();
cipherView.login.username = "testuser";
cipherView.login.password = "testpass";
const result = cipherView.getSdkCipherViewType();
expect(result).toHaveProperty("login");
expect((result as any).login).toMatchObject({
username: "testuser",
password: "testpass",
});
});
it("returns card type for Card cipher", () => {
const { CardView: RealCardView } = jest.requireActual("./card.view");
const cipherView = new CipherView();
cipherView.type = CipherType.Card;
cipherView.card = new RealCardView();
cipherView.card.cardholderName = "John Doe";
cipherView.card.number = "4111111111111111";
const result = cipherView.getSdkCipherViewType();
expect(result).toHaveProperty("card");
expect((result as any).card.cardholderName).toBe("John Doe");
expect((result as any).card.number).toBe("4111111111111111");
});
it("returns identity type for Identity cipher", () => {
const { IdentityView: RealIdentityView } = jest.requireActual("./identity.view");
const cipherView = new CipherView();
cipherView.type = CipherType.Identity;
cipherView.identity = new RealIdentityView();
cipherView.identity.firstName = "John";
cipherView.identity.lastName = "Doe";
const result = cipherView.getSdkCipherViewType();
expect(result).toHaveProperty("identity");
expect((result as any).identity.firstName).toBe("John");
expect((result as any).identity.lastName).toBe("Doe");
});
it("returns secureNote type for SecureNote cipher", () => {
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
const cipherView = new CipherView();
cipherView.type = CipherType.SecureNote;
cipherView.secureNote = new RealSecureNoteView();
const result = cipherView.getSdkCipherViewType();
expect(result).toHaveProperty("secureNote");
});
it("returns sshKey type for SshKey cipher", () => {
const { SshKeyView: RealSshKeyView } = jest.requireActual("./ssh-key.view");
const cipherView = new CipherView();
cipherView.type = CipherType.SshKey;
cipherView.sshKey = new RealSshKeyView();
cipherView.sshKey.privateKey = "privateKeyData";
cipherView.sshKey.publicKey = "publicKeyData";
const result = cipherView.getSdkCipherViewType();
expect(result).toHaveProperty("sshKey");
expect((result as any).sshKey.privateKey).toBe("privateKeyData");
expect((result as any).sshKey.publicKey).toBe("publicKeyData");
});
it("defaults to empty login for unknown cipher type", () => {
const cipherView = new CipherView();
cipherView.type = 999 as CipherType;
const result = cipherView.getSdkCipherViewType();
expect(result).toHaveProperty("login");
});
});
});

View File

@@ -1,7 +1,12 @@
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { asUuid, uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { ItemView } from "@bitwarden/common/vault/models/view/item.view";
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
import {
CipherCreateRequest,
CipherEditRequest,
CipherViewType,
CipherView as SdkCipherView,
} from "@bitwarden/sdk-internal";
import { View } from "../../../models/view/view";
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
@@ -332,6 +337,85 @@ export class CipherView implements View, InitializerMetadata {
return cipherView;
}
/**
* Maps CipherView to an SDK CipherCreateRequest
*
* @returns {CipherCreateRequest} The SDK cipher create request object
*/
toSdkCreateCipherRequest(): CipherCreateRequest {
const sdkCipherCreateRequest: CipherCreateRequest = {
organizationId: this.organizationId ? asUuid(this.organizationId) : undefined,
collectionIds: this.collectionIds ? this.collectionIds.map((i) => asUuid(i)) : [],
folderId: this.folderId ? asUuid(this.folderId) : undefined,
name: this.name ?? "",
notes: this.notes,
favorite: this.favorite ?? false,
reprompt: this.reprompt ?? CipherRepromptType.None,
fields: this.fields?.map((f) => f.toSdkFieldView()),
type: this.getSdkCipherViewType(),
};
return sdkCipherCreateRequest;
}
/**
* Maps CipherView to an SDK CipherEditRequest
*
* @returns {CipherEditRequest} The SDK cipher edit request object
*/
toSdkUpdateCipherRequest(): CipherEditRequest {
const sdkCipherEditRequest: CipherEditRequest = {
id: asUuid(this.id),
organizationId: this.organizationId ? asUuid(this.organizationId) : undefined,
folderId: this.folderId ? asUuid(this.folderId) : undefined,
name: this.name ?? "",
notes: this.notes,
favorite: this.favorite ?? false,
reprompt: this.reprompt ?? CipherRepromptType.None,
fields: this.fields?.map((f) => f.toSdkFieldView()),
type: this.getSdkCipherViewType(),
revisionDate: this.revisionDate?.toISOString(),
archivedDate: this.archivedDate?.toISOString(),
attachments: this.attachments?.map((a) => a.toSdkAttachmentView()),
key: this.key?.toSdk(),
};
return sdkCipherEditRequest;
}
/**
* Returns the SDK CipherViewType object for the cipher.
*
* @returns {CipherViewType} The SDK CipherViewType for the cipher.t
*/
getSdkCipherViewType(): CipherViewType {
let viewType: CipherViewType;
switch (this.type) {
case CipherType.Card:
viewType = { card: this.card?.toSdkCardView() };
break;
case CipherType.Identity:
viewType = { identity: this.identity?.toSdkIdentityView() };
break;
case CipherType.Login:
viewType = { login: this.login?.toSdkLoginView() };
break;
case CipherType.SecureNote:
viewType = { secureNote: this.secureNote?.toSdkSecureNoteView() };
break;
case CipherType.SshKey:
viewType = { sshKey: this.sshKey?.toSdkSshKeyView() };
break;
default:
viewType = {
// Default to empty login - should not be valid code path.
login: new LoginView().toSdkLoginView(),
};
break;
}
return viewType;
}
/**
* Maps CipherView to SdkCipherView
*

View File

@@ -205,6 +205,70 @@ describe("CipherAuthorizationService", () => {
});
});
describe("canEditCipher$", () => {
it("should return true if isAdminConsoleAction is true and cipher is unassigned", (done) => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization({ canEditUnassignedCiphers: true });
mockOrganizationService.organizations$.mockReturnValue(
of([organization]) as Observable<Organization[]>,
);
cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(true);
done();
});
});
it("should return true if isAdminConsoleAction is true and user can edit all ciphers in the org", (done) => {
const cipher = createMockCipher("org1", ["col1"]) as CipherView;
const organization = createMockOrganization({ canEditAllCiphers: true });
mockOrganizationService.organizations$.mockReturnValue(
of([organization]) as Observable<Organization[]>,
);
cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(true);
expect(mockOrganizationService.organizations$).toHaveBeenCalledWith(mockUserId);
done();
});
});
it("should return false if isAdminConsoleAction is true but user does not have permission to edit unassigned ciphers", (done) => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization({ canEditUnassignedCiphers: false });
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => {
expect(result).toBe(false);
done();
});
});
it("should return true if cipher.edit is true and is not an admin action", (done) => {
const cipher = createMockCipher("org1", [], true) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
cipherAuthorizationService.canEditCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(true);
expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
done();
});
});
it("should return false if cipher.edit is false and is not an admin action", (done) => {
const cipher = createMockCipher("org1", [], false) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[]));
cipherAuthorizationService.canEditCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(false);
expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
done();
});
});
});
describe("canCloneCipher$", () => {
it("should return true if cipher has no organizationId", async () => {
const cipher = createMockCipher(null, []) as CipherView;

View File

@@ -53,6 +53,19 @@ export abstract class CipherAuthorizationService {
cipher: CipherLike,
isAdminConsoleAction?: boolean,
) => Observable<boolean>;
/**
* Determines if the user can edit the specified cipher.
*
* @param {CipherLike} cipher - The cipher object to evaluate for edit permissions.
* @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console.
*
* @returns {Observable<boolean>} - An observable that emits a boolean value indicating if the user can edit the cipher.
*/
abstract canEditCipher$: (
cipher: CipherLike,
isAdminConsoleAction?: boolean,
) => Observable<boolean>;
}
/**
@@ -118,6 +131,29 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
);
}
/**
*
* {@link CipherAuthorizationService.canEditCipher$}
*/
canEditCipher$(cipher: CipherLike, isAdminConsoleAction?: boolean): Observable<boolean> {
return this.organization$(cipher).pipe(
map((organization) => {
if (isAdminConsoleAction) {
// If the user is an admin, they can edit an unassigned cipher
if (!cipher.collectionIds || cipher.collectionIds.length === 0) {
return organization?.canEditUnassignedCiphers === true;
}
if (organization?.canEditAllCiphers) {
return true;
}
}
return !!cipher.edit;
}),
);
}
/**
* {@link CipherAuthorizationService.canCloneCipher$}
*/

View File

@@ -0,0 +1,534 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { UserId, CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherType } from "../enums/cipher-type";
import { DefaultCipherSdkService } from "./cipher-sdk.service";
describe("DefaultCipherSdkService", () => {
const sdkService = mock<SdkService>();
const logService = mock<LogService>();
const userId = "test-user-id" as UserId;
const cipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId;
let cipherSdkService: DefaultCipherSdkService;
let mockSdkClient: any;
let mockCiphersSdk: any;
let mockAdminSdk: any;
let mockVaultSdk: any;
beforeEach(() => {
// Mock the SDK client chain for admin operations
mockAdminSdk = {
create: jest.fn(),
edit: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
delete_many: jest.fn().mockResolvedValue(undefined),
soft_delete: jest.fn().mockResolvedValue(undefined),
soft_delete_many: jest.fn().mockResolvedValue(undefined),
restore: jest.fn().mockResolvedValue(undefined),
restore_many: jest.fn().mockResolvedValue(undefined),
};
mockCiphersSdk = {
create: jest.fn(),
edit: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
delete_many: jest.fn().mockResolvedValue(undefined),
soft_delete: jest.fn().mockResolvedValue(undefined),
soft_delete_many: jest.fn().mockResolvedValue(undefined),
restore: jest.fn().mockResolvedValue(undefined),
restore_many: jest.fn().mockResolvedValue(undefined),
admin: jest.fn().mockReturnValue(mockAdminSdk),
};
mockVaultSdk = {
ciphers: jest.fn().mockReturnValue(mockCiphersSdk),
};
const mockSdkValue = {
vault: jest.fn().mockReturnValue(mockVaultSdk),
};
mockSdkClient = {
take: jest.fn().mockReturnValue({
value: mockSdkValue,
[Symbol.dispose]: jest.fn(),
}),
};
// Mock sdkService to return the mock client
sdkService.userClient$.mockReturnValue(of(mockSdkClient));
cipherSdkService = new DefaultCipherSdkService(sdkService, logService);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("createWithServer()", () => {
it("should create cipher using SDK when orgAdmin is false", async () => {
const cipherView = new CipherView();
cipherView.id = cipherId;
cipherView.type = CipherType.Login;
cipherView.name = "Test Cipher";
cipherView.organizationId = orgId;
const mockSdkCipherView = cipherView.toSdkCipherView();
mockCiphersSdk.create.mockResolvedValue(mockSdkCipherView);
const result = await cipherSdkService.createWithServer(cipherView, userId, false);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.create).toHaveBeenCalledWith(
expect.objectContaining({
name: cipherView.name,
organizationId: expect.anything(),
}),
);
expect(result).toBeInstanceOf(CipherView);
expect(result?.name).toBe(cipherView.name);
});
it("should create cipher using SDK admin API when orgAdmin is true", async () => {
const cipherView = new CipherView();
cipherView.id = cipherId;
cipherView.type = CipherType.Login;
cipherView.name = "Test Admin Cipher";
cipherView.organizationId = orgId;
const mockSdkCipherView = cipherView.toSdkCipherView();
mockAdminSdk.create.mockResolvedValue(mockSdkCipherView);
const result = await cipherSdkService.createWithServer(cipherView, userId, true);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.create).toHaveBeenCalledWith(
expect.objectContaining({
name: cipherView.name,
}),
);
expect(result).toBeInstanceOf(CipherView);
expect(result?.name).toBe(cipherView.name);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to create cipher"),
);
});
it("should throw error and log when SDK throws an error", async () => {
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
mockCiphersSdk.create.mockRejectedValue(new Error("SDK error"));
await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to create cipher"),
);
});
});
describe("updateWithServer()", () => {
it("should update cipher using SDK when orgAdmin is false", async () => {
const cipherView = new CipherView();
cipherView.id = cipherId;
cipherView.type = CipherType.Login;
cipherView.name = "Updated Cipher";
cipherView.organizationId = orgId;
const mockSdkCipherView = cipherView.toSdkCipherView();
mockCiphersSdk.edit.mockResolvedValue(mockSdkCipherView);
const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, false);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.edit).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.anything(),
name: cipherView.name,
}),
);
expect(result).toBeInstanceOf(CipherView);
expect(result.name).toBe(cipherView.name);
});
it("should update cipher using SDK admin API when orgAdmin is true", async () => {
const cipherView = new CipherView();
cipherView.id = cipherId;
cipherView.type = CipherType.Login;
cipherView.name = "Updated Admin Cipher";
cipherView.organizationId = orgId;
const originalCipherView = new CipherView();
originalCipherView.id = cipherId;
originalCipherView.name = "Original Cipher";
const mockSdkCipherView = cipherView.toSdkCipherView();
mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView);
const result = await cipherSdkService.updateWithServer(
cipherView,
userId,
originalCipherView,
true,
);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.edit).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.anything(),
name: cipherView.name,
}),
originalCipherView.toSdkCipherView(),
);
expect(result).toBeInstanceOf(CipherView);
expect(result.name).toBe(cipherView.name);
});
it("should update cipher using SDK admin API without originalCipherView", async () => {
const cipherView = new CipherView();
cipherView.id = cipherId;
cipherView.type = CipherType.Login;
cipherView.name = "Updated Admin Cipher";
cipherView.organizationId = orgId;
const mockSdkCipherView = cipherView.toSdkCipherView();
mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView);
const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, true);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.edit).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.anything(),
name: cipherView.name,
}),
expect.anything(), // Empty CipherView - timestamps vary so we just verify it was called
);
expect(result).toBeInstanceOf(CipherView);
expect(result.name).toBe(cipherView.name);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
await expect(
cipherSdkService.updateWithServer(cipherView, userId, undefined, false),
).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to update cipher"),
);
});
it("should throw error and log when SDK throws an error", async () => {
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
mockCiphersSdk.edit.mockRejectedValue(new Error("SDK error"));
await expect(
cipherSdkService.updateWithServer(cipherView, userId, undefined, false),
).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to update cipher"),
);
});
});
describe("deleteWithServer()", () => {
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
it("should delete cipher using SDK when asAdmin is false", async () => {
await cipherSdkService.deleteWithServer(testCipherId, userId, false);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.delete).toHaveBeenCalledWith(testCipherId);
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
});
it("should delete cipher using SDK admin API when asAdmin is true", async () => {
await cipherSdkService.deleteWithServer(testCipherId, userId, true);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.delete).toHaveBeenCalledWith(testCipherId);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow(
"SDK not available",
);
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete cipher"),
);
});
it("should throw error and log when SDK throws an error", async () => {
mockCiphersSdk.delete.mockRejectedValue(new Error("SDK error"));
await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete cipher"),
);
});
});
describe("deleteManyWithServer()", () => {
const testCipherIds = [
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
];
it("should delete multiple ciphers using SDK when asAdmin is false", async () => {
await cipherSdkService.deleteManyWithServer(testCipherIds, userId, false);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.delete_many).toHaveBeenCalledWith(testCipherIds);
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
});
it("should delete multiple ciphers using SDK admin API when asAdmin is true", async () => {
await cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, orgId);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.delete_many).toHaveBeenCalledWith(testCipherIds, orgId);
});
it("should throw error when asAdmin is true but orgId is missing", async () => {
await expect(
cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, undefined),
).rejects.toThrow("Organization ID is required for admin delete.");
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow(
"SDK not available",
);
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete multiple ciphers"),
);
});
it("should throw error and log when SDK throws an error", async () => {
mockCiphersSdk.delete_many.mockRejectedValue(new Error("SDK error"));
await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete multiple ciphers"),
);
});
});
describe("softDeleteWithServer()", () => {
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
it("should soft delete cipher using SDK when asAdmin is false", async () => {
await cipherSdkService.softDeleteWithServer(testCipherId, userId, false);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.soft_delete).toHaveBeenCalledWith(testCipherId);
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
});
it("should soft delete cipher using SDK admin API when asAdmin is true", async () => {
await cipherSdkService.softDeleteWithServer(testCipherId, userId, true);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.soft_delete).toHaveBeenCalledWith(testCipherId);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow(
"SDK not available",
);
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to soft delete cipher"),
);
});
it("should throw error and log when SDK throws an error", async () => {
mockCiphersSdk.soft_delete.mockRejectedValue(new Error("SDK error"));
await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to soft delete cipher"),
);
});
});
describe("softDeleteManyWithServer()", () => {
const testCipherIds = [
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
];
it("should soft delete multiple ciphers using SDK when asAdmin is false", async () => {
await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, false);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds);
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
});
it("should soft delete multiple ciphers using SDK admin API when asAdmin is true", async () => {
await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds, orgId);
});
it("should throw error when asAdmin is true but orgId is missing", async () => {
await expect(
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, undefined),
).rejects.toThrow("Organization ID is required for admin soft delete.");
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
await expect(
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId),
).rejects.toThrow("SDK not available");
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to soft delete multiple ciphers"),
);
});
it("should throw error and log when SDK throws an error", async () => {
mockCiphersSdk.soft_delete_many.mockRejectedValue(new Error("SDK error"));
await expect(
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId),
).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to soft delete multiple ciphers"),
);
});
});
describe("restoreWithServer()", () => {
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
it("should restore cipher using SDK when asAdmin is false", async () => {
await cipherSdkService.restoreWithServer(testCipherId, userId, false);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.restore).toHaveBeenCalledWith(testCipherId);
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
});
it("should restore cipher using SDK admin API when asAdmin is true", async () => {
await cipherSdkService.restoreWithServer(testCipherId, userId, true);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.restore).toHaveBeenCalledWith(testCipherId);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow(
"SDK not available",
);
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to restore cipher"),
);
});
it("should throw error and log when SDK throws an error", async () => {
mockCiphersSdk.restore.mockRejectedValue(new Error("SDK error"));
await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to restore cipher"),
);
});
});
describe("restoreManyWithServer()", () => {
const testCipherIds = [
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
];
it("should restore multiple ciphers using SDK when orgId is not provided", async () => {
await cipherSdkService.restoreManyWithServer(testCipherIds, userId);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.restore_many).toHaveBeenCalledWith(testCipherIds);
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
});
it("should restore multiple ciphers using SDK admin API when orgId is provided", async () => {
const orgIdString = orgId as string;
await cipherSdkService.restoreManyWithServer(testCipherIds, userId, orgIdString);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.restore_many).toHaveBeenCalledWith(testCipherIds, orgIdString);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow(
"SDK not available",
);
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to restore multiple ciphers"),
);
});
it("should throw error and log when SDK throws an error", async () => {
mockCiphersSdk.restore_many.mockRejectedValue(new Error("SDK error"));
await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to restore multiple ciphers"),
);
});
});
});

View File

@@ -0,0 +1,263 @@
import { firstValueFrom, switchMap, catchError } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkService, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
export class DefaultCipherSdkService implements CipherSdkService {
constructor(
private sdkService: SdkService,
private logService: LogService,
) {}
async createWithServer(
cipherView: CipherView,
userId: UserId,
orgAdmin?: boolean,
): Promise<CipherView | undefined> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
const sdkCreateRequest = cipherView.toSdkCreateCipherRequest();
let result: SdkCipherView;
if (orgAdmin) {
result = await ref.value.vault().ciphers().admin().create(sdkCreateRequest);
} else {
result = await ref.value.vault().ciphers().create(sdkCreateRequest);
}
return CipherView.fromSdkCipherView(result);
}),
catchError((error: unknown) => {
this.logService.error(`Failed to create cipher: ${error}`);
throw error;
}),
),
);
}
async updateWithServer(
cipher: CipherView,
userId: UserId,
originalCipherView?: CipherView,
orgAdmin?: boolean,
): Promise<CipherView | undefined> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
const sdkUpdateRequest = cipher.toSdkUpdateCipherRequest();
let result: SdkCipherView;
if (orgAdmin) {
result = await ref.value
.vault()
.ciphers()
.admin()
.edit(
sdkUpdateRequest,
originalCipherView?.toSdkCipherView() || new CipherView().toSdkCipherView(),
);
} else {
result = await ref.value.vault().ciphers().edit(sdkUpdateRequest);
}
return CipherView.fromSdkCipherView(result);
}),
catchError((error: unknown) => {
this.logService.error(`Failed to update cipher: ${error}`);
throw error;
}),
),
);
}
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
if (asAdmin) {
await ref.value.vault().ciphers().admin().delete(asUuid(id));
} else {
await ref.value.vault().ciphers().delete(asUuid(id));
}
}),
catchError((error: unknown) => {
this.logService.error(`Failed to delete cipher: ${error}`);
throw error;
}),
),
);
}
async deleteManyWithServer(
ids: string[],
userId: UserId,
asAdmin = false,
orgId?: OrganizationId,
): Promise<void> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
if (asAdmin) {
if (orgId == null) {
throw new Error("Organization ID is required for admin delete.");
}
await ref.value
.vault()
.ciphers()
.admin()
.delete_many(
ids.map((id) => asUuid(id)),
asUuid(orgId),
);
} else {
await ref.value
.vault()
.ciphers()
.delete_many(ids.map((id) => asUuid(id)));
}
}),
catchError((error: unknown) => {
this.logService.error(`Failed to delete multiple ciphers: ${error}`);
throw error;
}),
),
);
}
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
if (asAdmin) {
await ref.value.vault().ciphers().admin().soft_delete(asUuid(id));
} else {
await ref.value.vault().ciphers().soft_delete(asUuid(id));
}
}),
catchError((error: unknown) => {
this.logService.error(`Failed to soft delete cipher: ${error}`);
throw error;
}),
),
);
}
async softDeleteManyWithServer(
ids: string[],
userId: UserId,
asAdmin = false,
orgId?: OrganizationId,
): Promise<void> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
if (asAdmin) {
if (orgId == null) {
throw new Error("Organization ID is required for admin soft delete.");
}
await ref.value
.vault()
.ciphers()
.admin()
.soft_delete_many(
ids.map((id) => asUuid(id)),
asUuid(orgId),
);
} else {
await ref.value
.vault()
.ciphers()
.soft_delete_many(ids.map((id) => asUuid(id)));
}
}),
catchError((error: unknown) => {
this.logService.error(`Failed to soft delete multiple ciphers: ${error}`);
throw error;
}),
),
);
}
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
if (asAdmin) {
await ref.value.vault().ciphers().admin().restore(asUuid(id));
} else {
await ref.value.vault().ciphers().restore(asUuid(id));
}
}),
catchError((error: unknown) => {
this.logService.error(`Failed to restore cipher: ${error}`);
throw error;
}),
),
);
}
async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
// No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable
// The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore
if (orgId) {
await ref.value
.vault()
.ciphers()
.admin()
.restore_many(
ids.map((id) => asUuid(id)),
asUuid(orgId),
);
} else {
await ref.value
.vault()
.ciphers()
.restore_many(ids.map((id) => asUuid(id)));
}
}),
catchError((error: unknown) => {
this.logService.error(`Failed to restore multiple ciphers: ${error}`);
throw error;
}),
),
);
}
}

View File

@@ -28,6 +28,7 @@ import { ContainerService } from "../../platform/services/container.service";
import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid";
import { CipherKey, OrgKey, UserKey } from "../../types/key";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
import { EncryptionContext } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { SearchService } from "../abstractions/search.service";
@@ -54,9 +55,9 @@ function encryptText(clearText: string | Uint8Array) {
const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
const cipherData: CipherData = {
id: "id",
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId,
folderId: "folderId",
id: "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId,
folderId: "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23",
edit: true,
viewPassword: true,
organizationUseTotp: true,
@@ -109,12 +110,15 @@ describe("Cipher Service", () => {
const stateProvider = new FakeStateProvider(accountService);
const cipherEncryptionService = mock<CipherEncryptionService>();
const messageSender = mock<MessageSender>();
const cipherSdkService = mock<CipherSdkService>();
const userId = "TestUserId" as UserId;
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId;
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId;
let cipherService: CipherService;
let encryptionContext: EncryptionContext;
// BehaviorSubject for SDK feature flag - allows tests to change the value after service instantiation
let sdkCrudFeatureFlag$: BehaviorSubject<boolean>;
beforeEach(() => {
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
@@ -130,6 +134,10 @@ describe("Cipher Service", () => {
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
// Create BehaviorSubject for SDK feature flag - tests can update this to change behavior
sdkCrudFeatureFlag$ = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockReturnValue(sdkCrudFeatureFlag$.asObservable());
cipherService = new CipherService(
keyService,
domainSettingsService,
@@ -145,6 +153,7 @@ describe("Cipher Service", () => {
logService,
cipherEncryptionService,
messageSender,
cipherSdkService,
);
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
@@ -207,11 +216,22 @@ describe("Cipher Service", () => {
});
describe("createWithServer()", () => {
beforeEach(() => {
jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext);
jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => {
return new CipherView(cipher);
});
});
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(false);
const spy = jest
.spyOn(apiService, "postCipherAdmin")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext, true);
const cipherView = new CipherView(encryptionContext.cipher);
await cipherService.createWithServer(cipherView, userId, true);
const expectedObj = new CipherCreateRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
@@ -219,11 +239,15 @@ describe("Cipher Service", () => {
});
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(false);
encryptionContext.cipher.organizationId = null!;
const spy = jest
.spyOn(apiService, "postCipher")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext, true);
const cipherView = new CipherView(encryptionContext.cipher);
await cipherService.createWithServer(cipherView, userId, true);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
@@ -231,11 +255,15 @@ describe("Cipher Service", () => {
});
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(false);
encryptionContext.cipher.collectionIds = ["123"];
const spy = jest
.spyOn(apiService, "postCipherCreate")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext);
const cipherView = new CipherView(encryptionContext.cipher);
await cipherService.createWithServer(cipherView, userId);
const expectedObj = new CipherCreateRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
@@ -243,35 +271,84 @@ describe("Cipher Service", () => {
});
it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(false);
const spy = jest
.spyOn(apiService, "postCipher")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext);
const cipherView = new CipherView(encryptionContext.cipher);
await cipherService.createWithServer(cipherView, userId);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
sdkCrudFeatureFlag$.next(true);
const cipherView = new CipherView(encryptionContext.cipher);
const expectedResult = new CipherView(encryptionContext.cipher);
const cipherSdkServiceSpy = jest
.spyOn(cipherSdkService, "createWithServer")
.mockResolvedValue(expectedResult);
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
const apiSpy = jest.spyOn(apiService, "postCipher");
const result = await cipherService.createWithServer(cipherView, userId);
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined);
expect(apiSpy).not.toHaveBeenCalled();
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
expect(result).toBeInstanceOf(CipherView);
});
});
describe("updateWithServer()", () => {
beforeEach(() => {
jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext);
jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => {
return new CipherView(cipher);
});
jest.spyOn(cipherService, "upsert").mockResolvedValue({
[cipherData.id as CipherId]: cipherData,
});
});
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const testCipher = new Cipher(cipherData);
testCipher.organizationId = orgId;
const testContext = { cipher: testCipher, encryptedFor: userId };
jest.spyOn(cipherService, "encrypt").mockResolvedValue(testContext);
const spy = jest
.spyOn(apiService, "putCipherAdmin")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext, true);
const expectedObj = new CipherRequest(encryptionContext);
.mockImplementation(() => Promise.resolve<any>(testCipher.toCipherData()));
const cipherView = new CipherView(testCipher);
await cipherService.updateWithServer(cipherView, userId, undefined, true);
const expectedObj = new CipherRequest(testContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
expect(spy).toHaveBeenCalledWith(testCipher.id, expectedObj);
});
it("should call apiService.putCipher if cipher.edit is true", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(false);
encryptionContext.cipher.edit = true;
const spy = jest
.spyOn(apiService, "putCipher")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext);
const cipherView = new CipherView(encryptionContext.cipher);
await cipherService.updateWithServer(cipherView, userId);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
@@ -279,16 +356,75 @@ describe("Cipher Service", () => {
});
it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(false);
encryptionContext.cipher.edit = false;
const spy = jest
.spyOn(apiService, "putPartialCipher")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext);
const cipherView = new CipherView(encryptionContext.cipher);
await cipherService.updateWithServer(cipherView, userId);
const expectedObj = new CipherPartialRequest(encryptionContext.cipher);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
});
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
sdkCrudFeatureFlag$.next(true);
const testCipher = new Cipher(cipherData);
const cipherView = new CipherView(testCipher);
const expectedResult = new CipherView(testCipher);
const cipherSdkServiceSpy = jest
.spyOn(cipherSdkService, "updateWithServer")
.mockResolvedValue(expectedResult);
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
const apiSpy = jest.spyOn(apiService, "putCipher");
const result = await cipherService.updateWithServer(cipherView, userId);
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined, undefined);
expect(apiSpy).not.toHaveBeenCalled();
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
expect(result).toBeInstanceOf(CipherView);
});
it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => {
sdkCrudFeatureFlag$.next(true);
const testCipher = new Cipher(cipherData);
const cipherView = new CipherView(testCipher);
const originalCipherView = new CipherView(testCipher);
const expectedResult = new CipherView(testCipher);
const cipherSdkServiceSpy = jest
.spyOn(cipherSdkService, "updateWithServer")
.mockResolvedValue(expectedResult);
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
const apiSpy = jest.spyOn(apiService, "putCipherAdmin");
const result = await cipherService.updateWithServer(
cipherView,
userId,
originalCipherView,
true,
);
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(
cipherView,
userId,
originalCipherView,
true,
);
expect(apiSpy).not.toHaveBeenCalled();
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
expect(result).toBeInstanceOf(CipherView);
});
});
describe("encrypt", () => {
@@ -873,6 +1009,238 @@ describe("Cipher Service", () => {
});
});
describe("deleteWithServer()", () => {
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
it("should call apiService.deleteCipher when feature flag is disabled", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const apiSpy = jest.spyOn(apiService, "deleteCipher").mockResolvedValue(undefined);
await cipherService.deleteWithServer(testCipherId, userId);
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
});
it("should call apiService.deleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const apiSpy = jest.spyOn(apiService, "deleteCipherAdmin").mockResolvedValue(undefined);
await cipherService.deleteWithServer(testCipherId, userId, true);
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
});
it("should use SDK to delete cipher when feature flag is enabled", async () => {
sdkCrudFeatureFlag$.next(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "deleteWithServer")
.mockResolvedValue(undefined);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.deleteWithServer(testCipherId, userId, false);
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
it("should use SDK admin delete when feature flag is enabled and asAdmin is true", async () => {
sdkCrudFeatureFlag$.next(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "deleteWithServer")
.mockResolvedValue(undefined);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.deleteWithServer(testCipherId, userId, true);
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
});
describe("deleteManyWithServer()", () => {
const testCipherIds = [
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
];
it("should call apiService.deleteManyCiphers when feature flag is disabled", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const apiSpy = jest.spyOn(apiService, "deleteManyCiphers").mockResolvedValue(undefined);
await cipherService.deleteManyWithServer(testCipherIds, userId);
expect(apiSpy).toHaveBeenCalled();
});
it("should call apiService.deleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const apiSpy = jest.spyOn(apiService, "deleteManyCiphersAdmin").mockResolvedValue(undefined);
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId });
});
it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => {
sdkCrudFeatureFlag$.next(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "deleteManyWithServer")
.mockResolvedValue(undefined);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.deleteManyWithServer(testCipherIds, userId, false);
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
it("should use SDK admin delete many when feature flag is enabled and asAdmin is true", async () => {
sdkCrudFeatureFlag$.next(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "deleteManyWithServer")
.mockResolvedValue(undefined);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
});
describe("softDeleteWithServer()", () => {
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
it("should call apiService.putDeleteCipher when feature flag is disabled", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const apiSpy = jest.spyOn(apiService, "putDeleteCipher").mockResolvedValue(undefined);
await cipherService.softDeleteWithServer(testCipherId, userId);
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
});
it("should call apiService.putDeleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const apiSpy = jest.spyOn(apiService, "putDeleteCipherAdmin").mockResolvedValue(undefined);
await cipherService.softDeleteWithServer(testCipherId, userId, true);
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
});
it("should use SDK to soft delete cipher when feature flag is enabled", async () => {
sdkCrudFeatureFlag$.next(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "softDeleteWithServer")
.mockResolvedValue(undefined);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.softDeleteWithServer(testCipherId, userId, false);
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
it("should use SDK admin soft delete when feature flag is enabled and asAdmin is true", async () => {
sdkCrudFeatureFlag$.next(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "softDeleteWithServer")
.mockResolvedValue(undefined);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.softDeleteWithServer(testCipherId, userId, true);
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
});
describe("softDeleteManyWithServer()", () => {
const testCipherIds = [
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
];
it("should call apiService.putDeleteManyCiphers when feature flag is disabled", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphers").mockResolvedValue(undefined);
await cipherService.softDeleteManyWithServer(testCipherIds, userId);
expect(apiSpy).toHaveBeenCalled();
});
it("should call apiService.putDeleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => {
configService.getFeatureFlag$
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockReturnValue(of(false));
const apiSpy = jest
.spyOn(apiService, "putDeleteManyCiphersAdmin")
.mockResolvedValue(undefined);
await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId });
});
it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => {
sdkCrudFeatureFlag$.next(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "softDeleteManyWithServer")
.mockResolvedValue(undefined);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.softDeleteManyWithServer(testCipherIds, userId, false);
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
it("should use SDK admin soft delete many when feature flag is enabled and asAdmin is true", async () => {
sdkCrudFeatureFlag$.next(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "softDeleteManyWithServer")
.mockResolvedValue(undefined);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
});
describe("replace (no upsert)", () => {
// In order to set up initial state we need to manually update the encrypted state
// which will result in an emission. All tests will have this baseline emission.

View File

@@ -42,6 +42,7 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid
import { OrgKey, UserKey } from "../../types/key";
import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
import {
CipherService as CipherServiceAbstraction,
EncryptionContext,
@@ -105,6 +106,13 @@ export class CipherService implements CipherServiceAbstraction {
*/
private clearCipherViewsForUser$: Subject<UserId> = new Subject<UserId>();
/**
* Observable exposing the feature flag status for using the SDK for cipher CRUD operations.
*/
private readonly sdkCipherCrudEnabled$: Observable<boolean> = this.configService.getFeatureFlag$(
FeatureFlag.PM27632_SdkCipherCrudOperations,
);
constructor(
private keyService: KeyService,
private domainSettingsService: DomainSettingsService,
@@ -120,6 +128,7 @@ export class CipherService implements CipherServiceAbstraction {
private logService: LogService,
private cipherEncryptionService: CipherEncryptionService,
private messageSender: MessageSender,
private cipherSdkService: CipherSdkService,
) {}
localData$(userId: UserId): Observable<Record<CipherId, LocalData>> {
@@ -903,6 +912,43 @@ export class CipherService implements CipherServiceAbstraction {
}
async createWithServer(
cipherView: CipherView,
userId: UserId,
orgAdmin?: boolean,
): Promise<CipherView> {
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
if (useSdk) {
return (
(await this.createWithServerUsingSdk(cipherView, userId, orgAdmin)) || new CipherView()
);
}
const encrypted = await this.encrypt(cipherView, userId);
const result = await this.createWithServer_legacy(encrypted, orgAdmin);
return await this.decrypt(result, userId);
}
private async createWithServerUsingSdk(
cipherView: CipherView,
userId: UserId,
orgAdmin?: boolean,
): Promise<CipherView | void> {
// Clear the cache before creating the cipher. The SDK internally updates the encrypted storage
// but the timing of the storage emitting the new values differs across platforms. Clearing the cache after
// `createWithServer` can cause race conditions where the cache is cleared after the
// encrypted storage has already been updated and thus downstream consumers not getting updated data.
await this.clearCache(userId);
const resultCipherView = await this.cipherSdkService.createWithServer(
cipherView,
userId,
orgAdmin,
);
return resultCipherView;
}
private async createWithServer_legacy(
{ cipher, encryptedFor }: EncryptionContext,
orgAdmin?: boolean,
): Promise<Cipher> {
@@ -929,6 +975,45 @@ export class CipherService implements CipherServiceAbstraction {
}
async updateWithServer(
cipherView: CipherView,
userId: UserId,
originalCipherView?: CipherView,
orgAdmin?: boolean,
): Promise<CipherView> {
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
if (useSdk) {
return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin);
}
const encrypted = await this.encrypt(cipherView, userId);
const updatedCipher = await this.updateWithServer_legacy(encrypted, orgAdmin);
const updatedCipherView = await this.decrypt(updatedCipher, userId);
return updatedCipherView;
}
async updateWithServerUsingSdk(
cipher: CipherView,
userId: UserId,
originalCipherView?: CipherView,
orgAdmin?: boolean,
): Promise<CipherView> {
// Clear the cache before updating the cipher. The SDK internally updates the encrypted storage
// but the timing of the storage emitting the new values differs across platforms. Clearing the cache after
// `updateWithServer` can cause race conditions where the cache is cleared after the
// encrypted storage has already been updated and thus downstream consumers not getting updated data.
await this.clearCache(userId);
const resultCipherView = await this.cipherSdkService.updateWithServer(
cipher,
userId,
originalCipherView,
orgAdmin,
);
return resultCipherView;
}
async updateWithServer_legacy(
{ cipher, encryptedFor }: EncryptionContext,
orgAdmin?: boolean,
): Promise<Cipher> {
@@ -1119,8 +1204,7 @@ export class CipherService implements CipherServiceAbstraction {
//in order to keep item and it's attachments with the same encryption level
if (cipher.key != null && !cipherKeyEncryptionEnabled) {
const model = await this.decrypt(cipher, userId);
const reEncrypted = await this.encrypt(model, userId);
await this.updateWithServer(reEncrypted);
await this.updateWithServer(model, userId);
}
const encFileName = await this.encryptService.encryptString(filename, cipherEncKey);
@@ -1318,7 +1402,14 @@ export class CipherService implements CipherServiceAbstraction {
await this.encryptedCiphersState(userId).update(() => ciphers);
}
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
if (useSdk) {
await this.cipherSdkService.deleteWithServer(id, userId, asAdmin);
await this.clearCache(userId);
return;
}
if (asAdmin) {
await this.apiService.deleteCipherAdmin(id);
} else {
@@ -1328,8 +1419,20 @@ export class CipherService implements CipherServiceAbstraction {
await this.delete(id, userId);
}
async deleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise<any> {
const request = new CipherBulkDeleteRequest(ids);
async deleteManyWithServer(
ids: string[],
userId: UserId,
asAdmin = false,
orgId?: OrganizationId,
): Promise<void> {
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
if (useSdk) {
await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId);
await this.clearCache(userId);
return;
}
const request = new CipherBulkDeleteRequest(ids, orgId);
if (asAdmin) {
await this.apiService.deleteManyCiphersAdmin(request);
} else {
@@ -1468,7 +1571,7 @@ export class CipherService implements CipherServiceAbstraction {
};
}
async softDelete(id: string | string[], userId: UserId): Promise<any> {
async softDelete(id: string | string[], userId: UserId): Promise<void> {
let ciphers = await firstValueFrom(this.ciphers$(userId));
if (ciphers == null) {
return;
@@ -1496,7 +1599,14 @@ export class CipherService implements CipherServiceAbstraction {
});
}
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
if (useSdk) {
await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin);
await this.clearCache(userId);
return;
}
if (asAdmin) {
await this.apiService.putDeleteCipherAdmin(id);
} else {
@@ -1506,8 +1616,20 @@ export class CipherService implements CipherServiceAbstraction {
await this.softDelete(id, userId);
}
async softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise<any> {
const request = new CipherBulkDeleteRequest(ids);
async softDeleteManyWithServer(
ids: string[],
userId: UserId,
asAdmin = false,
orgId?: OrganizationId,
): Promise<void> {
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
if (useSdk) {
await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId);
await this.clearCache(userId);
return;
}
const request = new CipherBulkDeleteRequest(ids, orgId);
if (asAdmin) {
await this.apiService.putDeleteManyCiphersAdmin(request);
} else {
@@ -1550,7 +1672,14 @@ export class CipherService implements CipherServiceAbstraction {
});
}
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
if (useSdk) {
await this.cipherSdkService.restoreWithServer(id, userId, asAdmin);
await this.clearCache(userId);
return;
}
let response;
if (asAdmin) {
response = await this.apiService.putRestoreCipherAdmin(id);
@@ -1566,6 +1695,13 @@ export class CipherService implements CipherServiceAbstraction {
* The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore
*/
async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void> {
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
if (useSdk) {
await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId);
await this.clearCache(userId);
return;
}
let response;
if (orgId) {

View File

@@ -91,21 +91,11 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
const response = new ListResponse(r, CipherResponse);
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
// prevent mutating ciphers$ state
const localCiphers = structuredClone(currentCiphers);
const responseDataArray = response.data.map(
(cipher) => new CipherData(cipher, currentCiphers[cipher.id as CipherId]?.collectionIds),
);
for (const cipher of response.data) {
const localCipher = localCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
}
localCipher.archivedDate = cipher.archivedDate;
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.upsert(Object.values(localCiphers), userId);
await this.cipherService.upsert(responseDataArray, userId);
return response.data[0];
}
@@ -115,21 +105,11 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
const response = new ListResponse(r, CipherResponse);
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
// prevent mutating ciphers$ state
const localCiphers = structuredClone(currentCiphers);
const responseDataArray = response.data.map(
(cipher) => new CipherData(cipher, currentCiphers[cipher.id as CipherId]?.collectionIds),
);
for (const cipher of response.data) {
const localCipher = localCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
}
localCipher.archivedDate = cipher.archivedDate;
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.upsert(Object.values(localCiphers), userId);
await this.cipherService.upsert(responseDataArray, userId);
return response.data[0];
}
}