mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 00:23:17 +00:00
Merge branch 'main' into PM-29919-Add-dropdown-to-select-email-verification-and-emails-field-to-Send-when-creating-or-editing-a-Send
This commit is contained in:
@@ -194,7 +194,12 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
||||
return this.searchService.searchCiphers(
|
||||
userId,
|
||||
searchText,
|
||||
[filter, this.deletedFilter, this.archivedFilter, restrictedTypeFilter],
|
||||
[
|
||||
filter,
|
||||
this.deletedFilter,
|
||||
...(this.deleted ? [] : [this.archivedFilter]),
|
||||
restrictedTypeFilter,
|
||||
],
|
||||
allCiphers,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -51,7 +51,8 @@ export class VaultFilter {
|
||||
cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
if (this.status === "archive" && cipherPassesFilter) {
|
||||
cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher);
|
||||
cipherPassesFilter =
|
||||
CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
if (this.cipherType != null && cipherPassesFilter) {
|
||||
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
BadgeComponent,
|
||||
ButtonModule,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<bit-simple-dialog dialogSize="small" hideIcon>
|
||||
<div class="tw-flex tw-flex-col tw-justify-start" bitDialogTitle>
|
||||
<div class="tw-flex tw-justify-start tw-pb-2">
|
||||
<span bitBadge variant="info"> {{ "availableNow" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<h3 class="tw-text-start">
|
||||
<strong>
|
||||
{{ "autoConfirmSetup" | i18n }}
|
||||
</strong>
|
||||
</h3>
|
||||
<span class="tw-overflow-y-auto tw-text-start tw-break-words tw-hyphens-auto tw-text-sm">
|
||||
{{ "autoConfirmSetupDesc" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<div class="tw-flex tw-flex-col tw-justify-center">
|
||||
<button
|
||||
class="tw-mb-2"
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="dialogRef.close(true)"
|
||||
>
|
||||
{{ "turnOn" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
class="tw-mb-4"
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
(click)="dialogRef.close(false)"
|
||||
>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
<a
|
||||
class="tw-text-sm tw-text-center"
|
||||
bitLink
|
||||
href="https://bitwarden.com/help/automatic-confirmation/"
|
||||
target="_blank"
|
||||
>
|
||||
<strong class="tw-pr-1">
|
||||
{{ "autoConfirmSetupHint" | i18n }}
|
||||
</strong>
|
||||
<i class="bwi bwi-external-link bwi-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
imports: [ButtonModule, DialogModule, CommonModule, JslibModule, BadgeComponent],
|
||||
})
|
||||
export class AutoConfirmExtensionSetupDialogComponent {
|
||||
constructor(public dialogRef: DialogRef<boolean>) {}
|
||||
|
||||
static open(dialogService: DialogService) {
|
||||
return dialogService.open<boolean>(AutoConfirmExtensionSetupDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
ButtonModule,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@Component({
|
||||
@@ -14,6 +19,8 @@ export class AutoConfirmWarningDialogComponent {
|
||||
constructor(public dialogRef: DialogRef<boolean>) {}
|
||||
|
||||
static open(dialogService: DialogService) {
|
||||
return dialogService.open<boolean>(AutoConfirmWarningDialogComponent);
|
||||
return dialogService.open<boolean>(AutoConfirmWarningDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./auto-confirm-extension-dialog.component";
|
||||
export * from "./auto-confirm-warning-dialog.component";
|
||||
|
||||
@@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
|
||||
if (salt == null) {
|
||||
const bytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
salt = Utils.fromBufferToUtf8(bytes);
|
||||
salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer);
|
||||
}
|
||||
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
|
||||
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Observable } from "rxjs";
|
||||
import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
|
||||
export abstract class CipherArchiveService {
|
||||
abstract hasArchiveFlagEnabled$: Observable<boolean>;
|
||||
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
|
||||
abstract userCanArchive$(userId: UserId): Observable<boolean>;
|
||||
abstract userHasPremium$(userId: UserId): Observable<boolean>;
|
||||
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
|
||||
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
|
||||
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
|
||||
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
|
||||
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ export abstract class CipherEncryptionService {
|
||||
*/
|
||||
abstract encrypt(model: CipherView, userId: UserId): Promise<EncryptionContext | undefined>;
|
||||
|
||||
/**
|
||||
* Encrypts multiple ciphers using the SDK for the given userId.
|
||||
*
|
||||
* @param models The cipher views to encrypt
|
||||
* @param userId The user ID to initialize the SDK client with
|
||||
*
|
||||
* @returns A promise that resolves to an array of encryption contexts
|
||||
*/
|
||||
abstract encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -50,6 +50,15 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
keyForCipherKeyDecryption?: SymmetricCryptoKey,
|
||||
originalCipher?: Cipher,
|
||||
): Promise<EncryptionContext>;
|
||||
/**
|
||||
* Encrypts multiple ciphers for the given user.
|
||||
*
|
||||
* @param models The cipher views to encrypt
|
||||
* @param userId The user ID to encrypt for
|
||||
*
|
||||
* @returns A promise that resolves to an array of encryption contexts
|
||||
*/
|
||||
abstract encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]>;
|
||||
abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise<Field[]>;
|
||||
abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise<Field>;
|
||||
abstract get(id: string, userId: UserId): Promise<Cipher>;
|
||||
|
||||
@@ -109,6 +109,10 @@ export class CipherView implements View, InitializerMetadata {
|
||||
return this.item?.subTitle;
|
||||
}
|
||||
|
||||
get canBeArchived(): boolean {
|
||||
return !this.isDeleted && !this.isArchived;
|
||||
}
|
||||
|
||||
get hasPasswordHistory(): boolean {
|
||||
return this.passwordHistory && this.passwordHistory.length > 0;
|
||||
}
|
||||
|
||||
@@ -340,6 +340,24 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]> {
|
||||
const sdkEncryptionEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM22136_SdkCipherEncryption,
|
||||
);
|
||||
|
||||
if (sdkEncryptionEnabled) {
|
||||
return await this.cipherEncryptionService.encryptMany(models, userId);
|
||||
}
|
||||
|
||||
// Fallback to sequential encryption if SDK disabled
|
||||
const results: EncryptionContext[] = [];
|
||||
for (const model of models) {
|
||||
const result = await this.encrypt(model, userId);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async encryptAttachments(
|
||||
attachmentsModel: AttachmentView[],
|
||||
key: SymmetricCryptoKey,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* include structuredClone in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom, BehaviorSubject } from "rxjs";
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { CipherArchiveService } from "../abstractions/cipher-archive.service";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
|
||||
export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
constructor(
|
||||
@@ -84,15 +85,17 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
);
|
||||
}
|
||||
|
||||
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
|
||||
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
|
||||
const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]);
|
||||
const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true);
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
|
||||
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
// prevent mutating ciphers$ state
|
||||
const localCiphers = structuredClone(currentCiphers);
|
||||
|
||||
for (const cipher of response.data) {
|
||||
const localCipher = currentCiphers[cipher.id as CipherId];
|
||||
const localCipher = localCiphers[cipher.id as CipherId];
|
||||
|
||||
if (localCipher == null) {
|
||||
continue;
|
||||
@@ -102,18 +105,21 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.upsert(Object.values(currentCiphers), userId);
|
||||
await this.cipherService.upsert(Object.values(localCiphers), userId);
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
|
||||
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
|
||||
const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]);
|
||||
const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true);
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
|
||||
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
// prevent mutating ciphers$ state
|
||||
const localCiphers = structuredClone(currentCiphers);
|
||||
|
||||
for (const cipher of response.data) {
|
||||
const localCipher = currentCiphers[cipher.id as CipherId];
|
||||
const localCipher = localCiphers[cipher.id as CipherId];
|
||||
|
||||
if (localCipher == null) {
|
||||
continue;
|
||||
@@ -123,6 +129,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.upsert(Object.values(currentCiphers), userId);
|
||||
await this.cipherService.upsert(Object.values(localCiphers), userId);
|
||||
return response.data[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,68 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptMany", () => {
|
||||
it("should encrypt multiple ciphers", async () => {
|
||||
const cipherView2 = new CipherView(cipherObj);
|
||||
cipherView2.name = "test-name-2";
|
||||
const cipherView3 = new CipherView(cipherObj);
|
||||
cipherView3.name = "test-name-3";
|
||||
|
||||
const ciphers = [cipherViewObj, cipherView2, cipherView3];
|
||||
|
||||
const expectedCipher1: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-1",
|
||||
} as unknown as Cipher;
|
||||
|
||||
const expectedCipher2: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-2",
|
||||
} as unknown as Cipher;
|
||||
|
||||
const expectedCipher3: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-3",
|
||||
} as unknown as Cipher;
|
||||
|
||||
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(Cipher, "fromSdkCipher")
|
||||
.mockReturnValueOnce(expectedCipher1)
|
||||
.mockReturnValueOnce(expectedCipher2)
|
||||
.mockReturnValueOnce(expectedCipher3);
|
||||
|
||||
const results = await cipherEncryptionService.encryptMany(ciphers, userId);
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].cipher).toEqual(expectedCipher1);
|
||||
expect(results[1].cipher).toEqual(expectedCipher2);
|
||||
expect(results[2].cipher).toEqual(expectedCipher3);
|
||||
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(results[0].encryptedFor).toBe(userId);
|
||||
expect(results[1].encryptedFor).toBe(userId);
|
||||
expect(results[2].encryptedFor).toBe(userId);
|
||||
});
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
const results = await cipherEncryptionService.encryptMany([], userId);
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(results.length).toBe(0);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptCipherForRotation", () => {
|
||||
it("should call the sdk method to encrypt the cipher with a new key for rotation", async () => {
|
||||
mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation.mockReturnValue({
|
||||
|
||||
@@ -51,6 +51,44 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
);
|
||||
}
|
||||
|
||||
async encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]> {
|
||||
if (!models || models.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
map((sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
|
||||
using ref = sdk.take();
|
||||
|
||||
const results: EncryptionContext[] = [];
|
||||
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-30580
|
||||
// Replace this loop with a native SDK encryptMany method for better performance.
|
||||
for (const model of models) {
|
||||
const sdkCipherView = this.toSdkCipherView(model, ref.value);
|
||||
const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView);
|
||||
|
||||
results.push({
|
||||
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
|
||||
encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to encrypt ciphers in batch: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async moveToOrganization(
|
||||
model: CipherView,
|
||||
organizationId: OrganizationId,
|
||||
|
||||
@@ -250,6 +250,38 @@ describe("DefaultCipherRiskService", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter out deleted Login ciphers", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const activeCipher = new CipherView();
|
||||
activeCipher.id = mockCipherId1;
|
||||
activeCipher.type = CipherType.Login;
|
||||
activeCipher.login = new LoginView();
|
||||
activeCipher.login.password = "password1";
|
||||
activeCipher.deletedDate = undefined;
|
||||
|
||||
const deletedCipher = new CipherView();
|
||||
deletedCipher.id = mockCipherId2;
|
||||
deletedCipher.type = CipherType.Login;
|
||||
deletedCipher.login = new LoginView();
|
||||
deletedCipher.login.password = "password2";
|
||||
deletedCipher.deletedDate = new Date();
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers([activeCipher, deletedCipher], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
password: "password1",
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPasswordReuseMap", () => {
|
||||
@@ -284,6 +316,41 @@ describe("DefaultCipherRiskService", () => {
|
||||
]);
|
||||
expect(result).toEqual(mockReuseMap);
|
||||
});
|
||||
|
||||
it("should exclude deleted ciphers when building password reuse map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockReuseMap = {
|
||||
password1: 1,
|
||||
};
|
||||
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
|
||||
|
||||
const activeCipher = new CipherView();
|
||||
activeCipher.id = mockCipherId1;
|
||||
activeCipher.type = CipherType.Login;
|
||||
activeCipher.login = new LoginView();
|
||||
activeCipher.login.password = "password1";
|
||||
activeCipher.deletedDate = undefined;
|
||||
|
||||
const deletedCipherWithSamePassword = new CipherView();
|
||||
deletedCipherWithSamePassword.id = mockCipherId2;
|
||||
deletedCipherWithSamePassword.type = CipherType.Login;
|
||||
deletedCipherWithSamePassword.login = new LoginView();
|
||||
deletedCipherWithSamePassword.login.password = "password1";
|
||||
deletedCipherWithSamePassword.deletedDate = new Date();
|
||||
|
||||
const result = await cipherRiskService.buildPasswordReuseMap(
|
||||
[activeCipher, deletedCipherWithSamePassword],
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
]);
|
||||
expect(result).toEqual(mockReuseMap);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCipherRiskForUser", () => {
|
||||
|
||||
@@ -71,7 +71,6 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
passwordMap,
|
||||
checkExposed,
|
||||
});
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
@@ -103,7 +102,8 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
return (
|
||||
cipher.type === CipherType.Login &&
|
||||
cipher.login?.password != null &&
|
||||
cipher.login.password !== ""
|
||||
cipher.login.password !== "" &&
|
||||
!cipher.isDeleted
|
||||
);
|
||||
})
|
||||
.map(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { TypographyDirective } from "../typography/typography.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-header",
|
||||
templateUrl: "./header.component.html",
|
||||
imports: [TypographyDirective],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
})
|
||||
|
||||
@@ -374,10 +374,13 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
|
||||
private async handleIndividualImport(importResult: ImportResult, userId: UserId) {
|
||||
const request = new ImportCiphersRequest();
|
||||
for (let i = 0; i < importResult.ciphers.length; i++) {
|
||||
const c = await this.cipherService.encrypt(importResult.ciphers[i], userId);
|
||||
request.ciphers.push(new CipherRequest(c));
|
||||
|
||||
const encryptedCiphers = await this.cipherService.encryptMany(importResult.ciphers, userId);
|
||||
|
||||
for (const encryptedCipher of encryptedCiphers) {
|
||||
request.ciphers.push(new CipherRequest(encryptedCipher));
|
||||
}
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
if (importResult.folders != null) {
|
||||
@@ -400,11 +403,18 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
userId: UserId,
|
||||
) {
|
||||
const request = new ImportOrganizationCiphersRequest();
|
||||
for (let i = 0; i < importResult.ciphers.length; i++) {
|
||||
importResult.ciphers[i].organizationId = organizationId;
|
||||
const c = await this.cipherService.encrypt(importResult.ciphers[i], userId);
|
||||
request.ciphers.push(new CipherRequest(c));
|
||||
|
||||
// Set organization ID on all ciphers before batch encryption
|
||||
importResult.ciphers.forEach((cipher) => {
|
||||
cipher.organizationId = organizationId;
|
||||
});
|
||||
|
||||
const encryptedCiphers = await this.cipherService.encryptMany(importResult.ciphers, userId);
|
||||
|
||||
for (const encryptedCipher of encryptedCiphers) {
|
||||
request.ciphers.push(new CipherRequest(encryptedCipher));
|
||||
}
|
||||
|
||||
if (importResult.collections != null) {
|
||||
for (let i = 0; i < importResult.collections.length; i++) {
|
||||
importResult.collections[i].organizationId = organizationId;
|
||||
|
||||
@@ -128,18 +128,13 @@ export abstract class KeyService {
|
||||
|
||||
/**
|
||||
* Generates a new user key
|
||||
* @deprecated Interacting with the master key directly is prohibited. Use {@link makeUserKeyV1} instead.
|
||||
* @throws Error when master key is null and there is no active user
|
||||
* @param masterKey The user's master key. When null, grabs master key from active user.
|
||||
* @deprecated Interacting with the master key directly is prohibited.
|
||||
* For new features please use the KM provided SDK methods for user cryptography initialization or reach out to the KM team.
|
||||
* @throws Error when master key is null or undefined.
|
||||
* @param masterKey The user's master key.
|
||||
* @returns A new user key and the master key protected version of it
|
||||
*/
|
||||
abstract makeUserKey(masterKey: MasterKey | null): Promise<[UserKey, EncString]>;
|
||||
/**
|
||||
* Generates a new user key for a V1 user
|
||||
* Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future.
|
||||
* @returns A new user key
|
||||
*/
|
||||
abstract makeUserKeyV1(): Promise<UserKey>;
|
||||
abstract makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]>;
|
||||
/**
|
||||
* Clears the user's stored version of the user key
|
||||
* @param userId The desired user
|
||||
@@ -334,9 +329,9 @@ export abstract class KeyService {
|
||||
abstract getFingerprint(fingerprintMaterial: string, publicKey: Uint8Array): Promise<string[]>;
|
||||
/**
|
||||
* Generates a new keypair
|
||||
* @param key A key to encrypt the private key with. If not provided,
|
||||
* defaults to the user key
|
||||
* @returns A new keypair: [publicKey in Base64, encrypted privateKey]
|
||||
* @deprecated New use-cases of this function are prohibited. Low-level cryptographic constructions and initialization should be done in the SDK.
|
||||
* @param key A symmetric key to wrap the newly created private key with.
|
||||
* @returns A new keypair: [publicKey in Base64, wrapped privateKey]
|
||||
* @throws If the provided key is a null-ish value.
|
||||
*/
|
||||
abstract makeKeyPair(key: SymmetricCryptoKey): Promise<[string, EncString]>;
|
||||
@@ -361,6 +356,8 @@ export abstract class KeyService {
|
||||
/**
|
||||
* Initialize all necessary crypto keys needed for a new account.
|
||||
* Warning! This completely replaces any existing keys!
|
||||
* @deprecated New use cases for cryptography initialization should be done in the SDK.
|
||||
* Current usage is actively being migrated see PM-21771 for details.
|
||||
* @param userId The user id of the target user.
|
||||
* @returns The user's newly created public key, private key, and encrypted private key
|
||||
* @throws An error if the userId is null or undefined.
|
||||
|
||||
@@ -177,6 +177,32 @@ describe("keyService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeUserKey", () => {
|
||||
test.each([null as unknown as MasterKey, undefined as unknown as MasterKey])(
|
||||
"throws when the provided masterKey is %s",
|
||||
async (masterKey) => {
|
||||
await expect(keyService.makeUserKey(masterKey)).rejects.toThrow("MasterKey is required");
|
||||
},
|
||||
);
|
||||
|
||||
it("encrypts the user key with the master key", async () => {
|
||||
const mockUserKey = makeSymmetricCryptoKey<UserKey>(64);
|
||||
const mockEncryptedUserKey = makeEncString("encryptedUserKey");
|
||||
|
||||
keyGenerationService.createKey.mockResolvedValue(mockUserKey);
|
||||
encryptService.wrapSymmetricKey.mockResolvedValue(mockEncryptedUserKey);
|
||||
const stretchedMasterKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
keyGenerationService.stretchKey.mockResolvedValue(stretchedMasterKey);
|
||||
|
||||
const result = await keyService.makeUserKey(makeSymmetricCryptoKey<MasterKey>(32));
|
||||
|
||||
expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockUserKey, stretchedMasterKey);
|
||||
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
|
||||
expect(result[0]).toBe(mockUserKey);
|
||||
expect(result[1]).toBe(mockEncryptedUserKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("everHadUserKey$", () => {
|
||||
let everHadUserKeyState: FakeSingleUserState<boolean>;
|
||||
|
||||
|
||||
@@ -204,28 +204,15 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
return (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId))) != null;
|
||||
}
|
||||
|
||||
async makeUserKey(masterKey: MasterKey | null): Promise<[UserKey, EncString]> {
|
||||
if (masterKey == null) {
|
||||
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
|
||||
if (userId == null) {
|
||||
throw new Error("No active user id found.");
|
||||
}
|
||||
|
||||
masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
|
||||
}
|
||||
if (masterKey == null) {
|
||||
throw new Error("No Master Key found.");
|
||||
async makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]> {
|
||||
if (!masterKey) {
|
||||
throw new Error("MasterKey is required");
|
||||
}
|
||||
|
||||
const newUserKey = await this.keyGenerationService.createKey(512);
|
||||
return this.buildProtectedSymmetricKey(masterKey, newUserKey);
|
||||
}
|
||||
|
||||
async makeUserKeyV1(): Promise<UserKey> {
|
||||
const newUserKey = await this.keyGenerationService.createKey(512);
|
||||
return newUserKey as UserKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key
|
||||
* @param userId The desired user
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("basic-lib generator", () => {
|
||||
expect(tsconfigContent).not.toBeNull();
|
||||
const tsconfig = JSON.parse(tsconfigContent?.toString() ?? "");
|
||||
expect(tsconfig.compilerOptions.paths[`@bitwarden/${options.name}`]).toEqual([
|
||||
`libs/test/src/index.ts`,
|
||||
`./libs/test/src/index.ts`,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ function updateTsConfigPath(tree: Tree, name: string, srcRoot: string) {
|
||||
updateJson(tree, "tsconfig.base.json", (json) => {
|
||||
const paths = json.compilerOptions.paths || {};
|
||||
|
||||
paths[`@bitwarden/${name}`] = [`${srcRoot}/index.ts`];
|
||||
paths[`@bitwarden/${name}`] = [`./${srcRoot}/index.ts`];
|
||||
|
||||
json.compilerOptions.paths = paths;
|
||||
return json;
|
||||
|
||||
@@ -525,6 +525,20 @@ describe("VaultExportService", () => {
|
||||
const exportedData = actual as ExportedVaultAsString;
|
||||
expectEqualFolders(UserFolders, exportedData.data);
|
||||
});
|
||||
|
||||
it("does not export the key property in unencrypted exports", async () => {
|
||||
// Create a cipher with a key property
|
||||
const cipherWithKey = generateCipherView(false);
|
||||
(cipherWithKey as any).key = "shouldBeDeleted";
|
||||
cipherService.getAllDecrypted.mockResolvedValue([cipherWithKey]);
|
||||
|
||||
const actual = await exportService.getExport(userId, "json");
|
||||
expect(typeof actual.data).toBe("string");
|
||||
const exportedData = actual as ExportedVaultAsString;
|
||||
const parsed = JSON.parse(exportedData.data);
|
||||
expect(parsed.items.length).toBe(1);
|
||||
expect(parsed.items[0].key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
export class FolderResponse {
|
||||
|
||||
@@ -317,6 +317,7 @@ export class IndividualVaultExportService
|
||||
const cipher = new CipherWithIdExport();
|
||||
cipher.build(c);
|
||||
cipher.collectionIds = null;
|
||||
delete cipher.key;
|
||||
jsonDoc.items.push(cipher);
|
||||
});
|
||||
|
||||
|
||||
@@ -383,6 +383,7 @@ export class OrganizationVaultExportService
|
||||
decCiphers.forEach((c) => {
|
||||
const cipher = new CipherWithIdExport();
|
||||
cipher.build(c);
|
||||
delete cipher.key;
|
||||
jsonDoc.items.push(cipher);
|
||||
});
|
||||
return JSON.stringify(jsonDoc, null, " ");
|
||||
|
||||
@@ -44,8 +44,10 @@ export const SendItemDialogResult = Object.freeze({
|
||||
} as const);
|
||||
|
||||
/** A result of the Send add/edit dialog. */
|
||||
export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult];
|
||||
|
||||
export type SendItemDialogResult = {
|
||||
result: (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult];
|
||||
send?: SendView;
|
||||
};
|
||||
/**
|
||||
* Component for adding or editing a send item.
|
||||
*/
|
||||
@@ -93,7 +95,7 @@ export class SendAddEditDialogComponent {
|
||||
*/
|
||||
async onSendCreated(send: SendView) {
|
||||
// FIXME Add dialogService.open send-created dialog
|
||||
this.dialogRef.close(SendItemDialogResult.Saved);
|
||||
this.dialogRef.close({ result: SendItemDialogResult.Saved, send });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,14 +103,14 @@ export class SendAddEditDialogComponent {
|
||||
* Handles the event when the send is updated.
|
||||
*/
|
||||
async onSendUpdated(send: SendView) {
|
||||
this.dialogRef.close(SendItemDialogResult.Saved);
|
||||
this.dialogRef.close({ result: SendItemDialogResult.Saved });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the event when the send is deleted.
|
||||
*/
|
||||
async onSendDeleted() {
|
||||
this.dialogRef.close(SendItemDialogResult.Deleted);
|
||||
this.dialogRef.close({ result: SendItemDialogResult.Deleted });
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@@ -24,6 +25,7 @@ describe("ArchiveCipherUtilitiesService", () => {
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher-id" as CipherId;
|
||||
const mockUserId = "user-id";
|
||||
const mockCipherData = { id: mockCipher.id } as CipherData;
|
||||
|
||||
beforeEach(() => {
|
||||
cipherArchiveService = mock<CipherArchiveService>();
|
||||
@@ -37,8 +39,8 @@ describe("ArchiveCipherUtilitiesService", () => {
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
|
||||
cipherArchiveService.archiveWithServer.mockResolvedValue(undefined);
|
||||
cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined);
|
||||
cipherArchiveService.archiveWithServer.mockResolvedValue(mockCipherData);
|
||||
cipherArchiveService.unarchiveWithServer.mockResolvedValue(mockCipherData);
|
||||
i18nService.t.mockImplementation((key) => key);
|
||||
|
||||
service = new ArchiveCipherUtilitiesService(
|
||||
|
||||
@@ -25,11 +25,18 @@ export class ArchiveCipherUtilitiesService {
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
/** Archive a cipher, with confirmation dialog and password reprompt checks. */
|
||||
async archiveCipher(cipher: CipherView) {
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
/** Archive a cipher, with confirmation dialog and password reprompt checks.
|
||||
*
|
||||
* @param cipher The cipher to archive
|
||||
* @param skipReprompt Whether to skip the password reprompt check
|
||||
* @returns The archived CipherData on success, or undefined on failure or cancellation
|
||||
*/
|
||||
async archiveCipher(cipher: CipherView, skipReprompt = false) {
|
||||
if (!skipReprompt) {
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
@@ -43,38 +50,47 @@ export class ArchiveCipherUtilitiesService {
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.cipherArchiveService
|
||||
.archiveWithServer(cipher.id as CipherId, userId)
|
||||
.then(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
try {
|
||||
const cipherResponse = await this.cipherArchiveService.archiveWithServer(
|
||||
cipher.id as CipherId,
|
||||
userId,
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
return cipherResponse;
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unarchives a cipher */
|
||||
/** Unarchives a cipher
|
||||
* @param cipher The cipher to unarchive
|
||||
* @returns The unarchived cipher on success, or undefined on failure
|
||||
*/
|
||||
async unarchiveCipher(cipher: CipherView) {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.cipherArchiveService
|
||||
.unarchiveWithServer(cipher.id as CipherId, userId)
|
||||
.then(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasUnarchived"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
try {
|
||||
const cipherResponse = await this.cipherArchiveService.unarchiveWithServer(
|
||||
cipher.id as CipherId,
|
||||
userId,
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasUnarchived"),
|
||||
});
|
||||
return cipherResponse;
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user