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:
@@ -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: (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"])],
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
109
libs/common/src/vault/abstractions/cipher-sdk.service.ts
Normal file
109
libs/common/src/vault/abstractions/cipher-sdk.service.ts
Normal 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>;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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$}
|
||||
*/
|
||||
|
||||
534
libs/common/src/vault/services/cipher-sdk.service.spec.ts
Normal file
534
libs/common/src/vault/services/cipher-sdk.service.spec.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
263
libs/common/src/vault/services/cipher-sdk.service.ts
Normal file
263
libs/common/src/vault/services/cipher-sdk.service.ts
Normal 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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user