1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 11:13:46 +00:00

[PM-22136] Implement SDK cipher encryption (#15337)

* [PM-22136] Update sdk cipher view map to support uknown uuid type

* [PM-22136] Add key to CipherView for copying to SdkCipherView for encryption

* [PM-22136] Add fromSdk* helpers to Cipher domain objects

* [PM-22136] Add toSdk* helpers to Cipher View objects

* [PM-22136] Add encrypt() to cipher encryption service

* [PM-22136] Add feature flag

* [PM-22136] Use new SDK encrypt method when feature flag is enabled

* [PM-22136] Filter out null/empty URIs

* [PM-22136] Change default value for cipher view arrays to []. See ADR-0014.

* [PM-22136] Keep encrypted key value on attachment so that it is passed to the SDK

* [PM-22136] Keep encrypted key value on CipherView so that it is passed to the SDK during encryption

* [PM-22136] Update failing attachment test

* [PM-22136] Update failing importer tests due to new default value for arrays

* [PM-22136] Update CipherView.fromJson to handle the prototype of EncString for the cipher key

* [PM-22136] Add tickets for followup work

* [PM-22136] Use new set_fido2_credentials SDK method instead

* [PM-22136] Fix missing prototype when decrypting Fido2Credentials

* [PM-22136] Fix test after sdk change

* [PM-22136] Update @bitwarden/sdk-internal version

* [PM-22136] Fix some strict typing errors

* [PM-23348] Migrate move cipher to org to SDK (#15567)

* [PM-23348] Add moveToOrganization method to cipher-encryption.service.ts

* [PM-23348] Use cipherEncryptionService.moveToOrganization in cipherService shareWithServer and shareManyWithServer methods

* [PM-23348] Update cipherFormService to use the shareWithServer() method instead of encrypt()

* [PM-23348] Fix typo

* [PM-23348] Add missing docs

* [PM-22136] Fix EncString import after merge with main
This commit is contained in:
Shane Melton
2025-07-21 23:27:01 -07:00
committed by GitHub
parent 81ee26733e
commit 391f540d1f
43 changed files with 1485 additions and 149 deletions

View File

@@ -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;
}
}

View File

@@ -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<CipherView>);
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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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(),
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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<string>,

View File

@@ -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
};
}
}

View File

@@ -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",
});
});
});
});

View File

@@ -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(),
};
}
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}