1
0
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:
bmbitwarden
2026-01-12 08:13:44 -05:00
committed by GitHub
294 changed files with 13909 additions and 949 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,2 @@
export * from "./auto-confirm-extension-dialog.component";
export * from "./auto-confirm-warning-dialog.component";

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

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

View File

@@ -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", () => {

View File

@@ -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(

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -317,6 +317,7 @@ export class IndividualVaultExportService
const cipher = new CipherWithIdExport();
cipher.build(c);
cipher.collectionIds = null;
delete cipher.key;
jsonDoc.items.push(cipher);
});

View File

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

View File

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

View File

@@ -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(

View File

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