-
-
-
-
-
-
-
+
+
+
+
+ @if (action === "view") {
+
+ }
+ @if (action === "add" || action === "edit" || action === "clone") {
+
-
- {{ "attachments" | i18n }}
-
-
-
-
-
-
+
+
+
+ {{ "attachments" | i18n }}
+
+
+
+
+
+
+ }
+
-
-
-
-
-
+ }
+ @if (!["add", "edit", "view", "clone"].includes(action)) {
+
+
+
+ @if (activeFilter.isArchived && !(hasArchivedCiphers$ | async)) {
+
+
+ {{ "noItemsInArchive" | i18n }}
+
+
+ {{ "noItemsInArchiveDesc" | i18n }}
+
+
+ } @else {
+
+ }
+
-
+ }
diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts
index e3b4493ec7d..896423b28ad 100644
--- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts
+++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts
@@ -18,6 +18,7 @@ import { filter, map, take } from "rxjs/operators";
import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
+import { ItemTypes } from "@bitwarden/assets/svg";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -58,6 +59,7 @@ import {
ToastService,
CopyClickListener,
COPY_CLICK_LISTENER,
+ NoItemsModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
@@ -112,6 +114,7 @@ const BroadcasterSubscriptionId = "VaultComponent";
ButtonModule,
PremiumBadgeComponent,
VaultItemsV2Component,
+ NoItemsModule,
],
providers: [
{
@@ -154,7 +157,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
type: CipherType | null = null;
folderId: string | null | undefined = null;
collectionId: string | null = null;
- organizationId: string | null = null;
+ organizationId: OrganizationId | null = null;
myVaultOnly = false;
addType: CipherType | undefined = undefined;
addOrganizationId: string | null = null;
@@ -168,9 +171,19 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
cipher: CipherView | null = new CipherView();
collections: CollectionView[] | null = null;
config: CipherFormConfig | null = null;
+ private userId$ = this.accountService.activeAccount$.pipe(getUserId);
+ showPremiumCallout$: Observable
= this.userId$.pipe(
+ switchMap((userId) =>
+ combineLatest([
+ this.routedVaultFilterBridgeService.activeFilter$,
+ this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
+ ]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)),
+ ),
+ );
/** Tracks the disabled status of the edit cipher form */
protected formDisabled: boolean = false;
+ protected itemTypesIcon = ItemTypes;
private organizations$: Observable = this.accountService.activeAccount$.pipe(
map((a) => a?.id),
@@ -178,10 +191,9 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
switchMap((id) => this.organizationService.organizations$(id)),
);
- protected canAccessAttachments$ = this.accountService.activeAccount$.pipe(
- filter((account): account is Account => !!account),
- switchMap((account) =>
- this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
+ protected hasArchivedCiphers$ = this.userId$.pipe(
+ switchMap((userId) =>
+ this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)),
),
);
diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts
index 1ec0bb0b22e..a6582f6de58 100644
--- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts
+++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts
@@ -9,7 +9,6 @@ import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angul
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
-import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
@@ -32,7 +31,6 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service"
})
export class VaultItemsV2Component extends BaseVaultItemsComponent {
readonly showPremiumCallout = input(false);
- readonly organizationId = input(undefined);
protected CipherViewLikeUtils = CipherViewLikeUtils;
@@ -55,7 +53,7 @@ export class VaultItemsV2Component extends BaseVaultIt
}
async navigateToGetPremium() {
- await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
+ await this.premiumUpgradePromptService.promptForPremium();
}
trackByFn(index: number, c: C): string {
diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.html b/apps/desktop/src/vault/app/vault/vault-v2.component.html
index 61b7c0ee355..129db673b39 100644
--- a/apps/desktop/src/vault/app/vault/vault-v2.component.html
+++ b/apps/desktop/src/vault/app/vault/vault-v2.component.html
@@ -7,7 +7,6 @@
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
[showPremiumCallout]="showPremiumCallout$ | async"
- [organizationId]="organizationId"
>
@if (!!action) {
diff --git a/apps/web/src/app/auth/emergency-access/request/emergency-access-password.request.ts b/apps/web/src/app/auth/emergency-access/request/emergency-access-password.request.ts
index ba9f1d1bc5a..68b6f4146d8 100644
--- a/apps/web/src/app/auth/emergency-access/request/emergency-access-password.request.ts
+++ b/apps/web/src/app/auth/emergency-access/request/emergency-access-password.request.ts
@@ -1,6 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
+
+import {
+ MasterPasswordAuthenticationData,
+ MasterPasswordUnlockData,
+} from "@bitwarden/common/key-management/master-password/types/master-password.types";
+
// @ts-strict-ignore
export class EmergencyAccessPasswordRequest {
newMasterPasswordHash: string;
key: string;
+
+ // This will eventually be changed to be an actual constructor, once all callers are updated.
+ // The body of this request will be changed to carry the authentication data and unlock data.
+ // https://bitwarden.atlassian.net/browse/PM-23234
+ static newConstructor(
+ authenticationData: MasterPasswordAuthenticationData,
+ unlockData: MasterPasswordUnlockData,
+ ): EmergencyAccessPasswordRequest {
+ const request = new EmergencyAccessPasswordRequest();
+ request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
+ request.key = unlockData.masterKeyWrappedUserKey;
+ return request;
+ }
}
diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
index 05d6094745c..717e21e246c 100644
--- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
+++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
@@ -7,8 +7,17 @@ import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
+import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
+import {
+ MasterKeyWrappedUserKey,
+ MasterPasswordAuthenticationData,
+ MasterPasswordAuthenticationHash,
+ MasterPasswordSalt,
+ MasterPasswordUnlockData,
+} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -18,7 +27,13 @@ import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { newGuid } from "@bitwarden/guid";
-import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
+import {
+ Argon2KdfConfig,
+ DEFAULT_KDF_CONFIG,
+ KdfType,
+ KeyService,
+ PBKDF2KdfConfig,
+} from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type";
@@ -42,6 +57,8 @@ describe("EmergencyAccessService", () => {
let cipherService: MockProxy;
let logService: MockProxy;
let emergencyAccessService: EmergencyAccessService;
+ let masterPasswordService: MockProxy;
+ let configService: MockProxy;
const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
@@ -54,6 +71,8 @@ describe("EmergencyAccessService", () => {
encryptService = mock();
cipherService = mock();
logService = mock();
+ masterPasswordService = mock();
+ configService = mock();
emergencyAccessService = new EmergencyAccessService(
emergencyAccessApiService,
@@ -62,6 +81,8 @@ describe("EmergencyAccessService", () => {
encryptService,
cipherService,
logService,
+ masterPasswordService,
+ configService,
);
});
@@ -215,7 +236,13 @@ describe("EmergencyAccessService", () => {
});
});
- describe("takeover", () => {
+ /**
+ * @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are any imports/properties
+ * in the test setup above that are now un-used and can also be removed.
+ */
+ describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
+ const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = false;
+
const params = {
id: "emergencyAccessId",
masterPassword: "mockPassword",
@@ -242,6 +269,10 @@ describe("EmergencyAccessService", () => {
);
beforeEach(() => {
+ configService.getFeatureFlag.mockResolvedValue(
+ PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
+ );
+
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
@@ -450,6 +481,180 @@ describe("EmergencyAccessService", () => {
});
});
+ describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
+ // Mock feature flag value
+ const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = true;
+
+ // Mock sut method params
+ const id = "emergency-access-id";
+ const masterPassword = "mockPassword";
+ const email = "user@example.com";
+ const activeUserId = newGuid() as UserId;
+
+ // Mock method data
+ const kdfConfig = DEFAULT_KDF_CONFIG;
+
+ const takeoverResponse = {
+ keyEncrypted: "EncryptedKey",
+ kdf: kdfConfig.kdfType,
+ kdfIterations: kdfConfig.iterations,
+ } as EmergencyAccessTakeoverResponse;
+
+ const activeUserPrivateKey = new Uint8Array(64) as UserPrivateKey;
+ let mockGrantorUserKey: UserKey;
+ let salt: MasterPasswordSalt;
+ let authenticationData: MasterPasswordAuthenticationData;
+ let unlockData: MasterPasswordUnlockData;
+
+ beforeEach(() => {
+ configService.getFeatureFlag.mockResolvedValue(
+ PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
+ );
+
+ emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(takeoverResponse);
+ keyService.userPrivateKey$.mockReturnValue(of(activeUserPrivateKey));
+
+ const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
+ encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockDecryptedGrantorUserKey);
+ mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
+
+ salt = email as MasterPasswordSalt;
+ masterPasswordService.emailToSalt.mockReturnValue(salt);
+
+ authenticationData = {
+ salt,
+ kdf: kdfConfig,
+ masterPasswordAuthenticationHash:
+ "masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
+ };
+
+ unlockData = {
+ salt,
+ kdf: kdfConfig,
+ masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
+ } as MasterPasswordUnlockData;
+
+ masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue(
+ authenticationData,
+ );
+ masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
+ });
+
+ it("should throw if active user private key is not found", async () => {
+ // Arrange
+ keyService.userPrivateKey$.mockReturnValue(of(null));
+
+ // Act
+ const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
+
+ // Assert
+ await expect(promise).rejects.toThrow(
+ "Active user does not have a private key, cannot complete a takeover.",
+ );
+ expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
+ });
+
+ it("should throw if the grantor user key cannot be decrypted via the active user private key", async () => {
+ // Arrange
+ encryptService.decapsulateKeyUnsigned.mockResolvedValue(null);
+
+ // Act
+ const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
+
+ // Assert
+ await expect(promise).rejects.toThrow("Failed to decrypt grantor key");
+ expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
+ });
+
+ it("should use PBKDF2 if takeover response contains KdfType.PBKDF2_SHA256", async () => {
+ // Act
+ await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
+
+ // Assert
+ expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
+ masterPassword,
+ kdfConfig, // default config (PBKDF2)
+ salt,
+ );
+ });
+
+ it("should use Argon2 if takeover response contains KdfType.Argon2id", async () => {
+ // Arrange
+ const argon2TakeoverResponse = {
+ keyEncrypted: "EncryptedKey",
+ kdf: KdfType.Argon2id,
+ kdfIterations: 3,
+ kdfMemory: 64,
+ kdfParallelism: 4,
+ } as EmergencyAccessTakeoverResponse;
+
+ emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(
+ argon2TakeoverResponse,
+ );
+
+ const expectedKdfConfig = new Argon2KdfConfig(
+ argon2TakeoverResponse.kdfIterations,
+ argon2TakeoverResponse.kdfMemory,
+ argon2TakeoverResponse.kdfParallelism,
+ );
+
+ // Act
+ await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
+
+ // Assert
+ expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
+ masterPassword,
+ expectedKdfConfig,
+ salt,
+ );
+ expect(masterPasswordService.makeMasterPasswordAuthenticationData).not.toHaveBeenCalledWith(
+ masterPassword,
+ kdfConfig, // default config (PBKDF2)
+ salt,
+ );
+ });
+
+ it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
+ // Act
+ await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
+
+ // Assert
+ const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
+
+ expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
+ masterPassword,
+ kdfConfig,
+ salt,
+ );
+
+ expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
+ masterPassword,
+ kdfConfig,
+ salt,
+ mockGrantorUserKey,
+ );
+
+ expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
+ id,
+ request,
+ );
+ });
+
+ it("should call the API method to change the grantor's master password", async () => {
+ // Act
+ await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
+
+ // Assert
+ const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
+
+ expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledTimes(1);
+ expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
+ id,
+ request,
+ );
+ });
+ });
+
describe("getRotatedData", () => {
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,
diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
index 80b1b27116b..81e7275af23 100644
--- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
+++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
@@ -4,11 +4,19 @@ import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
+import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
+import {
+ MasterPasswordAuthenticationData,
+ MasterPasswordSalt,
+ MasterPasswordUnlockData,
+} from "@bitwarden/common/key-management/master-password/types/master-password.types";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
@@ -56,6 +64,8 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
private encryptService: EncryptService,
private cipherService: CipherService,
private logService: LogService,
+ private masterPasswordService: MasterPasswordServiceAbstraction,
+ private configService: ConfigService,
) {}
/**
@@ -270,7 +280,7 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
* Intended for grantee.
* @param id emergency access id
* @param masterPassword new master password
- * @param email email address of grantee (must be consistent or login will fail)
+ * @param email email address of grantor (must be consistent or login will fail)
* @param activeUserId the user id of the active user
*/
async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
@@ -309,6 +319,36 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
break;
}
+ // When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
+ const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
+ FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
+ );
+
+ if (newApisWithInputPasswordFlagEnabled) {
+ const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
+
+ const authenticationData: MasterPasswordAuthenticationData =
+ await this.masterPasswordService.makeMasterPasswordAuthenticationData(
+ masterPassword,
+ config,
+ salt,
+ );
+
+ const unlockData: MasterPasswordUnlockData =
+ await this.masterPasswordService.makeMasterPasswordUnlockData(
+ masterPassword,
+ config,
+ salt,
+ grantorUserKey,
+ );
+
+ const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
+
+ await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
+
+ return; // EARLY RETURN for flagged logic
+ }
+
const masterKey = await this.keyService.makeMasterKey(masterPassword, email, config);
const masterKeyHash = await this.keyService.hashMasterKey(masterPassword, masterKey);
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index cfcd9df5d4b..df8f6f22263 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -5645,10 +5645,6 @@
"sendTypeText": {
"message": "Text"
},
- "sendPasswordDescV3": {
- "message": "Add an optional password for recipients to access this Send.",
- "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
- },
"createSend": {
"message": "New Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -12783,6 +12779,10 @@
"invalidSendPassword": {
"message": "Invalid Send password"
},
+ "sendPasswordHelperText": {
+ "message": "Individuals will need to enter the password to view this Send",
+ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
+ },
"perUser": {
"message": "per user"
},
diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts
index 28b1f064d89..9e858a6a9d7 100644
--- a/libs/common/src/vault/services/cipher.service.spec.ts
+++ b/libs/common/src/vault/services/cipher.service.spec.ts
@@ -1092,7 +1092,7 @@ describe("Cipher Service", () => {
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
- expect(apiSpy).toHaveBeenCalled();
+ expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId });
});
it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => {
diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts
index 81060870e8b..523a6490fb8 100644
--- a/libs/common/src/vault/services/cipher.service.ts
+++ b/libs/common/src/vault/services/cipher.service.ts
@@ -1422,7 +1422,7 @@ export class CipherService implements CipherServiceAbstraction {
return;
}
- const request = new CipherBulkDeleteRequest(ids);
+ const request = new CipherBulkDeleteRequest(ids, orgId);
if (asAdmin) {
await this.apiService.deleteManyCiphersAdmin(request);
} else {
diff --git a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts
index 4ec20ba2a87..8dbcf29fd2f 100644
--- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts
+++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts
@@ -2,6 +2,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
+import * as sdkInternal from "@bitwarden/sdk-internal";
import { APICredentialsData } from "../spec-data/onepassword-1pux/api-credentials";
import { BankAccountData } from "../spec-data/onepassword-1pux/bank-account";
@@ -25,11 +26,14 @@ import { SanitizedExport } from "../spec-data/onepassword-1pux/sanitized-export"
import { SecureNoteData } from "../spec-data/onepassword-1pux/secure-note";
import { ServerData } from "../spec-data/onepassword-1pux/server";
import { SoftwareLicenseData } from "../spec-data/onepassword-1pux/software-license";
+import { SSH_KeyData } from "../spec-data/onepassword-1pux/ssh-key";
import { SSNData } from "../spec-data/onepassword-1pux/ssn";
import { WirelessRouterData } from "../spec-data/onepassword-1pux/wireless-router";
import { OnePassword1PuxImporter } from "./onepassword-1pux-importer";
+jest.mock("@bitwarden/sdk-internal");
+
function validateCustomField(fields: FieldView[], fieldName: string, expectedValue: any) {
expect(fields).toBeDefined();
const customField = fields.find((f) => f.name === fieldName);
@@ -669,6 +673,37 @@ describe("1Password 1Pux Importer", () => {
validateCustomField(cipher.fields, "medication notes", "multiple times a day");
});
+ it("should parse category 114 - SSH Key", async () => {
+ // Mock the SDK import_ssh_key function to return converted OpenSSH format
+ const mockConvertedKey = {
+ privateKey:
+ "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRswAAAJh8F3bYfBd2\n2AAAAAtzc2gtZWQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRsw\nAAAEA59QYE22f+VFHhiyH1Vfqiwz7xLEt1zCuk8M8Ng5LpKpayncUVVUKwZ3beGxxGQM98\nbMpnzPVX9kH2fNt0MVGzAAAAE3Rlc3RAZXhhbXBsZS5jb20BAgMEBQ==\n-----END OPENSSH PRIVATE KEY-----\n",
+ publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz",
+ fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
+ } as sdkInternal.SshKeyView;
+
+ jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(mockConvertedKey);
+
+ const importer = new OnePassword1PuxImporter();
+ const jsonString = JSON.stringify(SSH_KeyData);
+ const result = await importer.parse(jsonString);
+ expect(result != null).toBe(true);
+ const cipher = result.ciphers.shift();
+ expect(cipher.type).toEqual(CipherType.SshKey);
+ expect(cipher.name).toEqual("Some SSH Key");
+ expect(cipher.notes).toEqual("SSH Key Note");
+
+ // Verify that import_ssh_key was called with the PKCS#8 key from 1Password
+ expect(sdkInternal.import_ssh_key).toHaveBeenCalledWith(
+ "-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
+ );
+
+ // Verify the key was converted to OpenSSH format
+ expect(cipher.sshKey.privateKey).toEqual(mockConvertedKey.privateKey);
+ expect(cipher.sshKey.publicKey).toEqual(mockConvertedKey.publicKey);
+ expect(cipher.sshKey.keyFingerprint).toEqual(mockConvertedKey.fingerprint);
+ });
+
it("should create folders", async () => {
const importer = new OnePassword1PuxImporter();
const result = await importer.parse(SanitizedExportJson);
diff --git a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts
index 4571a6957c4..48de18bc54b 100644
--- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts
+++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts
@@ -8,6 +8,8 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
+import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
+import { import_ssh_key } from "@bitwarden/sdk-internal";
import { ImportResult } from "../../models/import-result";
import { BaseImporter } from "../base-importer";
@@ -80,6 +82,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
cipher.type = CipherType.Identity;
cipher.identity = new IdentityView();
break;
+ case Category.SSH_Key:
+ cipher.type = CipherType.SshKey;
+ cipher.sshKey = new SshKeyView();
+ break;
default:
break;
}
@@ -316,6 +322,19 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
default:
break;
}
+ } else if (cipher.type === CipherType.SshKey) {
+ if (valueKey === "sshKey") {
+ // Use sshKey.metadata.privateKey instead of the sshKey.privateKey field.
+ // The sshKey.privateKey field doesn't have a consistent format for every item.
+ const { privateKey } = field.value.sshKey.metadata;
+ // Convert SSH key from PKCS#8 (1Password format) to OpenSSH format using SDK
+ // Note: 1Password does not store password-protected SSH keys, so no password handling needed for now
+ const parsedKey = import_ssh_key(privateKey);
+ cipher.sshKey.privateKey = parsedKey.privateKey;
+ cipher.sshKey.publicKey = parsedKey.publicKey;
+ cipher.sshKey.keyFingerprint = parsedKey.fingerprint;
+ return;
+ }
}
if (valueKey === "email") {
diff --git a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts
index 43f3bc4f7d6..a24c6489c24 100644
--- a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts
+++ b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts
@@ -49,6 +49,7 @@ export const Category = Object.freeze({
EmailAccount: "111",
API_Credential: "112",
MedicalRecord: "113",
+ SSH_Key: "114",
} as const);
/**
@@ -133,6 +134,7 @@ export interface Value {
creditCardType?: string | null;
creditCardNumber?: string | null;
reference?: string | null;
+ sshKey?: SSHKey | null;
}
export interface Email {
@@ -147,6 +149,19 @@ export interface Address {
zip: string;
state: string;
}
+
+export interface SSHKey {
+ privateKey: string;
+ metadata: SSHKeyMetadata;
+}
+
+export interface SSHKeyMetadata {
+ privateKey: string;
+ publicKey: string;
+ fingerprint: string;
+ keyType: string;
+}
+
export interface InputTraits {
keyboard: string;
correction: string;
diff --git a/libs/importer/src/importers/spec-data/onepassword-1pux/ssh-key.ts b/libs/importer/src/importers/spec-data/onepassword-1pux/ssh-key.ts
new file mode 100644
index 00000000000..3e9cde46271
--- /dev/null
+++ b/libs/importer/src/importers/spec-data/onepassword-1pux/ssh-key.ts
@@ -0,0 +1,83 @@
+import { ExportData } from "../../onepassword/types/onepassword-1pux-importer-types";
+
+export const SSH_KeyData: ExportData = {
+ accounts: [
+ {
+ attrs: {
+ accountName: "1Password Customer",
+ name: "1Password Customer",
+ avatar: "",
+ email: "username123123123@gmail.com",
+ uuid: "TRIZ3XV4JJFRXJ3BARILLTUA6E",
+ domain: "https://my.1password.com/",
+ },
+ vaults: [
+ {
+ attrs: {
+ uuid: "pqcgbqjxr4tng2hsqt5ffrgwju",
+ desc: "Just test entries",
+ avatar: "ke7i5rxnjrh3tj6uesstcosspu.png",
+ name: "T's Test Vault",
+ type: "U",
+ },
+ items: [
+ {
+ uuid: "kf7wevmfiqmbgyao42plvgrasy",
+ favIndex: 0,
+ createdAt: 1724868152,
+ updatedAt: 1724868152,
+ state: "active",
+ categoryUuid: "114",
+ details: {
+ loginFields: [],
+ notesPlain: "SSH Key Note",
+ sections: [
+ {
+ title: "SSH Key Section",
+ fields: [
+ {
+ title: "private key",
+ id: "private_key",
+ value: {
+ sshKey: {
+ privateKey:
+ "-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
+ metadata: {
+ privateKey:
+ "-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
+ publicKey:
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz",
+ fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
+ keyType: "ed25519",
+ },
+ },
+ },
+ guarded: true,
+ multiline: false,
+ dontGenerate: false,
+ inputTraits: {
+ keyboard: "default",
+ correction: "default",
+ capitalization: "default",
+ },
+ },
+ ],
+ hideAddAnotherField: true,
+ },
+ ],
+ passwordHistory: [],
+ },
+ overview: {
+ subtitle: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
+ icons: null,
+ title: "Some SSH Key",
+ url: "",
+ watchtowerExclusions: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html
index 581ee20caf7..dc1894b0935 100644
--- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html
+++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html
@@ -61,6 +61,9 @@
@if (sendDetailsForm.get("authType").value === AuthType.Email) {
{{ "emailVerificationDesc" | i18n }}
}
+ @if (sendDetailsForm.get("authType").value === AuthType.Password) {
+ {{ "sendPasswordHelperText" | i18n }}
+ }
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
@@ -108,7 +111,6 @@
>
}
-
{{ "sendPasswordDescV3" | i18n }}
}
diff --git a/package-lock.json b/package-lock.json
index da9b3e7dcbe..55873bdb40c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -160,7 +160,7 @@
"path-browserify": "1.0.1",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
- "prettier": "3.7.3",
+ "prettier": "3.8.1",
"prettier-plugin-tailwindcss": "0.7.1",
"process": "0.11.10",
"remark-gfm": "4.0.1",
@@ -36683,9 +36683,9 @@
}
},
"node_modules/prettier": {
- "version": "3.7.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz",
- "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
diff --git a/package.json b/package.json
index 20ca9b20f8e..1a72c49d263 100644
--- a/package.json
+++ b/package.json
@@ -127,7 +127,7 @@
"path-browserify": "1.0.1",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
- "prettier": "3.7.3",
+ "prettier": "3.8.1",
"prettier-plugin-tailwindcss": "0.7.1",
"process": "0.11.10",
"remark-gfm": "4.0.1",