diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1af2ab1f0a..8e9dc6fd35 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -54,6 +54,7 @@ export enum FeatureFlag { PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", 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", EndUserNotifications = "pm-10609-end-user-notifications", RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", @@ -103,6 +104,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.RemoveCardItemTypePolicy]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM19315EndUserActivationMvp]: FALSE, + [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, /* Auth */ [FeatureFlag.PM16117_SetInitialPasswordRefactor]: FALSE, diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index 6b2a8e8943..067c63b211 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -1,6 +1,7 @@ +import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherListView } from "@bitwarden/sdk-internal"; -import { UserId } from "../../types/guid"; +import { UserId, OrganizationId } from "../../types/guid"; import { Cipher } from "../models/domain/cipher"; import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; @@ -9,6 +10,28 @@ import { CipherView } from "../models/view/cipher.view"; * Service responsible for encrypting and decrypting ciphers. */ export abstract class CipherEncryptionService { + /** + * Encrypts a cipher using the SDK for the given userId. + * @param model The cipher view to encrypt + * @param userId The user ID to initialize the SDK client with + * + * @returns A promise that resolves to the encryption context, or undefined if encryption fails + */ + abstract encrypt(model: CipherView, userId: UserId): Promise; + + /** + * Move the cipher to the specified organization by re-encrypting its keys with the organization's key. + * The cipher.organizationId will be updated to the new organizationId. + * @param model The cipher view to move to the organization + * @param organizationId The ID of the organization to move the cipher to + * @param userId The user ID to initialize the SDK client with + */ + abstract moveToOrganization( + model: CipherView, + organizationId: OrganizationId, + userId: UserId, + ): Promise; + /** * Decrypts a cipher using the SDK for the given userId. * diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index d1d686a66a..2f18636946 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -120,11 +120,21 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + + /** + * Move a cipher to an organization by re-encrypting its keys with the organization's key. + * @param cipher The cipher to move + * @param organizationId The Id of the organization to move the cipher to + * @param collectionIds The collection Ids to assign the cipher to in the organization + * @param userId The Id of the user performing the operation + * @param originalCipher Optional original cipher that will be used to compare/update password history + */ abstract shareWithServer( cipher: CipherView, organizationId: string, collectionIds: string[], userId: UserId, + originalCipher?: Cipher, ): Promise; abstract shareManyWithServer( ciphers: CipherView[], diff --git a/libs/common/src/vault/models/api/cipher-permissions.api.ts b/libs/common/src/vault/models/api/cipher-permissions.api.ts index b7341d39b1..f9b62c4fc8 100644 --- a/libs/common/src/vault/models/api/cipher-permissions.api.ts +++ b/libs/common/src/vault/models/api/cipher-permissions.api.ts @@ -4,7 +4,7 @@ import { CipherPermissions as SdkCipherPermissions } from "@bitwarden/sdk-intern import { BaseResponse } from "../../../models/response/base.response"; -export class CipherPermissionsApi extends BaseResponse { +export class CipherPermissionsApi extends BaseResponse implements SdkCipherPermissions { delete: boolean = false; restore: boolean = false; @@ -35,4 +35,11 @@ export class CipherPermissionsApi extends BaseResponse { return permissions; } + + /** + * Converts the CipherPermissionsApi to an SdkCipherPermissions + */ + toSdkCipherPermissions(): SdkCipherPermissions { + return this; + } } diff --git a/libs/common/src/vault/models/data/local.data.ts b/libs/common/src/vault/models/data/local.data.ts index 9ba820a58a..50a24feba6 100644 --- a/libs/common/src/vault/models/data/local.data.ts +++ b/libs/common/src/vault/models/data/local.data.ts @@ -1,4 +1,45 @@ +import { + LocalDataView as SdkLocalDataView, + LocalData as SdkLocalData, +} from "@bitwarden/sdk-internal"; + export type LocalData = { lastUsedDate?: number; lastLaunched?: number; }; + +/** + * Convert the SdkLocalDataView to LocalData + * @param localData + */ +export function fromSdkLocalData( + localData: SdkLocalDataView | SdkLocalData | undefined, +): LocalData | undefined { + if (localData == null) { + return undefined; + } + return { + lastUsedDate: localData.lastUsedDate ? new Date(localData.lastUsedDate).getTime() : undefined, + lastLaunched: localData.lastLaunched ? new Date(localData.lastLaunched).getTime() : undefined, + }; +} + +/** + * Convert the LocalData to SdkLocalData + * @param localData + */ +export function toSdkLocalData( + localData: LocalData | undefined, +): (SdkLocalDataView & SdkLocalData) | undefined { + if (localData == null) { + return undefined; + } + return { + lastUsedDate: localData.lastUsedDate + ? new Date(localData.lastUsedDate).toISOString() + : undefined, + lastLaunched: localData.lastLaunched + ? new Date(localData.lastLaunched).toISOString() + : undefined, + }; +} diff --git a/libs/common/src/vault/models/domain/attachment.spec.ts b/libs/common/src/vault/models/domain/attachment.spec.ts index d2b536b059..2ea2c3d9a1 100644 --- a/libs/common/src/vault/models/domain/attachment.spec.ts +++ b/libs/common/src/vault/models/domain/attachment.spec.ts @@ -93,6 +93,7 @@ describe("Attachment", () => { sizeName: "1.1 KB", fileName: "fileName", key: expect.any(SymmetricCryptoKey), + encryptedKey: attachment.key, }); }); diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index abfebffb2e..638f354c4b 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -56,6 +56,7 @@ export class Attachment extends Domain { if (this.key != null) { view.key = await this.decryptAttachmentKey(orgId, encKey); + view.encryptedKey = this.key; // Keep the encrypted key for the view } return view; @@ -131,4 +132,24 @@ export class Attachment extends Domain { key: this.key?.toJSON(), }; } + + /** + * Maps an SDK Attachment object to an Attachment + * @param obj - The SDK attachment object + */ + static fromSdkAttachment(obj: SdkAttachment): Attachment | undefined { + if (!obj) { + return undefined; + } + + const attachment = new Attachment(); + attachment.id = obj.id; + attachment.url = obj.url; + attachment.size = obj.size; + attachment.sizeName = obj.sizeName; + attachment.fileName = EncString.fromJSON(obj.fileName); + attachment.key = EncString.fromJSON(obj.key); + + return attachment; + } } diff --git a/libs/common/src/vault/models/domain/card.ts b/libs/common/src/vault/models/domain/card.ts index c78f9dfb71..688053ae93 100644 --- a/libs/common/src/vault/models/domain/card.ts +++ b/libs/common/src/vault/models/domain/card.ts @@ -103,4 +103,24 @@ export class Card extends Domain { code: this.code?.toJSON(), }; } + + /** + * Maps an SDK Card object to a Card + * @param obj - The SDK Card object + */ + static fromSdkCard(obj: SdkCard): Card | undefined { + if (obj == null) { + return undefined; + } + + const card = new Card(); + card.cardholderName = EncString.fromJSON(obj.cardholderName); + card.brand = EncString.fromJSON(obj.brand); + card.number = EncString.fromJSON(obj.number); + card.expMonth = EncString.fromJSON(obj.expMonth); + card.expYear = EncString.fromJSON(obj.expYear); + card.code = EncString.fromJSON(obj.code); + + return card; + } } diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index 3ea8916a10..60fff8b510 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -10,6 +10,7 @@ import { UriMatchType, CipherRepromptType as SdkCipherRepromptType, LoginLinkedIdType, + Cipher as SdkCipher, } from "@bitwarden/sdk-internal"; import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils"; @@ -206,7 +207,7 @@ describe("Cipher DTO", () => { it("Convert", () => { const cipher = new Cipher(cipherData); - expect(cipher).toEqual({ + expect(cipher).toMatchObject({ initializerKey: InitializerKey.Cipher, id: "id", organizationId: "orgId", @@ -339,9 +340,9 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, login: loginView, - attachments: null, - fields: null, - passwordHistory: null, + attachments: [], + fields: [], + passwordHistory: [], collectionIds: undefined, revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), @@ -462,9 +463,9 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, secureNote: { type: 0 }, - attachments: null, - fields: null, - passwordHistory: null, + attachments: [], + fields: [], + passwordHistory: [], collectionIds: undefined, revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), @@ -603,9 +604,9 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, card: cardView, - attachments: null, - fields: null, - passwordHistory: null, + attachments: [], + fields: [], + passwordHistory: [], collectionIds: undefined, revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), @@ -768,9 +769,9 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, identity: identityView, - attachments: null, - fields: null, - passwordHistory: null, + attachments: [], + fields: [], + passwordHistory: [], collectionIds: undefined, revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), @@ -1001,6 +1002,167 @@ describe("Cipher DTO", () => { revisionDate: "2022-01-31T12:00:00.000Z", }); }); + + it("should map from SDK Cipher", () => { + jest.restoreAllMocks(); + const sdkCipher: SdkCipher = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + collectionIds: [], + key: "EncryptedString", + name: "EncryptedString", + notes: "EncryptedString", + type: SdkCipherType.Login, + login: { + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + uris: [ + { + uri: "EncryptedString", + uriChecksum: "EncryptedString", + match: UriMatchType.Domain, + }, + ], + totp: "EncryptedString", + autofillOnPageLoad: false, + fido2Credentials: undefined, + }, + identity: undefined, + card: undefined, + secureNote: undefined, + sshKey: undefined, + favorite: false, + reprompt: SdkCipherRepromptType.None, + organizationUseTotp: true, + edit: true, + permissions: new CipherPermissionsApi(), + viewPassword: true, + localData: { + lastUsedDate: "2025-04-15T12:00:00.000Z", + lastLaunched: "2025-04-15T12:00:00.000Z", + }, + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], + fields: [ + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedIdType.Username, + }, + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedIdType.Password, + }, + ], + passwordHistory: [ + { + password: "EncryptedString", + lastUsedDate: "2022-01-31T12:00:00.000Z", + }, + ], + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: undefined, + revisionDate: "2022-01-31T12:00:00.000Z", + }; + + const lastUsedDate = new Date("2025-04-15T12:00:00.000Z").getTime(); + const lastLaunched = new Date("2025-04-15T12:00:00.000Z").getTime(); + + const cipherData: CipherData = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + edit: true, + permissions: new CipherPermissionsApi(), + collectionIds: [], + viewPassword: true, + organizationUseTotp: true, + favorite: false, + revisionDate: "2022-01-31T12:00:00.000Z", + type: CipherType.Login, + name: "EncryptedString", + notes: "EncryptedString", + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: null, + reprompt: CipherRepromptType.None, + key: "EncryptedString", + login: { + uris: [ + { + uri: "EncryptedString", + uriChecksum: "EncryptedString", + match: UriMatchStrategy.Domain, + }, + ], + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + totp: "EncryptedString", + autofillOnPageLoad: false, + }, + passwordHistory: [ + { password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }, + ], + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], + fields: [ + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedId.Username, + }, + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedId.Password, + }, + ], + }; + const expectedCipher = new Cipher(cipherData, { lastUsedDate, lastLaunched }); + + const cipher = Cipher.fromSdkCipher(sdkCipher); + + expect(cipher).toEqual(expectedCipher); + }); }); }); diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 2f64fb8272..2a13cb06d7 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { uuidToString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Cipher as SdkCipher } from "@bitwarden/sdk-internal"; import { EncString } from "../../../key-management/crypto/models/enc-string"; @@ -14,7 +15,7 @@ import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherPermissionsApi } from "../api/cipher-permissions.api"; import { CipherData } from "../data/cipher.data"; -import { LocalData } from "../data/local.data"; +import { LocalData, fromSdkLocalData, toSdkLocalData } from "../data/local.data"; import { AttachmentView } from "../view/attachment.view"; import { CipherView } from "../view/cipher.view"; import { FieldView } from "../view/field.view"; @@ -361,16 +362,7 @@ export class Cipher extends Domain implements Decryptable { } : undefined, viewPassword: this.viewPassword ?? true, - localData: this.localData - ? { - lastUsedDate: this.localData.lastUsedDate - ? new Date(this.localData.lastUsedDate).toISOString() - : undefined, - lastLaunched: this.localData.lastLaunched - ? new Date(this.localData.lastLaunched).toISOString() - : undefined, - } - : undefined, + localData: toSdkLocalData(this.localData), attachments: this.attachments?.map((a) => a.toSdkAttachment()), fields: this.fields?.map((f) => f.toSdkField()), passwordHistory: this.passwordHistory?.map((ph) => ph.toSdkPasswordHistory()), @@ -408,4 +400,50 @@ export class Cipher extends Domain implements Decryptable { return sdkCipher; } + + /** + * Maps an SDK Cipher object to a Cipher + * @param sdkCipher - The SDK Cipher object + */ + static fromSdkCipher(sdkCipher: SdkCipher | null): Cipher | undefined { + if (sdkCipher == null) { + return undefined; + } + + const cipher = new Cipher(); + + cipher.id = sdkCipher.id ? uuidToString(sdkCipher.id) : undefined; + cipher.organizationId = sdkCipher.organizationId + ? uuidToString(sdkCipher.organizationId) + : undefined; + cipher.folderId = sdkCipher.folderId ? uuidToString(sdkCipher.folderId) : undefined; + cipher.collectionIds = sdkCipher.collectionIds ? sdkCipher.collectionIds.map(uuidToString) : []; + cipher.key = EncString.fromJSON(sdkCipher.key); + cipher.name = EncString.fromJSON(sdkCipher.name); + cipher.notes = EncString.fromJSON(sdkCipher.notes); + cipher.type = sdkCipher.type; + cipher.favorite = sdkCipher.favorite; + cipher.organizationUseTotp = sdkCipher.organizationUseTotp; + cipher.edit = sdkCipher.edit; + cipher.permissions = CipherPermissionsApi.fromSdkCipherPermissions(sdkCipher.permissions); + cipher.viewPassword = sdkCipher.viewPassword; + cipher.localData = fromSdkLocalData(sdkCipher.localData); + cipher.attachments = sdkCipher.attachments?.map((a) => Attachment.fromSdkAttachment(a)) ?? []; + cipher.fields = sdkCipher.fields?.map((f) => Field.fromSdkField(f)) ?? []; + cipher.passwordHistory = + sdkCipher.passwordHistory?.map((ph) => Password.fromSdkPasswordHistory(ph)) ?? []; + cipher.creationDate = new Date(sdkCipher.creationDate); + cipher.revisionDate = new Date(sdkCipher.revisionDate); + cipher.deletedDate = sdkCipher.deletedDate ? new Date(sdkCipher.deletedDate) : null; + cipher.reprompt = sdkCipher.reprompt; + + // Cipher type specific properties + cipher.login = Login.fromSdkLogin(sdkCipher.login); + cipher.secureNote = SecureNote.fromSdkSecureNote(sdkCipher.secureNote); + cipher.card = Card.fromSdkCard(sdkCipher.card); + cipher.identity = Identity.fromSdkIdentity(sdkCipher.identity); + cipher.sshKey = SshKey.fromSdkSshKey(sdkCipher.sshKey); + + return cipher; + } } diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts index 508f8a6d5f..5dbf55b44f 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -173,4 +173,32 @@ export class Fido2Credential extends Domain { creationDate: this.creationDate.toISOString(), }; } + + /** + * Maps an SDK Fido2Credential object to a Fido2Credential + * @param obj - The SDK Fido2Credential object + */ + static fromSdkFido2Credential(obj: SdkFido2Credential): Fido2Credential | undefined { + if (!obj) { + return undefined; + } + + const credential = new Fido2Credential(); + + credential.credentialId = EncString.fromJSON(obj.credentialId); + credential.keyType = EncString.fromJSON(obj.keyType); + credential.keyAlgorithm = EncString.fromJSON(obj.keyAlgorithm); + credential.keyCurve = EncString.fromJSON(obj.keyCurve); + credential.keyValue = EncString.fromJSON(obj.keyValue); + credential.rpId = EncString.fromJSON(obj.rpId); + credential.userHandle = EncString.fromJSON(obj.userHandle); + credential.userName = EncString.fromJSON(obj.userName); + credential.counter = EncString.fromJSON(obj.counter); + credential.rpName = EncString.fromJSON(obj.rpName); + credential.userDisplayName = EncString.fromJSON(obj.userDisplayName); + credential.discoverable = EncString.fromJSON(obj.discoverable); + credential.creationDate = new Date(obj.creationDate); + + return credential; + } } diff --git a/libs/common/src/vault/models/domain/field.spec.ts b/libs/common/src/vault/models/domain/field.spec.ts index 08bc0da84f..b5e26199e7 100644 --- a/libs/common/src/vault/models/domain/field.spec.ts +++ b/libs/common/src/vault/models/domain/field.spec.ts @@ -1,6 +1,14 @@ +import { + Field as SdkField, + FieldType, + LoginLinkedIdType, + CardLinkedIdType, + IdentityLinkedIdType, +} from "@bitwarden/sdk-internal"; + import { mockEnc, mockFromJson } from "../../../../spec"; import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; -import { CardLinkedId, FieldType, IdentityLinkedId, LoginLinkedId } from "../../enums"; +import { CardLinkedId, IdentityLinkedId, LoginLinkedId } from "../../enums"; import { FieldData } from "../../models/data/field.data"; import { Field } from "../../models/domain/field"; @@ -103,5 +111,34 @@ describe("Field", () => { identityField.linkedId = IdentityLinkedId.LicenseNumber; expect(identityField.toSdkField().linkedId).toBe(415); }); + + it("should map from SDK Field", () => { + // Test Login LinkedId + const loginField: SdkField = { + name: undefined, + value: undefined, + type: FieldType.Linked, + linkedId: LoginLinkedIdType.Username, + }; + expect(Field.fromSdkField(loginField)!.linkedId).toBe(100); + + // Test Card LinkedId + const cardField: SdkField = { + name: undefined, + value: undefined, + type: FieldType.Linked, + linkedId: CardLinkedIdType.Number, + }; + expect(Field.fromSdkField(cardField)!.linkedId).toBe(305); + + // Test Identity LinkedId + const identityFieldSdkField: SdkField = { + name: undefined, + value: undefined, + type: FieldType.Linked, + linkedId: IdentityLinkedIdType.LicenseNumber, + }; + expect(Field.fromSdkField(identityFieldSdkField)!.linkedId).toBe(415); + }); }); }); diff --git a/libs/common/src/vault/models/domain/field.ts b/libs/common/src/vault/models/domain/field.ts index d6453932cc..53756e2104 100644 --- a/libs/common/src/vault/models/domain/field.ts +++ b/libs/common/src/vault/models/domain/field.ts @@ -90,4 +90,22 @@ export class Field extends Domain { linkedId: this.linkedId as unknown as SdkLinkedIdType, }; } + + /** + * Maps SDK Field to Field + * @param obj The SDK Field object to map + */ + static fromSdkField(obj: SdkField): Field | undefined { + if (!obj) { + return undefined; + } + + const field = new Field(); + field.name = EncString.fromJSON(obj.name); + field.value = EncString.fromJSON(obj.value); + field.type = obj.type; + field.linkedId = obj.linkedId; + + return field; + } } diff --git a/libs/common/src/vault/models/domain/identity.ts b/libs/common/src/vault/models/domain/identity.ts index 5dc752531b..16e68c7255 100644 --- a/libs/common/src/vault/models/domain/identity.ts +++ b/libs/common/src/vault/models/domain/identity.ts @@ -195,4 +195,36 @@ export class Identity extends Domain { licenseNumber: this.licenseNumber?.toJSON(), }; } + + /** + * Maps an SDK Identity object to an Identity + * @param obj - The SDK Identity object + */ + static fromSdkIdentity(obj: SdkIdentity): Identity | undefined { + if (obj == null) { + return undefined; + } + + const identity = new Identity(); + identity.title = EncString.fromJSON(obj.title); + identity.firstName = EncString.fromJSON(obj.firstName); + identity.middleName = EncString.fromJSON(obj.middleName); + identity.lastName = EncString.fromJSON(obj.lastName); + identity.address1 = EncString.fromJSON(obj.address1); + identity.address2 = EncString.fromJSON(obj.address2); + identity.address3 = EncString.fromJSON(obj.address3); + identity.city = EncString.fromJSON(obj.city); + identity.state = EncString.fromJSON(obj.state); + identity.postalCode = EncString.fromJSON(obj.postalCode); + identity.country = EncString.fromJSON(obj.country); + identity.company = EncString.fromJSON(obj.company); + identity.email = EncString.fromJSON(obj.email); + identity.phone = EncString.fromJSON(obj.phone); + identity.ssn = EncString.fromJSON(obj.ssn); + identity.username = EncString.fromJSON(obj.username); + identity.passportNumber = EncString.fromJSON(obj.passportNumber); + identity.licenseNumber = EncString.fromJSON(obj.licenseNumber); + + return identity; + } } diff --git a/libs/common/src/vault/models/domain/login-uri.ts b/libs/common/src/vault/models/domain/login-uri.ts index e7bc2e8892..9cfa4951dd 100644 --- a/libs/common/src/vault/models/domain/login-uri.ts +++ b/libs/common/src/vault/models/domain/login-uri.ts @@ -102,4 +102,17 @@ export class LoginUri extends Domain { match: this.match, }; } + + static fromSdkLoginUri(obj: SdkLoginUri): LoginUri | undefined { + if (obj == null) { + return undefined; + } + + const view = new LoginUri(); + view.uri = EncString.fromJSON(obj.uri); + view.uriChecksum = obj.uriChecksum ? EncString.fromJSON(obj.uriChecksum) : undefined; + view.match = obj.match; + + return view; + } } diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index 4d77983d4a..93af226918 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -163,4 +163,31 @@ export class Login extends Domain { fido2Credentials: this.fido2Credentials?.map((f) => f.toSdkFido2Credential()), }; } + + /** + * Maps an SDK Login object to a Login + * @param obj - The SDK Login object + */ + static fromSdkLogin(obj: SdkLogin): Login | undefined { + if (!obj) { + return undefined; + } + + const login = new Login(); + + login.uris = + obj.uris?.filter((u) => u.uri != null).map((uri) => LoginUri.fromSdkLoginUri(uri)) ?? []; + login.username = EncString.fromJSON(obj.username); + login.password = EncString.fromJSON(obj.password); + login.passwordRevisionDate = obj.passwordRevisionDate + ? new Date(obj.passwordRevisionDate) + : undefined; + login.totp = EncString.fromJSON(obj.totp); + login.autofillOnPageLoad = obj.autofillOnPageLoad ?? false; + login.fido2Credentials = obj.fido2Credentials?.map((f) => + Fido2Credential.fromSdkFido2Credential(f), + ); + + return login; + } } diff --git a/libs/common/src/vault/models/domain/password.ts b/libs/common/src/vault/models/domain/password.ts index f8aacf765b..b8a3009945 100644 --- a/libs/common/src/vault/models/domain/password.ts +++ b/libs/common/src/vault/models/domain/password.ts @@ -71,4 +71,20 @@ export class Password extends Domain { lastUsedDate: this.lastUsedDate.toISOString(), }; } + + /** + * Maps an SDK PasswordHistory object to a Password + * @param obj - The SDK PasswordHistory object + */ + static fromSdkPasswordHistory(obj: PasswordHistory): Password | undefined { + if (!obj) { + return undefined; + } + + const passwordHistory = new Password(); + passwordHistory.password = EncString.fromJSON(obj.password); + passwordHistory.lastUsedDate = new Date(obj.lastUsedDate); + + return passwordHistory; + } } diff --git a/libs/common/src/vault/models/domain/secure-note.ts b/libs/common/src/vault/models/domain/secure-note.ts index ac7977b0e4..1426ff85ea 100644 --- a/libs/common/src/vault/models/domain/secure-note.ts +++ b/libs/common/src/vault/models/domain/secure-note.ts @@ -54,4 +54,19 @@ export class SecureNote extends Domain { type: this.type, }; } + + /** + * Maps an SDK SecureNote object to a SecureNote + * @param obj - The SDK SecureNote object + */ + static fromSdkSecureNote(obj: SdkSecureNote): SecureNote | undefined { + if (obj == null) { + return undefined; + } + + const secureNote = new SecureNote(); + secureNote.type = obj.type; + + return secureNote; + } } diff --git a/libs/common/src/vault/models/domain/ssh-key.ts b/libs/common/src/vault/models/domain/ssh-key.ts index c0afcd83fc..0c8abf76e4 100644 --- a/libs/common/src/vault/models/domain/ssh-key.ts +++ b/libs/common/src/vault/models/domain/ssh-key.ts @@ -85,4 +85,21 @@ export class SshKey extends Domain { fingerprint: this.keyFingerprint.toJSON(), }; } + + /** + * Maps an SDK SshKey object to a SshKey + * @param obj - The SDK SshKey object + */ + static fromSdkSshKey(obj: SdkSshKey): SshKey | undefined { + if (obj == null) { + return undefined; + } + + const sshKey = new SshKey(); + sshKey.privateKey = EncString.fromJSON(obj.privateKey); + sshKey.publicKey = EncString.fromJSON(obj.publicKey); + sshKey.keyFingerprint = EncString.fromJSON(obj.fingerprint); + + return sshKey; + } } diff --git a/libs/common/src/vault/models/view/card.view.ts b/libs/common/src/vault/models/view/card.view.ts index dd7f5d6be5..ed02fa6836 100644 --- a/libs/common/src/vault/models/view/card.view.ts +++ b/libs/common/src/vault/models/view/card.view.ts @@ -10,7 +10,7 @@ import { linkedFieldOption } from "../../linked-field-option.decorator"; import { ItemView } from "./item.view"; -export class CardView extends ItemView { +export class CardView extends ItemView implements SdkCardView { @linkedFieldOption(LinkedId.CardholderName, { sortPosition: 0 }) cardholderName: string = null; @linkedFieldOption(LinkedId.ExpMonth, { sortPosition: 3, i18nKey: "expirationMonth" }) @@ -168,4 +168,12 @@ export class CardView extends ItemView { return cardView; } + + /** + * Converts the CardView to an SDK CardView. + * The view implements the SdkView so we can safely return `this` + */ + toSdkCardView(): SdkCardView { + return this; + } } diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index b9d3e42aa6..46cea06979 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -1,3 +1,7 @@ +import { Jsonify } from "type-fest"; + +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { CipherPermissionsApi } from "@bitwarden/common/vault/models/api/cipher-permissions.api"; import { CipherView as SdkCipherView, CipherType as SdkCipherType, @@ -85,6 +89,25 @@ describe("CipherView", () => { expect(actual).toMatchObject(expected); }); + + it("handle both string and object inputs for the cipher key", () => { + const cipherKeyString = "cipherKeyString"; + const cipherKeyObject = new EncString("cipherKeyObject"); + + // Test with string input + let actual = CipherView.fromJSON({ + key: cipherKeyString, + }); + expect(actual.key).toBeInstanceOf(EncString); + expect(actual.key?.toJSON()).toBe(cipherKeyString); + + // Test with object input (which can happen when cipher view is stored in an InMemory state provider) + actual = CipherView.fromJSON({ + key: cipherKeyObject, + } as Jsonify); + expect(actual.key).toBeInstanceOf(EncString); + expect(actual.key?.toJSON()).toBe(cipherKeyObject.toJSON()); + }); }); describe("fromSdkCipherView", () => { @@ -196,11 +219,80 @@ describe("CipherView", () => { __fromSdk: true, }, ], - passwordHistory: null, + passwordHistory: [], creationDate: new Date("2022-01-01T12:00:00.000Z"), revisionDate: new Date("2022-01-02T12:00:00.000Z"), deletedDate: null, }); }); }); + + describe("toSdkCipherView", () => { + it("maps properties correctly", () => { + 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.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"]; + cipherView.key = new EncString("some-key"); + cipherView.name = "name"; + cipherView.notes = "notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.edit = true; + cipherView.viewPassword = false; + cipherView.reprompt = CipherRepromptType.None; + cipherView.organizationUseTotp = false; + cipherView.localData = { + lastLaunched: new Date("2022-01-01T12:00:00.000Z").getTime(), + lastUsedDate: new Date("2022-01-02T12:00:00.000Z").getTime(), + }; + cipherView.permissions = new CipherPermissionsApi(); + cipherView.permissions.restore = true; + cipherView.permissions.delete = true; + cipherView.attachments = []; + cipherView.fields = []; + cipherView.passwordHistory = []; + cipherView.login = new LoginView(); + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + cipherView.creationDate = new Date("2022-01-02T12:00:00.000Z"); + + const sdkCipherView = cipherView.toSdkCipherView(); + + expect(sdkCipherView).toMatchObject({ + id: "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602", + organizationId: "000f2a6e-da5e-4726-87ed-1c5c77322c3c", + folderId: "41b22db4-8e2a-4ed2-b568-f1186c72922f", + collectionIds: ["b0473506-3c3c-4260-a734-dfaaf833ab6f"], + key: "some-key", + name: "name", + notes: "notes", + type: SdkCipherType.Login, + favorite: true, + edit: true, + viewPassword: false, + reprompt: SdkCipherRepromptType.None, + organizationUseTotp: false, + localData: { + lastLaunched: "2022-01-01T12:00:00.000Z", + lastUsedDate: "2022-01-02T12:00:00.000Z", + }, + permissions: { + restore: true, + delete: true, + }, + deletedDate: undefined, + creationDate: "2022-01-02T12:00:00.000Z", + revisionDate: "2022-01-02T12:00:00.000Z", + attachments: [], + passwordHistory: [], + login: undefined, + identity: undefined, + card: undefined, + secureNote: undefined, + sshKey: undefined, + fields: [], + } as SdkCipherView); + }); + }); }); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 353fffa8ee..0c41e49c3a 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { uuidToString, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; import { View } from "../../../models/view/view"; @@ -9,7 +11,7 @@ import { DeepJsonify } from "../../../types/deep-jsonify"; import { CipherType, LinkedIdType } from "../../enums"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherPermissionsApi } from "../api/cipher-permissions.api"; -import { LocalData } from "../data/local.data"; +import { LocalData, toSdkLocalData, fromSdkLocalData } from "../data/local.data"; import { Cipher } from "../domain/cipher"; import { AttachmentView } from "./attachment.view"; @@ -41,14 +43,17 @@ export class CipherView implements View, InitializerMetadata { card = new CardView(); secureNote = new SecureNoteView(); sshKey = new SshKeyView(); - attachments: AttachmentView[] = null; - fields: FieldView[] = null; - passwordHistory: PasswordHistoryView[] = null; + attachments: AttachmentView[] = []; + fields: FieldView[] = []; + passwordHistory: PasswordHistoryView[] = []; collectionIds: string[] = null; revisionDate: Date = null; creationDate: Date = null; deletedDate: Date = null; reprompt: CipherRepromptType = CipherRepromptType.None; + // We need a copy of the encrypted key so we can pass it to + // the SdkCipherView during encryption + key?: EncString; /** * Flag to indicate if the cipher decryption failed. @@ -76,6 +81,7 @@ export class CipherView implements View, InitializerMetadata { this.deletedDate = c.deletedDate; // Old locally stored ciphers might have reprompt == null. If so set it to None. this.reprompt = c.reprompt ?? CipherRepromptType.None; + this.key = c.key; } private get item() { @@ -194,6 +200,18 @@ export class CipherView implements View, InitializerMetadata { const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a)); const fields = obj.fields?.map((f: any) => FieldView.fromJSON(f)); const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph)); + const permissions = CipherPermissionsApi.fromJSON(obj.permissions); + let key: EncString | undefined; + + if (obj.key != null) { + if (typeof obj.key === "string") { + // If the key is a string, we need to parse it as EncString + key = EncString.fromJSON(obj.key); + } else if ((obj.key as any) instanceof EncString) { + // If the key is already an EncString instance, we can use it directly + key = obj.key; + } + } Object.assign(view, obj, { creationDate: creationDate, @@ -202,6 +220,8 @@ export class CipherView implements View, InitializerMetadata { attachments: attachments, fields: fields, passwordHistory: passwordHistory, + permissions: permissions, + key: key, }); switch (obj.type) { @@ -236,9 +256,9 @@ export class CipherView implements View, InitializerMetadata { } const cipherView = new CipherView(); - cipherView.id = obj.id ?? null; - cipherView.organizationId = obj.organizationId ?? null; - cipherView.folderId = obj.folderId ?? null; + cipherView.id = uuidToString(obj.id) ?? null; + cipherView.organizationId = uuidToString(obj.organizationId) ?? null; + cipherView.folderId = uuidToString(obj.folderId) ?? null; cipherView.name = obj.name; cipherView.notes = obj.notes ?? null; cipherView.type = obj.type; @@ -247,26 +267,18 @@ export class CipherView implements View, InitializerMetadata { cipherView.permissions = CipherPermissionsApi.fromSdkCipherPermissions(obj.permissions); cipherView.edit = obj.edit; cipherView.viewPassword = obj.viewPassword; - cipherView.localData = obj.localData - ? { - lastUsedDate: obj.localData.lastUsedDate - ? new Date(obj.localData.lastUsedDate).getTime() - : undefined, - lastLaunched: obj.localData.lastLaunched - ? new Date(obj.localData.lastLaunched).getTime() - : undefined, - } - : undefined; + cipherView.localData = fromSdkLocalData(obj.localData); cipherView.attachments = - obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? null; - cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? null; + obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? []; + cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? []; cipherView.passwordHistory = - obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? null; - cipherView.collectionIds = obj.collectionIds ?? null; + obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? []; + cipherView.collectionIds = obj.collectionIds?.map((i) => uuidToString(i)) ?? []; cipherView.revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); cipherView.creationDate = obj.creationDate == null ? null : new Date(obj.creationDate); cipherView.deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate); cipherView.reprompt = obj.reprompt ?? CipherRepromptType.None; + cipherView.key = EncString.fromJSON(obj.key); switch (obj.type) { case CipherType.Card: @@ -290,4 +302,66 @@ export class CipherView implements View, InitializerMetadata { return cipherView; } + + /** + * Maps CipherView to SdkCipherView + * + * @returns {SdkCipherView} The SDK cipher view object + */ + toSdkCipherView(): SdkCipherView { + const sdkCipherView: SdkCipherView = { + id: this.id ? asUuid(this.id) : undefined, + organizationId: this.organizationId ? asUuid(this.organizationId) : undefined, + folderId: this.folderId ? asUuid(this.folderId) : undefined, + name: this.name ?? "", + notes: this.notes, + type: this.type ?? CipherType.Login, + favorite: this.favorite, + organizationUseTotp: this.organizationUseTotp, + permissions: this.permissions?.toSdkCipherPermissions(), + edit: this.edit, + viewPassword: this.viewPassword, + localData: toSdkLocalData(this.localData), + attachments: this.attachments?.map((a) => a.toSdkAttachmentView()), + fields: this.fields?.map((f) => f.toSdkFieldView()), + passwordHistory: this.passwordHistory?.map((ph) => ph.toSdkPasswordHistoryView()), + collectionIds: this.collectionIds?.map((i) => i) ?? [], + // Revision and creation dates are non-nullable in SDKCipherView + revisionDate: (this.revisionDate ?? new Date()).toISOString(), + creationDate: (this.creationDate ?? new Date()).toISOString(), + deletedDate: this.deletedDate?.toISOString(), + reprompt: this.reprompt ?? CipherRepromptType.None, + key: this.key?.toJSON(), + // Cipher type specific properties are set in the switch statement below + // CipherView initializes each with default constructors (undefined values) + // The SDK does not expect those undefined values and will throw exceptions + login: undefined, + card: undefined, + identity: undefined, + secureNote: undefined, + sshKey: undefined, + }; + + switch (this.type) { + case CipherType.Card: + sdkCipherView.card = this.card.toSdkCardView(); + break; + case CipherType.Identity: + sdkCipherView.identity = this.identity.toSdkIdentityView(); + break; + case CipherType.Login: + sdkCipherView.login = this.login.toSdkLoginView(); + break; + case CipherType.SecureNote: + sdkCipherView.secureNote = this.secureNote.toSdkSecureNoteView(); + break; + case CipherType.SshKey: + sdkCipherView.sshKey = this.sshKey.toSdkSshKeyView(); + break; + default: + break; + } + + return sdkCipherView; + } } diff --git a/libs/common/src/vault/models/view/fido2-credential.view.ts b/libs/common/src/vault/models/view/fido2-credential.view.ts index bf1d324d22..410757ebe3 100644 --- a/libs/common/src/vault/models/view/fido2-credential.view.ts +++ b/libs/common/src/vault/models/view/fido2-credential.view.ts @@ -2,7 +2,10 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; -import { Fido2CredentialView as SdkFido2CredentialView } from "@bitwarden/sdk-internal"; +import { + Fido2CredentialView as SdkFido2CredentialView, + Fido2CredentialFullView, +} from "@bitwarden/sdk-internal"; import { ItemView } from "./item.view"; @@ -56,4 +59,22 @@ export class Fido2CredentialView extends ItemView { return view; } + + toSdkFido2CredentialFullView(): Fido2CredentialFullView { + return { + credentialId: this.credentialId, + keyType: this.keyType, + keyAlgorithm: this.keyAlgorithm, + keyCurve: this.keyCurve, + keyValue: this.keyValue, + rpId: this.rpId, + userHandle: this.userHandle, + userName: this.userName, + counter: this.counter.toString(), + rpName: this.rpName, + userDisplayName: this.userDisplayName, + discoverable: this.discoverable ? "true" : "false", + creationDate: this.creationDate?.toISOString(), + }; + } } diff --git a/libs/common/src/vault/models/view/field.view.ts b/libs/common/src/vault/models/view/field.view.ts index 770150f8a6..8c9a923aed 100644 --- a/libs/common/src/vault/models/view/field.view.ts +++ b/libs/common/src/vault/models/view/field.view.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; -import { FieldView as SdkFieldView } from "@bitwarden/sdk-internal"; +import { FieldView as SdkFieldView, FieldType as SdkFieldType } from "@bitwarden/sdk-internal"; import { View } from "../../../models/view/view"; import { FieldType, LinkedIdType } from "../../enums"; @@ -50,4 +50,16 @@ export class FieldView implements View { return view; } + + /** + * Converts the FieldView to an SDK FieldView. + */ + toSdkFieldView(): SdkFieldView { + return { + name: this.name ?? undefined, + value: this.value ?? undefined, + type: this.type ?? SdkFieldType.Text, + linkedId: this.linkedId ?? undefined, + }; + } } diff --git a/libs/common/src/vault/models/view/identity.view.ts b/libs/common/src/vault/models/view/identity.view.ts index 877940e4ae..2b863dc5e5 100644 --- a/libs/common/src/vault/models/view/identity.view.ts +++ b/libs/common/src/vault/models/view/identity.view.ts @@ -10,7 +10,7 @@ import { linkedFieldOption } from "../../linked-field-option.decorator"; import { ItemView } from "./item.view"; -export class IdentityView extends ItemView { +export class IdentityView extends ItemView implements SdkIdentityView { @linkedFieldOption(LinkedId.Title, { sortPosition: 0 }) title: string = null; @linkedFieldOption(LinkedId.MiddleName, { sortPosition: 2 }) @@ -192,4 +192,12 @@ export class IdentityView extends ItemView { return identityView; } + + /** + * Converts the IdentityView to an SDK IdentityView. + * The view implements the SdkView so we can safely return `this` + */ + toSdkIdentityView(): SdkIdentityView { + return this; + } } diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 43d47aa4a3..38cd517e54 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -129,6 +129,15 @@ export class LoginUriView implements View { return view; } + /** Converts a LoginUriView object to an SDK LoginUriView object. */ + toSdkLoginUriView(): SdkLoginUriView { + return { + uri: this.uri ?? undefined, + match: this.match ?? undefined, + uriChecksum: undefined, // SDK handles uri checksum generation internally + }; + } + matchesUri( targetUri: string, equivalentDomains: Set, diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index c6e6ca001e..d268cf4afa 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -124,10 +124,30 @@ export class LoginView extends ItemView { obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); loginView.totp = obj.totp ?? null; loginView.autofillOnPageLoad = obj.autofillOnPageLoad ?? null; - loginView.uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || []; + loginView.uris = + obj.uris + ?.filter((uri) => uri.uri != null && uri.uri !== "") + .map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || []; // FIDO2 credentials are not decrypted here, they remain encrypted loginView.fido2Credentials = null; return loginView; } + + /** + * Converts the LoginView to an SDK LoginView. + * + * Note: FIDO2 credentials remain encrypted in the SDK view so they are not included here. + */ + toSdkLoginView(): SdkLoginView { + return { + username: this.username, + password: this.password, + passwordRevisionDate: this.passwordRevisionDate?.toISOString(), + totp: this.totp, + autofillOnPageLoad: this.autofillOnPageLoad ?? undefined, + uris: this.uris?.map((uri) => uri.toSdkLoginUriView()), + fido2Credentials: undefined, // FIDO2 credentials are handled separately and remain encrypted + }; + } } diff --git a/libs/common/src/vault/models/view/password-history.view.spec.ts b/libs/common/src/vault/models/view/password-history.view.spec.ts index 81894ec749..512ec8d86d 100644 --- a/libs/common/src/vault/models/view/password-history.view.spec.ts +++ b/libs/common/src/vault/models/view/password-history.view.spec.ts @@ -33,4 +33,17 @@ describe("PasswordHistoryView", () => { }); }); }); + + describe("toSdkPasswordHistoryView", () => { + it("should return a SdkPasswordHistoryView", () => { + const passwordHistoryView = new PasswordHistoryView(); + passwordHistoryView.password = "password"; + passwordHistoryView.lastUsedDate = new Date("2023-10-01T00:00:00.000Z"); + + expect(passwordHistoryView.toSdkPasswordHistoryView()).toMatchObject({ + password: "password", + lastUsedDate: "2023-10-01T00:00:00.000Z", + }); + }); + }); }); diff --git a/libs/common/src/vault/models/view/password-history.view.ts b/libs/common/src/vault/models/view/password-history.view.ts index 31f05f4cc7..9bd708b19f 100644 --- a/libs/common/src/vault/models/view/password-history.view.ts +++ b/libs/common/src/vault/models/view/password-history.view.ts @@ -41,4 +41,14 @@ export class PasswordHistoryView implements View { return view; } + + /** + * Converts the PasswordHistoryView to an SDK PasswordHistoryView. + */ + toSdkPasswordHistoryView(): SdkPasswordHistoryView { + return { + password: this.password ?? "", + lastUsedDate: this.lastUsedDate.toISOString(), + }; + } } diff --git a/libs/common/src/vault/models/view/secure-note.view.ts b/libs/common/src/vault/models/view/secure-note.view.ts index 8e7a6b4652..5e40196186 100644 --- a/libs/common/src/vault/models/view/secure-note.view.ts +++ b/libs/common/src/vault/models/view/secure-note.view.ts @@ -9,7 +9,7 @@ import { SecureNote } from "../domain/secure-note"; import { ItemView } from "./item.view"; -export class SecureNoteView extends ItemView { +export class SecureNoteView extends ItemView implements SdkSecureNoteView { type: SecureNoteType = null; constructor(n?: SecureNote) { @@ -42,4 +42,12 @@ export class SecureNoteView extends ItemView { return secureNoteView; } + + /** + * Converts the SecureNoteView to an SDK SecureNoteView. + * The view implements the SdkView so we can safely return `this` + */ + toSdkSecureNoteView(): SdkSecureNoteView { + return this; + } } diff --git a/libs/common/src/vault/models/view/ssh-key.view.ts b/libs/common/src/vault/models/view/ssh-key.view.ts index a83793678d..0547eeb7f8 100644 --- a/libs/common/src/vault/models/view/ssh-key.view.ts +++ b/libs/common/src/vault/models/view/ssh-key.view.ts @@ -63,4 +63,15 @@ export class SshKeyView extends ItemView { return sshKeyView; } + + /** + * Converts the SshKeyView to an SDK SshKeyView. + */ + toSdkSshKeyView(): SdkSshKeyView { + return { + privateKey: this.privateKey, + publicKey: this.publicKey, + fingerprint: this.keyFingerprint, + }; + } } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index bf30b78ca6..f027122993 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,7 +1,9 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, map, of } from "rxjs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response"; // 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 { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management"; @@ -23,7 +25,7 @@ import { Utils } from "../../platform/misc/utils"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../platform/services/container.service"; -import { CipherId, UserId } from "../../types/guid"; +import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid"; import { CipherKey, OrgKey, UserKey } from "../../types/key"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; import { EncryptionContext } from "../abstractions/cipher.service"; @@ -108,6 +110,7 @@ describe("Cipher Service", () => { const cipherEncryptionService = mock(); const userId = "TestUserId" as UserId; + const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId; let cipherService: CipherService; let encryptionContext: EncryptionContext; @@ -155,7 +158,9 @@ describe("Cipher Service", () => { ); configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false)); - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(false); const spy = jest.spyOn(cipherFileUploadService, "upload"); @@ -270,6 +275,55 @@ describe("Cipher Service", () => { jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true); }); + it("should call encrypt method of CipherEncryptionService when feature flag is true", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(true); + cipherEncryptionService.encrypt.mockResolvedValue(encryptionContext); + + const result = await cipherService.encrypt(cipherView, userId); + + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).toHaveBeenCalledWith(cipherView, userId); + }); + + it("should call legacy encrypt when feature flag is false", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(false); + + jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher); + + const result = await cipherService.encrypt(cipherView, userId); + + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled(); + }); + + it("should call legacy encrypt when keys are provided", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(true); + + jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher); + + const encryptKey = new SymmetricCryptoKey(new Uint8Array(32)); + const decryptKey = new SymmetricCryptoKey(new Uint8Array(32)); + + let result = await cipherService.encrypt(cipherView, userId, encryptKey); + + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled(); + + result = await cipherService.encrypt(cipherView, userId, undefined, decryptKey); + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled(); + + result = await cipherService.encrypt(cipherView, userId, encryptKey, decryptKey); + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled(); + }); + it("should return the encrypting user id", async () => { keyService.getOrgKey.mockReturnValue( Promise.resolve(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey), @@ -310,7 +364,9 @@ describe("Cipher Service", () => { }); it("is null when feature flag is false", async () => { - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(false); const { cipher } = await cipherService.encrypt(cipherView, userId); expect(cipher.key).toBeNull(); @@ -318,7 +374,9 @@ describe("Cipher Service", () => { describe("when feature flag is true", () => { beforeEach(() => { - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(true); }); it("is null when the cipher is not viewPassword", async () => { @@ -348,7 +406,9 @@ describe("Cipher Service", () => { }); it("is not called when feature flag is false", async () => { - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(false); await cipherService.encrypt(cipherView, userId); @@ -357,7 +417,9 @@ describe("Cipher Service", () => { describe("when feature flag is true", () => { beforeEach(() => { - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(true); }); it("is called when cipher viewPassword is true", async () => { @@ -401,7 +463,9 @@ describe("Cipher Service", () => { let encryptedKey: EncString; beforeEach(() => { - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(true); configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true)); searchService.indexedEntityId$.mockReturnValue(of(null)); @@ -474,7 +538,9 @@ describe("Cipher Service", () => { describe("decrypt", () => { it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => { - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) + .mockResolvedValue(true); cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(encryptionContext.cipher)); const result = await cipherService.decrypt(encryptionContext.cipher, userId); @@ -488,7 +554,9 @@ describe("Cipher Service", () => { it("should call legacy decrypt when feature flag is false", async () => { const mockUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) + .mockResolvedValue(false); cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey); encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); jest @@ -509,7 +577,9 @@ describe("Cipher Service", () => { it("should use SDK when feature flag is enabled", async () => { const cipher = new Cipher(cipherData); const attachment = new AttachmentView(cipher.attachments![0]); - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) + .mockResolvedValue(true); jest.spyOn(cipherService, "ciphers$").mockReturnValue(of({ [cipher.id]: cipherData })); cipherEncryptionService.decryptAttachmentContent.mockResolvedValue(mockDecryptedContent); @@ -534,7 +604,9 @@ describe("Cipher Service", () => { }); it("should use legacy decryption when feature flag is enabled", async () => { - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) + .mockResolvedValue(false); const cipher = new Cipher(cipherData); const attachment = new AttachmentView(cipher.attachments![0]); attachment.key = makeSymmetricCryptoKey(64); @@ -557,4 +629,77 @@ describe("Cipher Service", () => { expect(encryptService.decryptFileData).toHaveBeenCalledWith(mockEncBuf, attachment.key); }); }); + + describe("shareWithServer()", () => { + it("should use cipherEncryptionService to move the cipher when feature flag enabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(true); + + apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData)); + + const expectedCipher = new Cipher(cipherData); + expectedCipher.organizationId = orgId; + const cipherView = new CipherView(expectedCipher); + const collectionIds = ["collection1", "collection2"] as CollectionId[]; + + cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test + + cipherEncryptionService.moveToOrganization.mockResolvedValue({ + cipher: expectedCipher, + encryptedFor: userId, + }); + + await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId); + + // Expect SDK usage + expect(cipherEncryptionService.moveToOrganization).toHaveBeenCalledWith( + cipherView, + orgId, + userId, + ); + // Expect collectionIds to be assigned + expect(apiService.putShareCipher).toHaveBeenCalledWith( + cipherView.id, + expect.objectContaining({ + cipher: expect.objectContaining({ organizationId: orgId }), + collectionIds: collectionIds, + }), + ); + }); + + it("should use legacy encryption when feature flag disabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(false); + + apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData)); + + const expectedCipher = new Cipher(cipherData); + expectedCipher.organizationId = orgId; + const cipherView = new CipherView(expectedCipher); + const collectionIds = ["collection1", "collection2"] as CollectionId[]; + + cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test + + const oldEncryptSharedSpy = jest + .spyOn(cipherService as any, "encryptSharedCipher") + .mockResolvedValue({ + cipher: expectedCipher, + encryptedFor: userId, + }); + + await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId); + + // Expect no SDK usage + expect(cipherEncryptionService.moveToOrganization).not.toHaveBeenCalled(); + expect(oldEncryptSharedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: orgId, + collectionIds: collectionIds, + } as unknown as CipherView), + userId, + ); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 8bef5289a9..1524e4e1b2 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -231,13 +231,14 @@ export class CipherService implements CipherServiceAbstraction { this.clearCipherViewsForUser$.next(userId); } - async encrypt( - model: CipherView, - userId: UserId, - keyForCipherEncryption?: SymmetricCryptoKey, - keyForCipherKeyDecryption?: SymmetricCryptoKey, - originalCipher: Cipher = null, - ): Promise { + /** + * Adjusts the cipher history for the given model by updating its history properties based on the original cipher. + * @param model The cipher model to adjust. + * @param userId The acting userId + * @param originalCipher The original cipher to compare against. If not provided, it will be fetched from the store. + * @private + */ + private async adjustCipherHistory(model: CipherView, userId: UserId, originalCipher?: Cipher) { if (model.id != null) { if (originalCipher == null) { originalCipher = await this.get(model.id, userId); @@ -247,6 +248,25 @@ export class CipherService implements CipherServiceAbstraction { } this.adjustPasswordHistoryLength(model); } + } + + async encrypt( + model: CipherView, + userId: UserId, + keyForCipherEncryption?: SymmetricCryptoKey, + keyForCipherKeyDecryption?: SymmetricCryptoKey, + originalCipher: Cipher = null, + ): Promise { + await this.adjustCipherHistory(model, userId, originalCipher); + + const sdkEncryptionEnabled = + (await this.configService.getFeatureFlag(FeatureFlag.PM22136_SdkCipherEncryption)) && + keyForCipherEncryption == null && // PM-23085 - SDK encryption does not currently support custom keys (e.g. key rotation) + keyForCipherKeyDecryption == null; // PM-23348 - Or has explicit methods for re-encrypting ciphers with different keys (e.g. move to org) + + if (sdkEncryptionEnabled) { + return await this.cipherEncryptionService.encrypt(model, userId); + } const cipher = new Cipher(); cipher.id = model.id; @@ -854,22 +874,48 @@ export class CipherService implements CipherServiceAbstraction { organizationId: string, collectionIds: string[], userId: UserId, + originalCipher?: Cipher, ): Promise { - const attachmentPromises: Promise[] = []; - if (cipher.attachments != null) { - cipher.attachments.forEach((attachment) => { - if (attachment.key == null) { - attachmentPromises.push( - this.shareAttachmentWithServer(attachment, cipher.id, organizationId), - ); - } - }); - } - await Promise.all(attachmentPromises); + const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM22136_SdkCipherEncryption, + ); + + await this.adjustCipherHistory(cipher, userId, originalCipher); + + let encCipher: EncryptionContext; + if (sdkCipherEncryptionEnabled) { + // The SDK does not expect the cipher to already have an organizationId. It will result in the wrong + // cipher encryption key being used during the move to organization operation. + if (cipher.organizationId != null) { + throw new Error("Cipher is already associated with an organization."); + } + + encCipher = await this.cipherEncryptionService.moveToOrganization( + cipher, + organizationId as OrganizationId, + userId, + ); + encCipher.cipher.collectionIds = collectionIds; + } else { + // This old attachment logic is safe to remove after it is replaced in PM-22750; which will require fixing + // the attachment before sharing. + const attachmentPromises: Promise[] = []; + if (cipher.attachments != null) { + cipher.attachments.forEach((attachment) => { + if (attachment.key == null) { + attachmentPromises.push( + this.shareAttachmentWithServer(attachment, cipher.id, organizationId), + ); + } + }); + } + await Promise.all(attachmentPromises); + + cipher.organizationId = organizationId; + cipher.collectionIds = collectionIds; + encCipher = await this.encryptSharedCipher(cipher, userId); + } - cipher.organizationId = organizationId; - cipher.collectionIds = collectionIds; - const encCipher = await this.encryptSharedCipher(cipher, userId); const request = new CipherShareRequest(encCipher); const response = await this.apiService.putShareCipher(cipher.id, request); const data = new CipherData(response, collectionIds); @@ -883,16 +929,36 @@ export class CipherService implements CipherServiceAbstraction { collectionIds: string[], userId: UserId, ) { + const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM22136_SdkCipherEncryption, + ); const promises: Promise[] = []; const encCiphers: Cipher[] = []; for (const cipher of ciphers) { - cipher.organizationId = organizationId; - cipher.collectionIds = collectionIds; - promises.push( - this.encryptSharedCipher(cipher, userId).then((c) => { - encCiphers.push(c.cipher); - }), - ); + if (sdkCipherEncryptionEnabled) { + // The SDK does not expect the cipher to already have an organizationId. It will result in the wrong + // cipher encryption key being used during the move to organization operation. + if (cipher.organizationId != null) { + throw new Error("Cipher is already associated with an organization."); + } + + promises.push( + this.cipherEncryptionService + .moveToOrganization(cipher, organizationId as OrganizationId, userId) + .then((encCipher) => { + encCipher.cipher.collectionIds = collectionIds; + encCiphers.push(encCipher.cipher); + }), + ); + } else { + cipher.organizationId = organizationId; + cipher.collectionIds = collectionIds; + promises.push( + this.encryptSharedCipher(cipher, userId).then((c) => { + encCiphers.push(c.cipher); + }), + ); + } } await Promise.all(promises); const request = new CipherBulkShareRequest(encCiphers, collectionIds, userId); diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index 4d05a5197f..9e0cf62ed0 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -1,20 +1,22 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Fido2Credential } from "@bitwarden/common/vault/models/domain/fido2-credential"; import { - Fido2Credential, + Fido2Credential as SdkFido2Credential, Cipher as SdkCipher, CipherType as SdkCipherType, CipherView as SdkCipherView, CipherListView, AttachmentView as SdkAttachmentView, + Fido2CredentialFullView, } from "@bitwarden/sdk-internal"; import { mockEnc } from "../../../spec"; import { UriMatchStrategy } from "../../models/domain/domain-service"; import { LogService } from "../../platform/abstractions/log.service"; import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; -import { UserId } from "../../types/guid"; +import { UserId, CipherId, OrganizationId } from "../../types/guid"; import { CipherRepromptType, CipherType } from "../enums"; import { CipherPermissionsApi } from "../models/api/cipher-permissions.api"; import { CipherData } from "../models/data/cipher.data"; @@ -25,10 +27,15 @@ import { Fido2CredentialView } from "../models/view/fido2-credential.view"; import { DefaultCipherEncryptionService } from "./default-cipher-encryption.service"; +const cipherId = "bdc4ef23-1116-477e-ae73-247854af58cb" as CipherId; +const orgId = "c5e9654f-6cc5-44c4-8e09-3d323522668c" as OrganizationId; +const folderId = "a3e9654f-6cc5-44c4-8e09-3d323522668c"; +const userId = "59fbbb44-8cc8-4279-ab40-afc5f68704f4" as UserId; + const cipherData: CipherData = { - id: "id", - organizationId: "orgId", - folderId: "folderId", + id: cipherId, + organizationId: orgId, + folderId: folderId, edit: true, viewPassword: true, organizationUseTotp: true, @@ -78,13 +85,17 @@ describe("DefaultCipherEncryptionService", () => { const sdkService = mock(); const logService = mock(); let sdkCipherView: SdkCipherView; + let sdkCipher: SdkCipher; const mockSdkClient = { vault: jest.fn().mockReturnValue({ ciphers: jest.fn().mockReturnValue({ + encrypt: jest.fn(), + set_fido2_credentials: jest.fn(), decrypt: jest.fn(), decrypt_list: jest.fn(), decrypt_fido2_credentials: jest.fn(), + move_to_organization: jest.fn(), }), attachments: jest.fn().mockReturnValue({ decrypt_buffer: jest.fn(), @@ -99,21 +110,25 @@ describe("DefaultCipherEncryptionService", () => { take: jest.fn().mockReturnValue(mockRef), }; - const userId = "user-id" as UserId; - let cipherObj: Cipher; + let cipherViewObj: CipherView; beforeEach(() => { sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any; cipherEncryptionService = new DefaultCipherEncryptionService(sdkService, logService); cipherObj = new Cipher(cipherData); + cipherViewObj = new CipherView(cipherObj); jest.spyOn(cipherObj, "toSdkCipher").mockImplementation(() => { return { id: cipherData.id } as SdkCipher; }); + jest.spyOn(cipherViewObj, "toSdkCipherView").mockImplementation(() => { + return { id: cipherData.id } as SdkCipherView; + }); + sdkCipherView = { - id: "test-id", + id: cipherId as string, type: SdkCipherType.Login, name: "test-name", login: { @@ -121,16 +136,211 @@ describe("DefaultCipherEncryptionService", () => { password: "test-password", }, } as SdkCipherView; + + sdkCipher = { + id: cipherId, + type: SdkCipherType.Login, + name: "encrypted-name", + login: { + username: "encrypted-username", + password: "encrypted-password", + }, + } as unknown as SdkCipher; }); afterEach(() => { jest.clearAllMocks(); }); + describe("encrypt", () => { + it("should encrypt a cipher successfully", async () => { + const expectedCipher: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name", + login: { + username: "encrypted-username", + password: "encrypted-password", + }, + } as unknown as Cipher; + + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher); + + const result = await cipherEncryptionService.encrypt(cipherViewObj, userId); + + expect(result).toBeDefined(); + expect(result!.cipher).toEqual(expectedCipher); + expect(result!.encryptedFor).toBe(userId); + expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled(); + expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledWith({ id: cipherData.id }); + }); + + it("should encrypt FIDO2 credentials if present", async () => { + const fidoCredentialView = new Fido2CredentialView(); + fidoCredentialView.credentialId = "credentialId"; + + cipherViewObj.login.fido2Credentials = [fidoCredentialView]; + + jest.spyOn(fidoCredentialView, "toSdkFido2CredentialFullView").mockImplementation( + () => + ({ + credentialId: "credentialId", + }) as Fido2CredentialFullView, + ); + jest.spyOn(cipherViewObj, "toSdkCipherView").mockImplementation( + () => + ({ + id: cipherId as string, + login: { + fido2Credentials: undefined, + }, + }) as unknown as SdkCipherView, + ); + + mockSdkClient + .vault() + .ciphers() + .set_fido2_credentials.mockReturnValue({ + id: cipherId as string, + login: { + fido2Credentials: [ + { + credentialId: "encrypted-credentialId", + }, + ], + }, + }); + + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + + cipherObj.login!.fido2Credentials = [ + { credentialId: "encrypted-credentialId" } as unknown as Fido2Credential, + ]; + + jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(cipherObj); + + const result = await cipherEncryptionService.encrypt(cipherViewObj, userId); + + expect(result).toBeDefined(); + expect(result!.cipher.login!.fido2Credentials).toHaveLength(1); + + // Ensure set_fido2_credentials was called with correct parameters + expect(mockSdkClient.vault().ciphers().set_fido2_credentials).toHaveBeenCalledWith( + expect.objectContaining({ id: cipherId }), + [{ credentialId: "credentialId" }], + ); + + // Encrypted fido2 credential should be in the cipher passed to encrypt + expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + id: cipherId, + login: { fido2Credentials: [{ credentialId: "encrypted-credentialId" }] }, + }), + ); + }); + }); + + describe("moveToOrganization", () => { + it("should call the sdk method to move a cipher to an organization", async () => { + const expectedCipher: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name", + organizationId: orgId, + login: { + username: "encrypted-username", + password: "encrypted-password", + }, + } as unknown as Cipher; + + mockSdkClient.vault().ciphers().move_to_organization.mockReturnValue({ + id: cipherId, + organizationId: orgId, + }); + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher); + + const result = await cipherEncryptionService.moveToOrganization(cipherViewObj, orgId, userId); + + expect(result).toBeDefined(); + expect(result!.cipher).toEqual(expectedCipher); + expect(result!.encryptedFor).toBe(userId); + expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled(); + expect(mockSdkClient.vault().ciphers().move_to_organization).toHaveBeenCalledWith( + { id: cipherData.id }, + orgId, + ); + }); + + it("should re-encrypt any fido2 credentials when moving to an organization", async () => { + const mockSdkCredentialView = { + username: "username", + } as unknown as Fido2CredentialFullView; + const mockCredentialView = mock(); + mockCredentialView.toSdkFido2CredentialFullView.mockReturnValue(mockSdkCredentialView); + cipherViewObj.login.fido2Credentials = [mockCredentialView]; + const expectedCipher: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name", + organizationId: orgId, + login: { + username: "encrypted-username", + password: "encrypted-password", + fido2Credentials: [{ username: "encrypted-username" }], + }, + } as unknown as Cipher; + + mockSdkClient + .vault() + .ciphers() + .set_fido2_credentials.mockReturnValue({ + id: cipherId as string, + login: { + fido2Credentials: [mockSdkCredentialView], + }, + } as SdkCipherView); + mockSdkClient.vault().ciphers().move_to_organization.mockReturnValue({ + id: cipherId, + organizationId: orgId, + }); + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher); + + const result = await cipherEncryptionService.moveToOrganization(cipherViewObj, orgId, userId); + + expect(result).toBeDefined(); + expect(result!.cipher).toEqual(expectedCipher); + expect(result!.encryptedFor).toBe(userId); + expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled(); + expect(mockSdkClient.vault().ciphers().set_fido2_credentials).toHaveBeenCalledWith( + expect.objectContaining({ id: cipherId }), + expect.arrayContaining([mockSdkCredentialView]), + ); + expect(mockSdkClient.vault().ciphers().move_to_organization).toHaveBeenCalledWith( + { id: cipherData.id, login: { fido2Credentials: [mockSdkCredentialView] } }, + orgId, + ); + }); + }); + describe("decrypt", () => { it("should decrypt a cipher successfully", async () => { const expectedCipherView: CipherView = { - id: "test-id", + id: cipherId as string, type: CipherType.Login, name: "test-name", login: { @@ -168,12 +378,12 @@ describe("DefaultCipherEncryptionService", () => { discoverable: mockEnc("true"), creationDate: new Date("2023-01-01T12:00:00.000Z"), }, - ] as unknown as Fido2Credential[]; + ] as unknown as SdkFido2Credential[]; sdkCipherView.login!.fido2Credentials = fido2Credentials; const expectedCipherView: CipherView = { - id: "test-id", + id: cipherId, type: CipherType.Login, name: "test-name", login: { @@ -228,13 +438,15 @@ describe("DefaultCipherEncryptionService", () => { it("should decrypt multiple ciphers successfully", async () => { const ciphers = [new Cipher(cipherData), new Cipher(cipherData)]; + const cipherId2 = "bdc4ef23-2222-477e-ae73-247854af58cb" as CipherId; + const expectedViews = [ { - id: "test-id-1", + id: cipherId as string, name: "test-name-1", } as CipherView, { - id: "test-id-2", + id: cipherId2 as string, name: "test-name-2", } as CipherView, ]; @@ -242,8 +454,11 @@ describe("DefaultCipherEncryptionService", () => { mockSdkClient .vault() .ciphers() - .decrypt.mockReturnValueOnce({ id: "test-id-1", name: "test-name-1" } as SdkCipherView) - .mockReturnValueOnce({ id: "test-id-2", name: "test-name-2" } as SdkCipherView); + .decrypt.mockReturnValueOnce({ + id: cipherId, + name: "test-name-1", + } as unknown as SdkCipherView) + .mockReturnValueOnce({ id: cipherId2, name: "test-name-2" } as unknown as SdkCipherView); jest .spyOn(CipherView, "fromSdkCipherView") diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index 2c57df6f5b..3547bafb4c 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -1,10 +1,15 @@ import { EMPTY, catchError, firstValueFrom, map } from "rxjs"; -import { CipherListView } from "@bitwarden/sdk-internal"; +import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { + CipherListView, + BitwardenClient, + CipherView as SdkCipherView, +} from "@bitwarden/sdk-internal"; import { LogService } from "../../platform/abstractions/log.service"; -import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; -import { UserId } from "../../types/guid"; +import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service"; +import { UserId, OrganizationId } from "../../types/guid"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; import { CipherType } from "../enums"; import { Cipher } from "../models/domain/cipher"; @@ -18,6 +23,67 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { private logService: LogService, ) {} + async encrypt(model: CipherView, userId: UserId): Promise { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + const sdkCipherView = this.toSdkCipherView(model, ref.value); + + const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView); + + return { + cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!, + encryptedFor: asUuid(encryptionContext.encryptedFor), + }; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to encrypt cipher: ${error}`); + return EMPTY; + }), + ), + ); + } + + async moveToOrganization( + model: CipherView, + organizationId: OrganizationId, + userId: UserId, + ): Promise { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + const sdkCipherView = this.toSdkCipherView(model, ref.value); + + const movedCipherView = ref.value + .vault() + .ciphers() + .move_to_organization(sdkCipherView, asUuid(organizationId)); + + const encryptionContext = ref.value.vault().ciphers().encrypt(movedCipherView); + + return { + cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!, + encryptedFor: asUuid(encryptionContext.encryptedFor), + }; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to move cipher to organization: ${error}`); + return EMPTY; + }), + ), + ); + } + async decrypt(cipher: Cipher, userId: UserId): Promise { return firstValueFrom( this.sdkService.userClient$(userId).pipe( @@ -51,11 +117,8 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { clientCipherView.login.fido2Credentials = fido2CredentialViews .map((f) => { const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; - - return { - ...view, - keyValue: decryptedKeyValue, - }; + view.keyValue = decryptedKeyValue; + return view; }) .filter((view): view is Fido2CredentialView => view !== undefined); } @@ -104,10 +167,8 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { clientCipherView.login.fido2Credentials = fido2CredentialViews .map((f) => { const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; - return { - ...view, - keyValue: decryptedKeyValue, - }; + view.keyValue = decryptedKeyValue; + return view; }) .filter((view): view is Fido2CredentialView => view !== undefined); } @@ -187,4 +248,25 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { ), ); } + + /** + * Helper method to convert a CipherView model to an SDK CipherView. Has special handling for Fido2 credentials + * that need to be encrypted before being sent to the SDK. + * @param model The CipherView model to convert + * @param sdk An instance of SDK client + * @private + */ + private toSdkCipherView(model: CipherView, sdk: BitwardenClient): SdkCipherView { + let sdkCipherView = model.toSdkCipherView(); + + if (model.type === CipherType.Login && model.login?.hasFido2Credentials) { + // Encrypt Fido2 credentials separately + const fido2Credentials = model.login.fido2Credentials?.map((f) => + f.toSdkFido2CredentialFullView(), + ); + sdkCipherView = sdk.vault().ciphers().set_fido2_credentials(sdkCipherView, fido2Credentials); + } + + return sdkCipherView; + } } diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 463d61dbbd..1a97bc5a32 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -320,12 +320,6 @@ export abstract class BaseImporter { } else { cipher.notes = cipher.notes.trim(); } - if (cipher.fields != null && cipher.fields.length === 0) { - cipher.fields = null; - } - if (cipher.passwordHistory != null && cipher.passwordHistory.length === 0) { - cipher.passwordHistory = null; - } } protected processKvp( diff --git a/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts b/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts index d7a4d487bc..026c501cf5 100644 --- a/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts +++ b/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts @@ -66,7 +66,7 @@ describe("Keeper CSV Importer", () => { expect(result != null).toBe(true); const cipher = result.ciphers.shift(); - expect(cipher.fields).toBeNull(); + expect(cipher.fields.length).toBe(0); const cipher2 = result.ciphers.shift(); expect(cipher2.fields.length).toBe(2); diff --git a/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts b/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts index 31169021e0..22008f3b4c 100644 --- a/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts +++ b/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts @@ -39,7 +39,7 @@ describe("Keeper Json Importer", () => { expect(cipher3.login.username).toEqual("someUserName"); expect(cipher3.login.password).toEqual("w4k4k1wergf$^&@#*%2"); expect(cipher3.notes).toBeNull(); - expect(cipher3.fields).toBeNull(); + expect(cipher3.fields.length).toBe(0); expect(cipher3.login.uris.length).toEqual(1); const uriView3 = cipher3.login.uris.shift(); expect(uriView3.uri).toEqual("https://example.com"); diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 5228c85c3f..d195ff8b00 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -5,7 +5,7 @@ import { firstValueFrom, map } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { UserId } from "@bitwarden/common/types/guid"; +import { UserId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -31,21 +31,13 @@ export class DefaultCipherFormService implements CipherFormService { } async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise { - // Passing the original cipher is important here as it is responsible for appending to password history const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const encrypted = await this.cipherService.encrypt( - cipher, - activeUserId, - null, - null, - config.originalCipher ?? null, - ); - const encryptedCipher = encrypted.cipher; let savedCipher: Cipher; // Creating a new cipher if (cipher.id == null) { + const encrypted = await this.cipherService.encrypt(cipher, activeUserId); savedCipher = await this.cipherService.createWithServer(encrypted, config.admin); return await this.cipherService.decrypt(savedCipher, activeUserId); } @@ -61,16 +53,37 @@ export class DefaultCipherFormService implements CipherFormService { // Call shareWithServer if the owner is changing from a user to an organization if (config.originalCipher.organizationId === null && cipher.organizationId != null) { + // shareWithServer expects the cipher to have no organizationId set + const organizationId = cipher.organizationId as OrganizationId; + cipher.organizationId = null; + savedCipher = await this.cipherService.shareWithServer( cipher, - cipher.organizationId, + organizationId, cipher.collectionIds, activeUserId, + config.originalCipher, ); // If the collectionIds are the same, update the cipher normally } else if (isSetEqual(originalCollectionIds, newCollectionIds)) { + const encrypted = await this.cipherService.encrypt( + cipher, + activeUserId, + null, + null, + config.originalCipher, + ); savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin); } 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; diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index a2ade0e885..e65dd62500 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -67,12 +67,12 @@ - + - + { }); describe("history", () => { - const password1 = { password: "bad-password-1", lastUsedDate: new Date("09/13/2004") }; - const password2 = { password: "bad-password-2", lastUsedDate: new Date("02/01/2004") }; + const password1 = { + password: "bad-password-1", + lastUsedDate: new Date("09/13/2004"), + } as PasswordHistoryView; + const password2 = { + password: "bad-password-2", + lastUsedDate: new Date("02/01/2004"), + } as PasswordHistoryView; beforeEach(async () => { mockCipher.passwordHistory = [password1, password2]; diff --git a/package-lock.json b/package-lock.json index e6d4a0b9b8..b9f661f691 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.225", + "@bitwarden/sdk-internal": "0.2.0-main.227", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4604,9 +4604,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.225", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.225.tgz", - "integrity": "sha512-bhSFNX584GPJ9wMBYff1d18/Hfj+o+D4E1l3uDLZNXRI9s7w919AQWqJ0xUy1vh8gpkLJovkf64HQGqs0OiQQA==", + "version": "0.2.0-main.227", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.227.tgz", + "integrity": "sha512-afOsl9jwi1qyX/tF4bYP3EWXgc8oMgnCA0hPPh+AJpn7GgoAPCi+WXaJkbBPwRpxZFKEpwt3oLRNTvtkECvFJw==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index b7923a92f6..ac00e67431 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.225", + "@bitwarden/sdk-internal": "0.2.0-main.227", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0",