1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-28 15:23:53 +00:00

[PM-30301][PM-30302] Use SDK for Create and Update cipher operations (#18149)

* Migrate create and edit operations to use SDK for ciphers

* WIP: Adds admin call to edit ciphers with SDK

* Add client version to SDK intialization settings

* Remove console.log statements

* Adds originalCipherId and collectionIds to updateCipher

* Update tests for new cipehrService interfaces

* Rename SdkCipherOperations feature flag

* Add call to Admin edit SDK if flag is passed

* Add tests for SDK path

* Revert changes to .npmrc

* Remove outdated comments

* Fix feature flag name

* Fix UUID format in cipher.service.spec.ts

* Update calls to cipherService.updateWithServer and .createWithServer to new interface

* Update CLI and Desktop to use new cipherSErvice interfaces

* Fix tests for new cipherService interface change

* Bump sdk-internal and commercial-sdk-internal versions to 0.2.0-main.439

* Fix linting errors

* Fix typescript errors impacted by this chnage

* Fix caching issue on browser extension when using SDK cipher ops.

* Remove commented code

* Fix bug causing race condition due to not consuming / awaiting observable.

* Add missing 'await' to decrypt call

* Clean up unnecessary else statements and fix function naming

* Add comments for this.clearCache

* Add tests for SDK CipherView conversion functions

* Replace sdkservice with cipher-sdk.service

* Fix import issues in browser

* Fix import issues in cli

* Fix type issues

* Fix type issues

* Fix type issues

* Fix test that fails sporadically due to timing issue
This commit is contained in:
Nik Gilmore
2026-01-26 11:43:35 -08:00
committed by GitHub
parent 87555eaabd
commit 06c8c7316d
28 changed files with 1126 additions and 125 deletions

View File

@@ -767,7 +767,6 @@ describe("NotificationBackground", () => {
let createWithServerSpy: jest.SpyInstance;
let updateWithServerSpy: jest.SpyInstance;
let folderExistsSpy: jest.SpyInstance;
let cipherEncryptSpy: jest.SpyInstance;
beforeEach(() => {
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
@@ -791,7 +790,6 @@ describe("NotificationBackground", () => {
createWithServerSpy = jest.spyOn(cipherService, "createWithServer");
updateWithServerSpy = jest.spyOn(cipherService, "updateWithServer");
folderExistsSpy = jest.spyOn(notificationBackground as any, "folderExists");
cipherEncryptSpy = jest.spyOn(cipherService, "encrypt");
accountService.activeAccount$ = activeAccountSubject;
});
@@ -1190,13 +1188,7 @@ describe("NotificationBackground", () => {
folderExistsSpy.mockResolvedValueOnce(false);
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
editItemSpy.mockResolvedValueOnce(undefined);
cipherEncryptSpy.mockResolvedValueOnce({
cipher: {
...cipherView,
id: "testId",
},
encryptedFor: userId,
});
createWithServerSpy.mockResolvedValueOnce(cipherView);
sendMockExtensionMessage(message, sender);
await flushPromises();
@@ -1205,7 +1197,6 @@ describe("NotificationBackground", () => {
queueMessage,
null,
);
expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId");
expect(createWithServerSpy).toHaveBeenCalled();
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
sender.tab,
@@ -1241,13 +1232,6 @@ describe("NotificationBackground", () => {
folderExistsSpy.mockResolvedValueOnce(true);
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
editItemSpy.mockResolvedValueOnce(undefined);
cipherEncryptSpy.mockResolvedValueOnce({
cipher: {
...cipherView,
id: "testId",
},
encryptedFor: userId,
});
const errorMessage = "fetch error";
createWithServerSpy.mockImplementation(() => {
throw new Error(errorMessage);
@@ -1256,7 +1240,6 @@ describe("NotificationBackground", () => {
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId");
expect(createWithServerSpy).toThrow(errorMessage);
expect(tabSendMessageSpy).not.toHaveBeenCalledWith(sender.tab, {
command: "addedCipher",

View File

@@ -866,13 +866,11 @@ export default class NotificationBackground {
return;
}
const encrypted = await this.cipherService.encrypt(newCipher, activeUserId);
const { cipher } = encrypted;
try {
await this.cipherService.createWithServer(encrypted);
const resultCipher = await this.cipherService.createWithServer(newCipher, activeUserId);
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
itemName: newCipher?.name && String(newCipher?.name),
cipherId: cipher?.id && String(cipher?.id),
cipherId: resultCipher?.id && String(resultCipher?.id),
});
await BrowserApi.tabSendMessage(tab, { command: "addedCipher" });
} catch (error) {
@@ -910,7 +908,6 @@ export default class NotificationBackground {
await BrowserApi.tabSendMessage(tab, { command: "editedCipher" });
return;
}
const cipher = await this.cipherService.encrypt(cipherView, userId);
try {
if (!cipherView.edit) {
@@ -939,7 +936,7 @@ export default class NotificationBackground {
return;
}
await this.cipherService.updateWithServer(cipher);
await this.cipherService.updateWithServer(cipherView, userId);
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
itemName: cipherView?.name && String(cipherView?.name),

View File

@@ -444,10 +444,9 @@ export class Fido2Component implements OnInit, OnDestroy {
);
this.buildCipher(name, username);
const encrypted = await this.cipherService.encrypt(this.cipher, activeUserId);
try {
await this.cipherService.createWithServer(encrypted);
this.cipher.id = encrypted.cipher.id;
const result = await this.cipherService.createWithServer(this.cipher, activeUserId);
this.cipher.id = result.id;
} catch (e) {
this.logService.error(e);
}

View File

@@ -194,6 +194,7 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service"
import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
@@ -211,6 +212,7 @@ import {
CipherAuthorizationService,
DefaultCipherAuthorizationService,
} from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
@@ -367,6 +369,7 @@ export default class MainBackground {
apiService: ApiServiceAbstraction;
hibpApiService: HibpApiService;
environmentService: BrowserEnvironmentService;
cipherSdkService: CipherSdkService;
cipherService: CipherServiceAbstraction;
folderService: InternalFolderServiceAbstraction;
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction;
@@ -973,6 +976,8 @@ export default class MainBackground {
this.logService,
);
this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService);
this.cipherService = new CipherService(
this.keyService,
this.domainSettingsService,
@@ -988,6 +993,7 @@ export default class MainBackground {
this.logService,
this.cipherEncryptionService,
this.messagingService,
this.cipherSdkService,
);
this.folderService = new FolderService(
this.keyService,

View File

@@ -277,8 +277,7 @@ export class ItemMoreOptionsComponent {
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
)) as UserId;
const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId);
await this.cipherService.updateWithServer(encryptedCipher);
await this.cipherService.updateWithServer(cipher, activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(

View File

@@ -378,8 +378,7 @@ describe("VaultPopupAutofillService", () => {
expect(result).toBe(true);
expect(mockCipher.login.uris).toHaveLength(1);
expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url);
expect(mockCipherService.encrypt).toHaveBeenCalledWith(mockCipher, mockUserId);
expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockEncryptedCipher);
expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockCipher, mockUserId);
});
it("should add a URI to the cipher when there are no existing URIs", async () => {

View File

@@ -426,8 +426,7 @@ export class VaultPopupAutofillService {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
await this.cipherService.updateWithServer(encCipher);
await this.cipherService.updateWithServer(cipher, activeUserId);
this.messagingService.send("editedCipher");
return true;
} catch {

View File

@@ -138,10 +138,8 @@ export class EditCommand {
);
}
const encCipher = await this.cipherService.encrypt(cipherView, activeUserId);
try {
const updatedCipher = await this.cipherService.updateWithServer(encCipher);
const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId);
const decCipher = await this.cipherService.updateWithServer(cipherView, activeUserId);
const res = new CipherResponse(decCipher);
return Response.success(res);
} catch (e) {

View File

@@ -147,11 +147,13 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service"
import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import {
CipherAuthorizationService,
DefaultCipherAuthorizationService,
} from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
@@ -254,6 +256,7 @@ export class ServiceContainer {
twoFactorApiService: TwoFactorApiService;
hibpApiService: HibpApiService;
environmentService: EnvironmentService;
cipherSdkService: CipherSdkService;
cipherService: CipherService;
folderService: InternalFolderService;
organizationUserApiService: OrganizationUserApiService;
@@ -794,6 +797,8 @@ export class ServiceContainer {
this.logService,
);
this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService);
this.cipherService = new CipherService(
this.keyService,
this.domainSettingsService,
@@ -809,6 +814,7 @@ export class ServiceContainer {
this.logService,
this.cipherEncryptionService,
this.messagingService,
this.cipherSdkService,
);
this.cipherArchiveService = new DefaultCipherArchiveService(

View File

@@ -103,10 +103,11 @@ export class CreateCommand {
return Response.error("Creating this item type is restricted by organizational policy.");
}
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
const newCipher = await this.cipherService.createWithServer(cipher);
const decCipher = await this.cipherService.decrypt(newCipher, activeUserId);
const res = new CipherResponse(decCipher);
const newCipher = await this.cipherService.createWithServer(
CipherExport.toView(req),
activeUserId,
);
const res = new CipherResponse(newCipher);
return Response.success(res);
} catch (e) {
return Response.error(e);

View File

@@ -299,12 +299,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
throw new Error("No active user ID found!");
}
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
try {
const createdCipher = await this.cipherService.createWithServer(encCipher);
const createdCipher = await this.cipherService.createWithServer(cipher, activeUserId);
const encryptedCreatedCipher = await this.cipherService.encrypt(createdCipher, activeUserId);
return createdCipher;
return encryptedCreatedCipher.cipher;
} catch {
throw new Error("Unable to create cipher");
}
@@ -316,8 +315,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
this.accountService.activeAccount$.pipe(
map(async (a) => {
if (a) {
const encCipher = await this.cipherService.encrypt(cipher, a.id);
await this.cipherService.updateWithServer(encCipher);
await this.cipherService.updateWithServer(cipher, a.id);
}
}),
),

View File

@@ -166,8 +166,7 @@ export class EncryptedMessageHandlerService {
try {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const encrypted = await this.cipherService.encrypt(cipherView, activeUserId);
await this.cipherService.createWithServer(encrypted);
await this.cipherService.createWithServer(cipherView, activeUserId);
// Notify other clients of new login
await this.messagingService.send("addedCipher");
@@ -212,9 +211,8 @@ export class EncryptedMessageHandlerService {
cipherView.login.password = credentialUpdatePayload.password;
cipherView.login.username = credentialUpdatePayload.userName;
cipherView.login.uris[0].uri = credentialUpdatePayload.uri;
const encrypted = await this.cipherService.encrypt(cipherView, activeUserId);
await this.cipherService.updateWithServer(encrypted);
await this.cipherService.updateWithServer(cipherView, activeUserId);
// Notify other clients of update
await this.messagingService.send("editedCipher");

View File

@@ -1536,8 +1536,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherFullView = await this.cipherService.getFullCipherView(cipher);
cipherFullView.favorite = !cipherFullView.favorite;
const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId);
await this.cipherService.updateWithServer(encryptedCipher);
await this.cipherService.updateWithServer(cipherFullView, activeUserId);
this.toastService.showToast({
variant: "success",

View File

@@ -303,6 +303,7 @@ import {
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
@@ -321,6 +322,7 @@ import {
CipherAuthorizationService,
DefaultCipherAuthorizationService,
} from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
@@ -590,6 +592,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultDomainSettingsService,
deps: [StateProvider, PolicyServiceAbstraction, AccountService],
}),
safeProvider({
provide: CipherSdkService,
useClass: DefaultCipherSdkService,
deps: [SdkService, LogService],
}),
safeProvider({
provide: CipherServiceAbstraction,
useFactory: (
@@ -607,6 +614,7 @@ const safeProviders: SafeProvider[] = [
logService: LogService,
cipherEncryptionService: CipherEncryptionService,
messagingService: MessagingServiceAbstraction,
cipherSdkService: CipherSdkService,
) =>
new CipherService(
keyService,
@@ -623,6 +631,7 @@ const safeProviders: SafeProvider[] = [
logService,
cipherEncryptionService,
messagingService,
cipherSdkService,
),
deps: [
KeyService,
@@ -639,6 +648,7 @@ const safeProviders: SafeProvider[] = [
LogService,
CipherEncryptionService,
MessagingServiceAbstraction,
CipherSdkService,
],
}),
safeProvider({

View File

@@ -68,6 +68,7 @@ export enum FeatureFlag {
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",
@@ -130,6 +131,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */

View File

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

View File

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

View File

@@ -80,7 +80,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,
@@ -210,7 +210,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,
@@ -322,11 +322,12 @@ export class DefaultSdkService implements SdkService {
client.platform().load_flags(featureFlagMap);
}
private toSettings(env: Environment): ClientSettings {
private async toSettings(env: Environment): Promise<ClientSettings> {
return {
apiUrl: env.getApiUrl(),
identityUrl: env.getIdentityUrl(),
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(),
userAgent: this.userAgent ?? navigator.userAgent,
};
}

View File

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

View File

@@ -0,0 +1,37 @@
import { 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>;
}

View File

@@ -119,9 +119,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* @returns A promise that resolves to the created cipher
*/
abstract createWithServer(
{ cipher, encryptedFor }: EncryptionContext,
cipherView: CipherView,
userId: UserId,
orgAdmin?: boolean,
): Promise<Cipher>;
): Promise<CipherView>;
/**
* Update a cipher with the server
* @param cipher The cipher to update
@@ -131,10 +133,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* @returns A promise that resolves to the updated cipher
*/
abstract updateWithServer(
{ cipher, encryptedFor }: EncryptionContext,
cipherView: CipherView,
userId: UserId,
originalCipherView?: CipherView,
orgAdmin?: boolean,
isNotClone?: boolean,
): Promise<Cipher>;
): Promise<CipherView>;
/**
* Move a cipher to an organization by re-encrypting its keys with the organization's key.

View File

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

View File

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

View File

@@ -0,0 +1,246 @@
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(),
};
mockCiphersSdk = {
create: jest.fn(),
edit: jest.fn(),
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"),
);
});
});
});

View File

@@ -0,0 +1,82 @@
import { firstValueFrom, switchMap, catchError } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { 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;
}),
),
);
}
}

View File

@@ -28,6 +28,7 @@ import { ContainerService } from "../../platform/services/container.service";
import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid";
import { CipherKey, OrgKey, UserKey } from "../../types/key";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
import { EncryptionContext } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { SearchService } from "../abstractions/search.service";
@@ -54,9 +55,9 @@ function encryptText(clearText: string | Uint8Array) {
const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
const cipherData: CipherData = {
id: "id",
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId,
folderId: "folderId",
id: "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId,
folderId: "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23",
edit: true,
viewPassword: true,
organizationUseTotp: true,
@@ -109,9 +110,10 @@ 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;
@@ -145,6 +147,7 @@ describe("Cipher Service", () => {
logService,
cipherEncryptionService,
messageSender,
cipherSdkService,
);
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
@@ -207,11 +210,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 +233,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 +249,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 +265,86 @@ 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 () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(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)
.mockResolvedValue(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 +352,79 @@ 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 () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(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 () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(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", () => {

View File

@@ -42,6 +42,7 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid
import { OrgKey, UserKey } from "../../types/key";
import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
import {
CipherService as CipherServiceAbstraction,
EncryptionContext,
@@ -120,6 +121,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 +905,40 @@ export class CipherService implements CipherServiceAbstraction {
}
async createWithServer(
cipherView: CipherView,
userId: UserId,
orgAdmin?: boolean,
): Promise<CipherView> {
const useSdk = await this.configService.getFeatureFlag(
FeatureFlag.PM27632_SdkCipherCrudOperations,
);
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> {
const resultCipherView = await this.cipherSdkService.createWithServer(
cipherView,
userId,
orgAdmin,
);
await this.clearCache(userId);
return resultCipherView;
}
private async createWithServer_legacy(
{ cipher, encryptedFor }: EncryptionContext,
orgAdmin?: boolean,
): Promise<Cipher> {
@@ -929,6 +965,42 @@ export class CipherService implements CipherServiceAbstraction {
}
async updateWithServer(
cipherView: CipherView,
userId: UserId,
originalCipherView?: CipherView,
orgAdmin?: boolean,
): Promise<CipherView> {
const useSdk = await this.configService.getFeatureFlag(
FeatureFlag.PM27632_SdkCipherCrudOperations,
);
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> {
const resultCipherView = await this.cipherSdkService.updateWithServer(
cipher,
userId,
originalCipherView,
orgAdmin,
);
await this.clearCache(userId);
return resultCipherView;
}
async updateWithServer_legacy(
{ cipher, encryptedFor }: EncryptionContext,
orgAdmin?: boolean,
): Promise<Cipher> {
@@ -1119,8 +1191,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);

View File

@@ -37,14 +37,13 @@ export class DefaultCipherFormService implements CipherFormService {
// Creating a new cipher
if (cipher.id == null || cipher.id === "") {
const encrypted = await this.cipherService.encrypt(cipher, activeUserId);
savedCipher = await this.cipherService.createWithServer(encrypted, config.admin);
return await this.cipherService.decrypt(savedCipher, activeUserId);
return await this.cipherService.createWithServer(cipher, activeUserId, config.admin);
}
if (config.originalCipher == null) {
throw new Error("Original cipher is required for updating an existing cipher");
}
const originalCipherView = await this.decryptCipher(config.originalCipher);
// Updating an existing cipher
@@ -66,35 +65,31 @@ export class DefaultCipherFormService implements CipherFormService {
);
// If the collectionIds are the same, update the cipher normally
} else if (isSetEqual(originalCollectionIds, newCollectionIds)) {
const encrypted = await this.cipherService.encrypt(
const savedCipherView = await this.cipherService.updateWithServer(
cipher,
activeUserId,
null,
null,
config.originalCipher,
originalCipherView,
config.admin,
);
savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin);
savedCipher = await this.cipherService
.encrypt(savedCipherView, activeUserId)
.then((res) => res.cipher);
} else {
const encrypted = await this.cipherService.encrypt(
cipher,
activeUserId,
null,
null,
config.originalCipher,
);
const encryptedCipher = encrypted.cipher;
// Updating a cipher with collection changes is not supported with a single request currently
// First update the cipher with the original collectionIds
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
await this.cipherService.updateWithServer(
encrypted,
cipher.collectionIds = config.originalCipher.collectionIds;
const newCipher = await this.cipherService.updateWithServer(
cipher,
activeUserId,
originalCipherView,
config.admin || originalCollectionIds.size === 0,
);
// Then save the new collection changes separately
encryptedCipher.collectionIds = cipher.collectionIds;
newCipher.collectionIds = cipher.collectionIds;
// TODO: Remove after migrating all SDK ops
const { cipher: encryptedCipher } = await this.cipherService.encrypt(newCipher, activeUserId);
if (config.admin || originalCollectionIds.size === 0) {
// When using an admin config or the cipher was unassigned, update collections as an admin
savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher);