1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-07 19:07:45 +01:00

Merge branch 'main' into autofill/pm-5189-fix-issues-present-with-inline-menu-rendering-in-iframes

This commit is contained in:
Cesar Gonzalez 2024-06-20 13:38:06 -05:00
commit 5e875b29b8
No known key found for this signature in database
GPG Key ID: 3381A5457F8CCECF
141 changed files with 1891 additions and 716 deletions

View File

@ -206,11 +206,29 @@
}
},
{
"files": ["libs/tools/generator/extensions/src/**/*.ts"],
"files": ["libs/tools/generator/extensions/history/src/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{ "patterns": ["@bitwarden/generator-extensions/*", "src/**/*"] }
{ "patterns": ["@bitwarden/generator-history/*", "src/**/*"] }
]
}
},
{
"files": ["libs/tools/generator/extensions/legacy/src/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{ "patterns": ["@bitwarden/generator-legacy/*", "src/**/*"] }
]
}
},
{
"files": ["libs/tools/generator/extensions/navigation/src/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{ "patterns": ["@bitwarden/generator-navigation/*", "src/**/*"] }
]
}
},

View File

@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2024.6.2",
"version": "2024.6.3",
"scripts": {
"build": "cross-env MANIFEST_VERSION=3 webpack",
"build:mv2": "webpack",

View File

@ -12,7 +12,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { BrowserRouterService } from "../../platform/popup/services/browser-router.service";
/**
* This guard verifies the user's authetication status.
* This guard verifies the user's authentication status.
* If "Locked", it saves the intended route in memory and redirects to the lock screen. Otherwise, the intended route is allowed.
*/
export const fido2AuthGuard: CanActivateFn = async (
@ -27,8 +27,10 @@ export const fido2AuthGuard: CanActivateFn = async (
if (authStatus === AuthenticationStatus.Locked) {
// Appending fromLock=true to the query params to indicate that the user is being redirected from the lock screen, this is used for user verification.
const previousUrl = `${state.url}&fromLock=true`;
routerService.setPreviousUrl(previousUrl);
// TODO: Revert to use previousUrl once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
// const previousUrl = `${state.url}&fromLock=true`;
routerService.setPreviousUrl(state.url);
return router.createUrlTree(["/lock"], { queryParams: route.queryParams });
}

View File

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.6.2",
"version": "2024.6.3",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.6.2",
"version": "2024.6.3",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@ -27,6 +27,7 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { ZonedMessageListenerService } from "../../../../platform/browser/zoned-message-listener.service";
import {
@ -59,7 +60,6 @@ export class Fido2Component implements OnInit, OnDestroy {
protected data$: Observable<ViewData>;
protected sessionId?: string;
protected senderTabId?: string;
protected fromLock?: boolean;
protected ciphers?: CipherView[] = [];
protected displayedCiphers?: CipherView[] = [];
protected loading = false;
@ -78,6 +78,7 @@ export class Fido2Component implements OnInit, OnDestroy {
private logService: LogService,
private dialogService: DialogService,
private browserMessagingApi: ZonedMessageListenerService,
private passwordRepromptService: PasswordRepromptService,
private fido2UserVerificationService: Fido2UserVerificationService,
) {}
@ -90,7 +91,6 @@ export class Fido2Component implements OnInit, OnDestroy {
sessionId: queryParamMap.get("sessionId"),
senderTabId: queryParamMap.get("senderTabId"),
senderUrl: queryParamMap.get("senderUrl"),
fromLock: queryParamMap.get("fromLock"),
})),
);
@ -103,7 +103,6 @@ export class Fido2Component implements OnInit, OnDestroy {
this.sessionId = queryParams.sessionId;
this.senderTabId = queryParams.senderTabId;
this.url = queryParams.senderUrl;
this.fromLock = queryParams.fromLock === "true";
// For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
if (
message.type === "NewSessionCreatedRequest" &&
@ -213,11 +212,9 @@ export class Fido2Component implements OnInit, OnDestroy {
protected async submit() {
const data = this.message$.value;
if (data?.type === "PickCredentialRequest") {
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
this.send({
sessionId: this.sessionId,
@ -238,11 +235,9 @@ export class Fido2Component implements OnInit, OnDestroy {
}
}
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
this.send({
sessionId: this.sessionId,
@ -259,21 +254,16 @@ export class Fido2Component implements OnInit, OnDestroy {
const data = this.message$.value;
if (data?.type === "ConfirmNewCredentialRequest") {
const name = data.credentialName || data.rpId;
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
if (!data.userVerification || userVerified) {
await this.createNewCipher(name);
}
// TODO: Revert to check for user verification once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
await this.createNewCipher(name);
// We are bypassing user verification pending approval.
this.send({
sessionId: this.sessionId,
cipherId: this.cipher?.id,
type: "ConfirmNewCredentialResponse",
userVerified,
userVerified: data.userVerification,
});
}
@ -322,7 +312,6 @@ export class Fido2Component implements OnInit, OnDestroy {
uilocation: "popout",
senderTabId: this.senderTabId,
sessionId: this.sessionId,
fromLock: this.fromLock,
userVerification: data.userVerification,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
@ -393,6 +382,20 @@ export class Fido2Component implements OnInit, OnDestroy {
}
}
// TODO: Remove and use fido2 user verification service once user verification for passkeys is approved for production.
private async handleUserVerification(
userVerificationRequested: boolean,
cipher: CipherView,
): Promise<boolean> {
const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0;
if (masterPasswordRepromptRequired) {
return await this.passwordRepromptService.showPasswordPrompt();
}
return userVerificationRequested;
}
private send(msg: BrowserFido2Message) {
BrowserFido2UserInterfaceSession.sendMessage({
sessionId: this.sessionId,

View File

@ -144,6 +144,7 @@
appStopClick
(click)="removePasskey()"
appA11yTitle="{{ 'removePasskey' | i18n }}"
*ngIf="!(!cipher.edit && editMode)"
>
<i class="bwi bwi-fw bwi-minus-circle bwi-lg" aria-hidden="true"></i>
</button>
@ -542,6 +543,7 @@
>
<button
type="button"
*ngIf="!(!cipher.edit && editMode)"
appStopClick
(click)="removeUri(u)"
appA11yTitle="{{ 'remove' | i18n }}"
@ -558,6 +560,7 @@
[hidden]="$any(u).showUriOptionsInput === true"
placeholder="{{ 'ex' | i18n }} https://google.com"
inputmode="url"
[readonly]="!cipher.edit && editMode"
appInputVerbatim
/>
<label for="loginUriMatch{{ i }}" class="sr-only">
@ -607,6 +610,7 @@
appA11yTitle="{{ 'toggleOptions' | i18n }}"
(click)="toggleUriOptions(u)"
[attr.aria-pressed]="$any(u).showOptions === true"
[disabled]="!cipher.edit && editMode"
>
<i class="bwi bwi-fw bwi-lg bwi-cog" aria-hidden="true"></i>
</button>

View File

@ -170,17 +170,14 @@ export class AddEditComponent extends BaseAddEditComponent {
async submit(): Promise<boolean> {
const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$);
const { isFido2Session, sessionId, userVerification, fromLock } = fido2SessionData;
const { isFido2Session, sessionId, userVerification } = fido2SessionData;
const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session;
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
if (
inFido2PopoutWindow &&
userVerification &&
!(await this.fido2UserVerificationService.handleUserVerification(
userVerification,
this.cipher,
fromLock,
))
!(await this.handleFido2UserVerification(sessionId, userVerification))
) {
return false;
}
@ -389,4 +386,13 @@ export class AddEditComponent extends BaseAddEditComponent {
this.load().catch((error) => this.logService.error(error));
}
}
// TODO: Remove and use fido2 user verification service once user verification for passkeys is approved for production.
private async handleFido2UserVerification(
sessionId: string,
userVerification: boolean,
): Promise<boolean> {
// We are bypassing user verification pending approval for production.
return true;
}
}

View File

@ -21,7 +21,9 @@
"@bitwarden/components": ["../../libs/components/src"],
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
"@bitwarden/vault-export-core": [
"../../libs/tools/export/vault-export/vault-export-core/src"
],

View File

@ -127,7 +127,7 @@
appStopClick
(click)="removePasskey()"
appA11yTitle="{{ 'removePasskey' | i18n }}"
[disabled]="!cipher.edit && editMode"
*ngIf="!(!cipher.edit && editMode)"
>
<i class="bwi bwi-fw bwi-minus-circle bwi-lg" aria-hidden="true"></i>
</button>
@ -488,7 +488,7 @@
appStopClick
(click)="removeUri(u)"
appA11yTitle="{{ 'remove' | i18n }}"
[disabled]="!cipher.edit && editMode"
*ngIf="!(!cipher.edit && editMode)"
>
<i class="bwi bwi-minus-circle bwi-lg" aria-hidden="true"></i>
</button>
@ -500,6 +500,7 @@
name="Login.Uris[{{ i }}].Uri"
[(ngModel)]="u.uri"
placeholder="{{ 'ex' | i18n }} https://google.com"
[readonly]="!cipher.edit && editMode"
appInputVerbatim
/>
<label for="loginUriMatch{{ i }}" class="sr-only">
@ -533,6 +534,7 @@
($any(u).showOptions == null && u.match == null)
)
"
[disabled]="!cipher.edit && editMode"
>
<i class="bwi bwi-lg bwi-cog" aria-hidden="true"></i>
</button>

View File

@ -19,7 +19,9 @@
"@bitwarden/components": ["../../libs/components/src"],
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
"@bitwarden/vault-export-core": [
"../../libs/tools/export/vault-export/vault-export-core/src"
],

View File

@ -3,9 +3,9 @@
<p class="tw-text-xl tw-text-center tw-mb-4">{{ "deleteOrganization" | i18n }}</p>
<div class="tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background">
<div class="tw-p-5">
<app-callout type="warning">{{
"deletingOrganizationIsPermanentWarning" | i18n: name
}}</app-callout>
<bit-callout type="warning">
{{ "deletingOrganizationIsPermanentWarning" | i18n: name }}
</bit-callout>
<p class="tw-text-center">
<strong>{{ name }}</strong>
</p>

View File

@ -1,11 +1,11 @@
<bit-dialog dialogSize="large" [title]="'confirmUsers' | i18n" [loading]="loading">
<ng-container bitDialogContent>
<app-callout type="danger" *ngIf="filteredUsers.length <= 0">
<bit-callout type="danger" *ngIf="filteredUsers.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
<app-callout type="error" *ngIf="error">
</bit-callout>
<bit-callout type="danger" [title]="'error' | i18n" *ngIf="error">
{{ error }}
</app-callout>
</bit-callout>
<ng-container *ngIf="!loading && !done">
<p bitTypography="body1">
{{ "fingerprintEnsureIntegrityVerify" | i18n }}

View File

@ -1,18 +1,18 @@
<bit-dialog dialogSize="large" [title]="'removeUsers' | i18n">
<ng-container bitDialogContent>
<app-callout type="danger" *ngIf="users.length <= 0">
<bit-callout type="danger" *ngIf="users.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
<app-callout type="error" *ngIf="error">
</bit-callout>
<bit-callout type="danger" [title]="'error' | i18n" *ngIf="error">
{{ error }}
</app-callout>
</bit-callout>
<ng-container *ngIf="!done">
<app-callout type="warning" *ngIf="users.length > 0 && !error">
<bit-callout type="warning" *ngIf="users.length > 0 && !error">
<p bitTypography="body1">{{ removeUsersWarning }}</p>
<p *ngIf="this.showNoMasterPasswordWarning" bitTypography="body1">
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
</p>
</app-callout>
</bit-callout>
<bit-table>
<ng-container header>
<tr>

View File

@ -48,14 +48,14 @@
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<app-callout
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi bwi-check-circle"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</app-callout>
</bit-callout>
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport scrollWindow [itemSize]="rowHeight" class="tw-pb-8">

View File

@ -13,6 +13,7 @@ import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
@ -157,7 +158,7 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
describe("getRotatedKeys", () => {
describe("getRotatedData", () => {
beforeEach(() => {
organizationService.getAll.mockResolvedValue([
createOrganization("1", "org1"),
@ -175,12 +176,24 @@ describe("OrganizationUserResetPasswordService", () => {
});
it("should return all re-encrypted account recovery keys", async () => {
const result = await sut.getRotatedKeys(
const result = await sut.getRotatedData(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
"mockUserId" as UserId,
);
expect(result).toHaveLength(2);
});
it("throws if the new user key is null", async () => {
await expect(
sut.getRotatedData(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
null,
"mockUserId" as UserId,
),
).rejects.toThrow("New user key is required for rotation.");
});
});
});

View File

@ -1,5 +1,6 @@
import { Injectable } from "@angular/core";
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
@ -19,12 +20,15 @@ import { KdfType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
@Injectable({
providedIn: "root",
})
export class OrganizationUserResetPasswordService {
export class OrganizationUserResetPasswordService
implements UserKeyRotationDataProvider<OrganizationUserResetPasswordWithIdRequest>
{
constructor(
private cryptoService: CryptoService,
private encryptService: EncryptService,
@ -129,11 +133,16 @@ export class OrganizationUserResetPasswordService {
/**
* Returns existing account recovery keys re-encrypted with the new user key.
* @param originalUserKey the original user key
* @param newUserKey the new user key
* @param userId the user id
* @throws Error if new user key is null
* @returns a list of account recovery keys that have been re-encrypted with the new user key
*/
async getRotatedKeys(
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<OrganizationUserResetPasswordWithIdRequest[] | null> {
if (newUserKey == null) {
throw new Error("New user key is required for rotation.");

View File

@ -1,6 +1,6 @@
<app-callout type="warning">
<bit-callout type="warning">
{{ "disableSendExemption" | i18n }}
</app-callout>
</bit-callout>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />

View File

@ -1,6 +1,6 @@
<app-callout type="info" *ngIf="showKeyConnectorInfo">
<bit-callout type="info" *ngIf="showKeyConnectorInfo">
{{ "keyConnectorPolicyRestriction" | i18n }}
</app-callout>
</bit-callout>
<div [formGroup]="data">
<bit-form-control>

View File

@ -1,6 +1,6 @@
<app-callout type="warning">
<bit-callout type="warning">
{{ "personalOwnershipExemption" | i18n }}
</app-callout>
</bit-callout>
<div class="form-group">
<div class="form-check">

View File

@ -1,9 +1,9 @@
<app-callout type="tip" title="{{ 'prerequisite' | i18n }}">
<bit-callout type="info" title="{{ 'prerequisite' | i18n }}">
{{ "requireSsoPolicyReq" | i18n }}
</app-callout>
<app-callout type="warning">
</bit-callout>
<bit-callout type="warning">
{{ "requireSsoExemption" | i18n }}
</app-callout>
</bit-callout>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />

View File

@ -1,6 +1,6 @@
<app-callout type="warning">
<bit-callout type="warning">
{{ "sendOptionsExemption" | i18n }}
</app-callout>
</bit-callout>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />

View File

@ -1,6 +1,6 @@
<app-callout type="warning">
<bit-callout type="warning">
{{ "singleOrgPolicyWarning" | i18n }}
</app-callout>
</bit-callout>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />

View File

@ -1,6 +1,6 @@
<app-callout type="warning">
<bit-callout type="warning">
{{ "twoStepLoginPolicyWarning" | i18n }}
</app-callout>
</bit-callout>
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />

View File

@ -2,9 +2,9 @@
<bit-dialog [loading]="!loaded">
<span bitDialogTitle>{{ "deleteOrganization" | i18n }}</span>
<div bitDialogContent>
<app-callout type="warning">{{
"deletingOrganizationIsPermanentWarning" | i18n: organization?.name
}}</app-callout>
<bit-callout type="warning">
{{ "deletingOrganizationIsPermanentWarning" | i18n: organization?.name }}
</bit-callout>
<p id="organizationDeleteDescription">
<ng-container
*ngIf="

View File

@ -190,7 +190,7 @@ describe("WebauthnAdminService", () => {
it("should throw when old userkey is null", async () => {
const newUserKey = makeSymmetricCryptoKey(64) as UserKey;
try {
await service.rotateWebAuthnKeys(null, newUserKey);
await service.getRotatedData(null, newUserKey, null);
} catch (error) {
expect(error).toEqual(new Error("oldUserKey is required"));
}
@ -198,7 +198,7 @@ describe("WebauthnAdminService", () => {
it("should throw when new userkey is null", async () => {
const oldUserKey = makeSymmetricCryptoKey(64) as UserKey;
try {
await service.rotateWebAuthnKeys(oldUserKey, null);
await service.getRotatedData(oldUserKey, null, null);
} catch (error) {
expect(error).toEqual(new Error("newUserKey is required"));
}
@ -222,7 +222,7 @@ describe("WebauthnAdminService", () => {
.mockResolvedValue(
new RotateableKeySet<PrfKey>(mockEncryptedUserKey, mockEncryptedPublicKey),
);
await service.rotateWebAuthnKeys(oldUserKey, newUserKey);
await service.getRotatedData(oldUserKey, newUserKey, null);
expect(rotateKeySetMock).toHaveBeenCalledWith(
expect.any(RotateableKeySet),
oldUserKey,
@ -242,7 +242,7 @@ describe("WebauthnAdminService", () => {
],
} as any);
const rotateKeySetMock = jest.spyOn(rotateableKeySetService, "rotateKeySet");
await service.rotateWebAuthnKeys(oldUserKey, newUserKey);
await service.getRotatedData(oldUserKey, newUserKey, null);
expect(rotateKeySetMock).not.toHaveBeenCalled();
});
});

View File

@ -1,7 +1,7 @@
import { Injectable, Optional } from "@angular/core";
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";
import { PrfKeySet } from "@bitwarden/auth/common";
import { PrfKeySet, UserKeyRotationDataProvider } from "@bitwarden/auth/common";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
@ -9,6 +9,7 @@ import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/a
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
@ -25,7 +26,9 @@ import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service
/**
* Service for managing WebAuthnLogin credentials.
*/
export class WebauthnLoginAdminService {
export class WebauthnLoginAdminService
implements UserKeyRotationDataProvider<WebauthnRotateCredentialRequest>
{
static readonly MaxCredentialCount = 5;
private navigatorCredentials: CredentialsContainer;
@ -283,11 +286,13 @@ export class WebauthnLoginAdminService {
*
* @param oldUserKey The old user key
* @param newUserKey The new user key
* @param userId The user id
* @returns A promise that returns an array of rotate credential requests when resolved.
*/
async rotateWebAuthnKeys(
async getRotatedData(
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<WebauthnRotateCredentialRequest[]> {
if (!oldUserKey) {
throw new Error("oldUserKey is required");

View File

@ -11,6 +11,7 @@ import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@ -223,8 +224,11 @@ describe("EmergencyAccessService", () => {
});
});
describe("getRotatedKeys", () => {
let mockUserKey: UserKey;
describe("getRotatedData", () => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOriginalUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
const mockNewUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,
EmergencyAccessStatusType.RecoveryInitiated,
@ -242,9 +246,6 @@ describe("EmergencyAccessService", () => {
} as ListResponse<EmergencyAccessGranteeDetailsResponse>;
beforeEach(() => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(mockEmergencyAccess);
apiService.getUserPublicKey.mockResolvedValue({
userId: "mockUserId",
@ -259,10 +260,20 @@ describe("EmergencyAccessService", () => {
});
it("Only returns emergency accesses with allowed statuses", async () => {
const result = await emergencyAccessService.getRotatedKeys(mockUserKey);
const result = await emergencyAccessService.getRotatedData(
mockOriginalUserKey,
mockNewUserKey,
"mockUserId" as UserId,
);
expect(result).toHaveLength(allowedStatuses.length);
});
it("throws if new user key is null", async () => {
await expect(
emergencyAccessService.getRotatedData(mockOriginalUserKey, null, "mockUserId" as UserId),
).rejects.toThrow("New user key is required for rotation.");
});
});
});

View File

@ -1,5 +1,6 @@
import { Injectable } from "@angular/core";
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
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";
@ -15,6 +16,7 @@ import { KdfType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@ -35,7 +37,9 @@ import {
import { EmergencyAccessApiService } from "./emergency-access-api.service";
@Injectable()
export class EmergencyAccessService {
export class EmergencyAccessService
implements UserKeyRotationDataProvider<EmergencyAccessWithIdRequest>
{
constructor(
private emergencyAccessApiService: EmergencyAccessApiService,
private apiService: ApiService,
@ -286,9 +290,21 @@ export class EmergencyAccessService {
/**
* Returns existing emergency access keys re-encrypted with new user key.
* Intended for grantor.
* @param originalUserKey the original user key
* @param newUserKey the new user key
* @param userId the user id
* @throws Error if newUserKey is nullish
* @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
*/
async getRotatedKeys(newUserKey: UserKey): Promise<EmergencyAccessWithIdRequest[]> {
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<EmergencyAccessWithIdRequest[]> {
if (newUserKey == null) {
throw new Error("New user key is required for rotation.");
}
const requests: EmergencyAccessWithIdRequest[] = [];
const existingEmergencyAccess =
await this.emergencyAccessApiService.getEmergencyAccessTrusted();

View File

@ -1,37 +1,30 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { BehaviorSubject } from "rxjs";
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { UserKey, UserPrivateKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
import {
FakeAccountService,
mockAccountServiceWith,
} from "../../../../../../libs/common/spec/fake-account-service";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { StateService } from "../../core";
import { WebauthnLoginAdminService } from "../core";
import { EmergencyAccessService } from "../emergency-access";
import { EmergencyAccessWithIdRequest } from "../emergency-access/request/emergency-access-update.request";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
import { UserKeyRotationService } from "./user-key-rotation.service";
@ -48,16 +41,19 @@ describe("KeyRotationService", () => {
let mockDeviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
let mockCryptoService: MockProxy<CryptoService>;
let mockEncryptService: MockProxy<EncryptService>;
let mockStateService: MockProxy<StateService>;
let mockConfigService: MockProxy<ConfigService>;
let mockKdfConfigService: MockProxy<KdfConfigService>;
let mockSyncService: MockProxy<SyncService>;
let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>;
const mockUserId = Utils.newGuid() as UserId;
const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId);
let mockMasterPasswordService: FakeMasterPasswordService = new FakeMasterPasswordService();
const mockUser = {
id: "mockUserId" as UserId,
email: "mockEmail",
emailVerified: true,
name: "mockName",
};
beforeAll(() => {
mockMasterPasswordService = new FakeMasterPasswordService();
mockApiService = mock<UserKeyRotationApiService>();
@ -69,7 +65,6 @@ describe("KeyRotationService", () => {
mockDeviceTrustService = mock<DeviceTrustServiceAbstraction>();
mockCryptoService = mock<CryptoService>();
mockEncryptService = mock<EncryptService>();
mockStateService = mock<StateService>();
mockConfigService = mock<ConfigService>();
mockKdfConfigService = mock<KdfConfigService>();
mockSyncService = mock<SyncService>();
@ -86,8 +81,6 @@ describe("KeyRotationService", () => {
mockDeviceTrustService,
mockCryptoService,
mockEncryptService,
mockStateService,
mockAccountService,
mockKdfConfigService,
mockSyncService,
mockWebauthnLoginAdminService,
@ -98,91 +91,82 @@ describe("KeyRotationService", () => {
jest.clearAllMocks();
});
it("instantiates", () => {
expect(keyRotationService).not.toBeFalsy();
});
describe("rotateUserKeyAndEncryptedData", () => {
let folderViews: BehaviorSubject<FolderView[]>;
let sends: BehaviorSubject<Send[]>;
let privateKey: BehaviorSubject<UserPrivateKey>;
beforeAll(() => {
beforeEach(() => {
mockCryptoService.makeMasterKey.mockResolvedValue("mockMasterKey" as any);
mockCryptoService.makeUserKey.mockResolvedValue([
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
{
encryptedString: "mockEncryptedUserKey",
encryptedString: "mockNewUserKey",
} as any,
]);
mockCryptoService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
mockConfigService.getFeatureFlag.mockResolvedValue(true);
// Mock private key
mockCryptoService.getPrivateKey.mockResolvedValue("MockPrivateKey" as any);
mockCryptoService.userKey$.mockReturnValue(
of(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey),
);
// Mock ciphers
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
// Mock folders
const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
folderViews = new BehaviorSubject<FolderView[]>(mockFolders);
mockFolderService.folderViews$ = folderViews;
// Mock sends
const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
sends = new BehaviorSubject<Send[]>(mockSends);
mockSendService.sends$ = sends;
mockWebauthnLoginAdminService.rotateWebAuthnKeys.mockResolvedValue([]);
// Mock encryption methods
mockEncryptService.encrypt.mockResolvedValue({
encryptedString: "mockEncryptedData",
} as any);
mockFolderService.encrypt.mockImplementation((folder, userKey) => {
const encryptedFolder = new Folder();
encryptedFolder.id = folder.id;
encryptedFolder.name = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"Encrypted: " + folder.name,
);
return Promise.resolve(encryptedFolder);
});
// Mock user key
mockCryptoService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
mockCipherService.encrypt.mockImplementation((cipher, userKey) => {
const encryptedCipher = new Cipher();
encryptedCipher.id = cipher.id;
encryptedCipher.name = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"Encrypted: " + cipher.name,
);
return Promise.resolve(encryptedCipher);
});
// Mock private key
privateKey = new BehaviorSubject("mockPrivateKey" as any);
mockCryptoService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
// Mock ciphers
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
// Mock folders
const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
mockFolderService.getRotatedData.mockResolvedValue(mockFolders);
// Mock sends
const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
mockSendService.getRotatedData.mockResolvedValue(mockSends);
// Mock emergency access
const emergencyAccess = [createMockEmergencyAccess("13")];
mockEmergencyAccessService.getRotatedData.mockResolvedValue(emergencyAccess);
// Mock reset password
const resetPassword = [createMockResetPassword("12")];
mockResetPasswordService.getRotatedData.mockResolvedValue(resetPassword);
// Mock Webauthn
const webauthn = [createMockWebauthn("13"), createMockWebauthn("14")];
mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue(webauthn);
});
it("rotates the user key and encrypted data", async () => {
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword");
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser);
expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled();
const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0];
expect(arg.key).toBe("mockNewUserKey");
expect(arg.privateKey).toBe("mockEncryptedData");
expect(arg.ciphers.length).toBe(2);
expect(arg.folders.length).toBe(2);
expect(arg.sends.length).toBe(2);
expect(arg.emergencyAccessKeys.length).toBe(1);
expect(arg.resetPasswordKeys.length).toBe(1);
expect(arg.webauthnKeys.length).toBe(2);
});
it("throws if master password provided is falsey", async () => {
await expect(keyRotationService.rotateUserKeyAndEncryptedData("")).rejects.toThrow();
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("", mockUser),
).rejects.toThrow();
});
it("throws if master key creation fails", async () => {
mockCryptoService.makeMasterKey.mockResolvedValueOnce(null);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
).rejects.toThrow();
});
@ -190,16 +174,24 @@ describe("KeyRotationService", () => {
mockCryptoService.makeUserKey.mockResolvedValueOnce([null, null]);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
).rejects.toThrow();
});
it("throws if no private key is found", async () => {
privateKey.next(null);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
).rejects.toThrow();
});
it("saves the master key in state after creation", async () => {
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword");
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser);
expect(mockMasterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
"mockMasterKey" as any,
mockUserId,
mockUser.id,
);
});
@ -207,30 +199,51 @@ describe("KeyRotationService", () => {
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
).rejects.toThrow();
});
});
});
function createMockFolder(id: string, name: string): FolderView {
const folder = new FolderView();
folder.id = id;
folder.name = name;
return folder;
function createMockFolder(id: string, name: string): FolderWithIdRequest {
return {
id: id,
name: name,
} as FolderWithIdRequest;
}
function createMockCipher(id: string, name: string): CipherView {
const cipher = new CipherView();
cipher.id = id;
cipher.name = name;
cipher.type = CipherType.Login;
return cipher;
function createMockCipher(id: string, name: string): CipherWithIdRequest {
return {
id: id,
name: name,
type: CipherType.Login,
} as CipherWithIdRequest;
}
function createMockSend(id: string, name: string): Send {
const send = new Send();
send.id = id;
send.name = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, name);
return send;
function createMockSend(id: string, name: string): SendWithIdRequest {
return {
id: id,
name: name,
} as SendWithIdRequest;
}
function createMockEmergencyAccess(id: string): EmergencyAccessWithIdRequest {
return {
id: id,
type: 0,
waitTimeDays: 5,
} as EmergencyAccessWithIdRequest;
}
function createMockResetPassword(id: string): OrganizationUserResetPasswordWithIdRequest {
return {
organizationId: id,
resetPasswordKey: "mockResetPasswordKey",
} as OrganizationUserResetPasswordWithIdRequest;
}
function createMockWebauthn(id: string): any {
return {
id: id,
} as WebauthnRotateCredentialRequest;
}

View File

@ -1,21 +1,19 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../core";
@ -37,8 +35,6 @@ export class UserKeyRotationService {
private deviceTrustService: DeviceTrustServiceAbstraction,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private stateService: StateService,
private accountService: AccountService,
private kdfConfigService: KdfConfigService,
private syncService: SyncService,
private webauthnLoginAdminService: WebauthnLoginAdminService,
@ -48,7 +44,10 @@ export class UserKeyRotationService {
* Creates a new user key and re-encrypts all required data with the it.
* @param masterPassword current master password (used for validation)
*/
async rotateUserKeyAndEncryptedData(masterPassword: string): Promise<void> {
async rotateUserKeyAndEncryptedData(
masterPassword: string,
user: { id: UserId } & AccountInfo,
): Promise<void> {
if (!masterPassword) {
throw new Error("Invalid master password");
}
@ -62,7 +61,7 @@ export class UserKeyRotationService {
// Create master key to validate the master password
const masterKey = await this.cryptoService.makeMasterKey(
masterPassword,
await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email))),
user.email,
await this.kdfConfigService.getKdfConfig(),
);
@ -71,9 +70,7 @@ export class UserKeyRotationService {
}
// Set master key again in case it was lost (could be lost on refresh)
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const oldUserKey = await firstValueFrom(this.cryptoService.userKey$(userId));
await this.masterPasswordService.setMasterKey(masterKey, userId);
await this.masterPasswordService.setMasterKey(masterKey, user.id);
const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey);
if (!newUserKey || !newEncUserKey) {
@ -90,61 +87,86 @@ export class UserKeyRotationService {
const masterPasswordHash = await this.cryptoService.hashMasterKey(masterPassword, masterKey);
request.masterPasswordHash = masterPasswordHash;
// Get original user key
// Note: We distribute the legacy key, but not all domains actually use it. If any of those
// domains break their legacy support it will break the migration process for legacy users.
const originalUserKey = await this.cryptoService.getUserKeyWithLegacySupport(user.id);
// Add re-encrypted data
request.privateKey = await this.encryptPrivateKey(newUserKey);
request.ciphers = await this.encryptCiphers(newUserKey);
request.folders = await this.encryptFolders(newUserKey);
request.sends = await this.sendService.getRotatedKeys(newUserKey);
request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey);
request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey);
request.webauthnKeys = await this.webauthnLoginAdminService.rotateWebAuthnKeys(
oldUserKey,
request.privateKey = await this.encryptPrivateKey(newUserKey, user.id);
const rotatedCiphers = await this.cipherService.getRotatedData(
originalUserKey,
newUserKey,
user.id,
);
if (rotatedCiphers != null) {
request.ciphers = rotatedCiphers;
}
const rotatedFolders = await this.folderService.getRotatedData(
originalUserKey,
newUserKey,
user.id,
);
if (rotatedFolders != null) {
request.folders = rotatedFolders;
}
const rotatedSends = await this.sendService.getRotatedData(
originalUserKey,
newUserKey,
user.id,
);
if (rotatedSends != null) {
request.sends = rotatedSends;
}
const rotatedEmergencyAccessKeys = await this.emergencyAccessService.getRotatedData(
originalUserKey,
newUserKey,
user.id,
);
if (rotatedEmergencyAccessKeys != null) {
request.emergencyAccessKeys = rotatedEmergencyAccessKeys;
}
// Note: Reset password keys request model has user verification
// properties, but the rotation endpoint uses its own MP hash.
const rotatedResetPasswordKeys = await this.resetPasswordService.getRotatedData(
originalUserKey,
newUserKey,
user.id,
);
if (rotatedResetPasswordKeys != null) {
request.resetPasswordKeys = rotatedResetPasswordKeys;
}
const rotatedWebauthnKeys = await this.webauthnLoginAdminService.getRotatedData(
originalUserKey,
newUserKey,
user.id,
);
if (rotatedWebauthnKeys != null) {
request.webauthnKeys = rotatedWebauthnKeys;
}
await this.apiService.postUserKeyUpdate(request);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustService.rotateDevicesTrust(
activeAccount.id,
newUserKey,
masterPasswordHash,
);
// TODO PM-2199: Add device trust rotation support to the user key rotation endpoint
await this.deviceTrustService.rotateDevicesTrust(user.id, newUserKey, masterPasswordHash);
}
private async encryptPrivateKey(newUserKey: UserKey): Promise<EncryptedString | null> {
const privateKey = await this.cryptoService.getPrivateKey();
private async encryptPrivateKey(
newUserKey: UserKey,
userId: UserId,
): Promise<EncryptedString | null> {
const privateKey = await firstValueFrom(
this.cryptoService.userPrivateKeyWithLegacySupport$(userId),
);
if (!privateKey) {
return;
throw new Error("No private key found for user key rotation");
}
return (await this.encryptService.encrypt(privateKey, newUserKey)).encryptedString;
}
private async encryptCiphers(newUserKey: UserKey): Promise<CipherWithIdRequest[]> {
const ciphers = await this.cipherService.getAllDecrypted();
if (!ciphers) {
// Must return an empty array for backwards compatibility
return [];
}
return await Promise.all(
ciphers.map(async (cipher) => {
const encryptedCipher = await this.cipherService.encrypt(cipher, newUserKey);
return new CipherWithIdRequest(encryptedCipher);
}),
);
}
private async encryptFolders(newUserKey: UserKey): Promise<FolderWithIdRequest[]> {
const folders = await firstValueFrom(this.folderService.folderViews$);
if (!folders) {
// Must return an empty array for backwards compatibility
return [];
}
return await Promise.all(
folders.map(async (folder) => {
const encryptedFolder = await this.folderService.encrypt(folder, newUserKey);
return new FolderWithIdRequest(encryptedFolder);
}),
);
}
}

View File

@ -1,6 +1,8 @@
import { Component } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@ -25,6 +27,7 @@ export class MigrateFromLegacyEncryptionComponent {
});
constructor(
private accountService: AccountService,
private keyRotationService: UserKeyRotationService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
@ -41,7 +44,9 @@ export class MigrateFromLegacyEncryptionComponent {
return;
}
const hasUserKey = await this.cryptoService.hasUserKey();
const activeUser = await firstValueFrom(this.accountService.activeAccount$);
const hasUserKey = await this.cryptoService.hasUserKey(activeUser.id);
if (hasUserKey) {
this.messagingService.send("logout");
throw new Error("User key already exists, cannot migrate legacy encryption.");
@ -52,7 +57,7 @@ export class MigrateFromLegacyEncryptionComponent {
try {
await this.syncService.fullSync(false, true);
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword);
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword, activeUser);
this.platformUtilsService.showToast(
"success",

View File

@ -14,7 +14,7 @@
bitButton
type="button"
buttonType="primary"
class="tw-w-full"
class="tw-w-full tw-mb-2"
[bitAction]="convert"
[block]="true"
>

View File

@ -4,9 +4,6 @@
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</div>
<app-callout type="error" *ngIf="error">
{{ error }}
</app-callout>
<p class="tw-text-lg">{{ "pickAnAvatarColor" | i18n }}</p>
<div class="tw-flex tw-flex-wrap tw-justify-center tw-gap-8">
<ng-container *ngFor="let c of defaultColorPalette">

View File

@ -31,7 +31,6 @@ export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
@ViewChild("colorPicker") colorPickerElement: ElementRef<HTMLElement>;
loading = false;
error: string;
defaultColorPalette: NamedAvatarColor[] = [
{ name: "brightBlue", color: "#16cbfc" },
{ name: "green", color: "#94cc4b" },

View File

@ -1,7 +1,7 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<app-callout type="warning" *ngIf="showTwoFactorEmailWarning">
<bit-callout type="warning" *ngIf="showTwoFactorEmailWarning">
{{ "changeEmailTwoFactorWarning" | i18n }}
</app-callout>
</bit-callout>
<div class="tw-w-1/2 tw-pr-2" formGroupName="step1">
<bit-form-field>
@ -29,7 +29,7 @@
<ng-container *ngIf="tokenSent">
<hr />
<p>{{ "changeEmailDesc" | i18n: formGroup.controls.step1.value.newEmail }}</p>
<app-callout type="warning">{{ "loggedOutWarning" | i18n }}</app-callout>
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>
<div class="tw-w-1/2 tw-pr-2">
<bit-form-field>

View File

@ -20,7 +20,7 @@
</div>
<div class="modal-body">
<p>{{ "deauthorizeSessionsDesc" | i18n }}</p>
<app-callout type="warning">{{ "deauthorizeSessionsWarning" | i18n }}</app-callout>
<bit-callout type="warning">{{ "deauthorizeSessionsWarning" | i18n }}</bit-callout>
<app-user-verification [(ngModel)]="masterPassword" ngDefaultControl name="secret">
</app-user-verification>
</div>

View File

@ -2,7 +2,7 @@
<bit-dialog dialogSize="default" [title]="'deleteAccount' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ "deleteAccountDesc" | i18n }}</p>
<app-callout type="warning">{{ "deleteAccountWarning" | i18n }}</app-callout>
<bit-callout type="warning">{{ "deleteAccountWarning" | i18n }}</bit-callout>
<app-user-verification-form-input
formControlName="verification"
name="verification"

View File

@ -220,6 +220,7 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
}
private async updateKey() {
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword);
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user);
}
}

View File

@ -5,7 +5,7 @@
<small class="tw-text-muted" *ngIf="params.name">{{ params.name }}</small>
</span>
<div bitDialogContent>
<app-callout type="warning">{{ "loggedOutWarning" | i18n }}</app-callout>
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<div class="tw-w-full tw-flex tw-gap-4">

View File

@ -5,11 +5,11 @@
<p bitTypography="body1">{{ data.apiKeyDescription | i18n }}</p>
<app-user-verification-form-input formControlName="masterPassword" *ngIf="!clientSecret">
</app-user-verification-form-input>
<app-callout type="warning" *ngIf="clientSecret">{{ data.apiKeyWarning | i18n }}</app-callout>
<app-callout
<bit-callout type="warning" *ngIf="clientSecret">{{ data.apiKeyWarning | i18n }}</bit-callout>
<bit-callout
type="info"
title="{{ 'oauth2ClientCredentials' | i18n }}"
icon="bwi bwi-key"
icon="bwi-key"
*ngIf="clientSecret"
>
<p bitTypography="body1" class="tw-mb-1">
@ -28,7 +28,7 @@
<strong>grant_type:</strong><br />
<code>{{ data.grantType }}</code>
</p>
</app-callout>
</bit-callout>
</div>
<div bitDialogFooter>
<button type="submit" buttonType="primary" *ngIf="!clientSecret" bitButton bitFormButton>

View File

@ -1,7 +1,7 @@
<form *ngIf="authed" [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default">
<span bitDialogTitle
>{{ "twoStepLogin" | i18n }}
<span bitDialogTitle>
{{ "twoStepLogin" | i18n }}
<span bitTypography="body1">{{ "authenticatorAppTitle" | i18n }}</span>
</span>
<ng-container bitDialogContent>
@ -13,10 +13,10 @@
</p>
</ng-container>
<ng-container *ngIf="enabled">
<app-callout type="success" title="{{ 'enabled' | i18n }}" icon="bwi bwi-check-circle">
<bit-callout type="success" title="{{ 'enabled' | i18n }}" icon="bwi-check-circle">
<p bitTypography="body1">{{ "twoStepLoginProviderEnabled" | i18n }}</p>
{{ "twoStepAuthenticatorReaddDesc" | i18n }}
</app-callout>
</bit-callout>
<img class="float-right mfaType0" alt="Authenticator app logo" />
<p bitTypography="body1">{{ "twoStepAuthenticatorNeedApp" | i18n }}</p>
</ng-container>

View File

@ -6,9 +6,9 @@
</span>
<ng-container bitDialogContent>
<ng-container *ngIf="enabled">
<app-callout type="success" title="{{ 'enabled' | i18n }}" icon="bwi bwi-check-circle">
<bit-callout type="success" title="{{ 'enabled' | i18n }}" icon="bwi-check-circle">
{{ "twoStepLoginProviderEnabled" | i18n }}
</app-callout>
</bit-callout>
<strong>{{ "email" | i18n }}:</strong> {{ email }}
</ng-container>
<ng-container *ngIf="!enabled">

View File

@ -455,7 +455,9 @@
</ng-container>
</bit-section>
<bit-section *ngIf="singleOrgPolicyBlock">
<app-callout [type]="'error'">{{ "singleOrgBlockCreateMessage" | i18n }}</app-callout>
<bit-callout type="danger" [title]="'error' | i18n">
{{ "singleOrgBlockCreateMessage" | i18n }}
</bit-callout>
</bit-section>
<bit-section>
<button

View File

@ -63,9 +63,9 @@
</div>
</ng-container>
<ng-container *ngIf="showMethods && method === paymentMethodType.BankAccount">
<app-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "verifyBankAccountInitialDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}
</app-callout>
</bit-callout>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" formGroupName="bank">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "routingNumber" | i18n }}</bit-label>
@ -106,8 +106,8 @@
</div>
</ng-container>
<ng-container *ngIf="showMethods && method === paymentMethodType.Credit">
<app-callout type="note">
<bit-callout>
{{ "makeSureEnoughCredit" | i18n }}
</app-callout>
</bit-callout>
</ng-container>
</div>

View File

@ -3,7 +3,7 @@
<bit-container>
<p bitTypography="body1">{{ "preferencesDesc" | i18n }}</p>
<form [formGroup]="form" [bitSubmit]="submit" class="tw-w-1/2">
<app-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
<bit-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
<span *ngIf="policy.timeout && policy.action">
{{
"vaultTimeoutPolicyWithActionInEffect"
@ -16,7 +16,7 @@
<span *ngIf="!policy.timeout && policy.action">
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
</span>
</app-callout>
</bit-callout>
<app-vault-timeout-input
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"

View File

@ -20,9 +20,9 @@
</button>
</div>
<div class="modal-body" *ngIf="cipher">
<app-callout type="info" *ngIf="allowOwnershipAssignment() && !allowPersonal">
<bit-callout type="info" *ngIf="allowOwnershipAssignment() && !allowPersonal">
{{ "personalOwnershipPolicyInEffect" | i18n }}
</app-callout>
</bit-callout>
<div class="row" *ngIf="!editMode && !viewOnly">
<div class="col-6 form-group">
<label for="type">{{ "whatTypeOfItem" | i18n }}</label>

View File

@ -94,6 +94,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
async ngOnInit() {
await super.ngOnInit();
await this.load();
this.viewOnly = !this.cipher.edit && this.editMode;
// remove when all the title for all clients are updated to New Item
if (this.cloneMode || !this.editMode) {
this.title = this.i18nService.t("newItem");

View File

@ -33,9 +33,9 @@
</div>
</div>
<div class="tw-basis-3/4 tw-max-w-3/4 tw-px-2.5">
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</app-callout>
</bit-callout>
<app-vault-items
[ciphers]="ciphers"
[collections]="collections"

View File

@ -41,9 +41,9 @@
{{ "addAccess" | i18n }}
</bit-toggle>
</bit-toggle-group>
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi bwi-exclamation-triangle">
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</app-callout>
</bit-callout>
<app-vault-items
[ciphers]="ciphers"
[collections]="collections"

View File

@ -4,7 +4,7 @@
<p bitTypography="body1">
{{ (organizationId ? "purgeOrgVaultDesc" : "purgeVaultDesc") | i18n }}
</p>
<app-callout type="warning">{{ "purgeVaultWarning" | i18n }}</app-callout>
<bit-callout type="warning">{{ "purgeVaultWarning" | i18n }}</bit-callout>
<app-user-verification formControlName="masterPassword"></app-user-verification>
</ng-container>
<ng-container bitDialogFooter>

View File

@ -8429,5 +8429,23 @@
},
"providerReinstate":{
"message": " Contact Customer Support to reinstate your subscription."
},
"secretPeopleDescription": {
"message": "Grant groups or people access to this secret. Permissions set for people will override permissions set by groups."
},
"secretPeopleEmptyMessage": {
"message": "Add people or groups to share access to this secret"
},
"secretMachineAccountsDescription": {
"message": "Grant machine accounts access to this secret."
},
"secretMachineAccountsEmptyMessage": {
"message": "Add machine accounts to grant access to this secret"
},
"smAccessRemovalWarningSecretTitle": {
"message": "Remove access to this secret"
},
"smAccessRemovalSecretMessage": {
"message": "This action will remove your access to this secret."
}
}

View File

@ -14,7 +14,9 @@
"@bitwarden/components": ["../../libs/components/src"],
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
"@bitwarden/vault-export-core": [
"../../libs/tools/export/vault-export/vault-export-core/src"
],

View File

@ -13,7 +13,9 @@
"@bitwarden/components": ["../../libs/components/src"],
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
"@bitwarden/vault-export-core": [
"../../libs/tools/export/vault-export/vault-export-core/src"
],

View File

@ -1,9 +1,9 @@
<app-callout type="warning">
<bit-callout type="warning">
{{ "experimentalFeature" | i18n }}
<a href="https://bitwarden.com/help/auto-fill-browser/" target="_blank" rel="noreferrer">{{
"learnMoreAboutAutofill" | i18n
}}</a>
</app-callout>
</bit-callout>
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />

View File

@ -1,6 +1,6 @@
<app-callout type="tip" title="{{ 'prerequisite' | i18n }}">
<bit-callout title="{{ 'prerequisite' | i18n }}">
{{ "requireSsoPolicyReq" | i18n }}
</app-callout>
</bit-callout>
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />

View File

@ -106,9 +106,9 @@
showKeyConnectorOptions
"
>
<app-callout type="warning" [useAlertRole]="true">
<bit-callout type="warning" [useAlertRole]="true">
{{ "keyConnectorWarning" | i18n }}
</app-callout>
</bit-callout>
<bit-form-field>
<bit-label>{{ "keyConnectorUrl" | i18n }}</bit-label>

View File

@ -1,71 +1,102 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large">
<ng-container bitDialogTitle>{{ title | i18n }}</ng-container>
<bit-dialog
dialogSize="large"
[disablePadding]="true"
[loading]="loading"
[title]="title | i18n"
[subtitle]="subtitle"
>
<div bitDialogContent class="tw-relative">
<div
*ngIf="showSpinner"
class="tw-absolute tw-flex tw-h-full tw-w-full tw-items-center tw-justify-center tw-bg-text-contrast"
>
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
<div class="tw-flex tw-gap-4 tw-pt-4">
<bit-form-field class="tw-w-1/3">
<bit-label for="secret-name">{{ "name" | i18n }}</bit-label>
<input appAutofocus formControlName="name" bitInput />
</bit-form-field>
<bit-form-field class="tw-w-full">
<bit-label>{{ "value" | i18n }}</bit-label>
<textarea bitInput rows="4" formControlName="value"></textarea>
</bit-form-field>
</div>
<bit-form-field>
<bit-label>{{ "notes" | i18n }}</bit-label>
<textarea bitInput rows="4" formControlName="notes"></textarea>
</bit-form-field>
<bit-tab-group [(selectedIndex)]="tabIndex">
<bit-tab label="{{ 'nameValuePair' | i18n }}">
<div class="tw-flex tw-gap-4 tw-pt-4">
<bit-form-field class="tw-w-1/3">
<bit-label>{{ "name" | i18n }}</bit-label>
<input appAutofocus formControlName="name" bitInput />
</bit-form-field>
<bit-form-field class="tw-w-full">
<bit-label>{{ "value" | i18n }}</bit-label>
<textarea bitInput rows="4" formControlName="value"></textarea>
</bit-form-field>
</div>
<bit-form-field>
<bit-label>{{ "notes" | i18n }}</bit-label>
<textarea bitInput rows="4" formControlName="notes"></textarea>
</bit-form-field>
<hr />
<hr />
<bit-form-field class="tw-mb-0 tw-mt-3">
<bit-label>{{ "project" | i18n }}</bit-label>
<bit-select bitInput name="project" formControlName="project">
<bit-option value="" [label]="'selectPlaceholder' | i18n"></bit-option>
<bit-option
*ngFor="let p of projects"
[icon]="p.id === this.newProjectGuid ? 'bwi-plus-circle' : ''"
[value]="p.id"
[label]="p.name"
<bit-form-field class="tw-mb-0 tw-mt-3">
<bit-label>{{ "project" | i18n }}</bit-label>
<bit-select bitInput name="project" formControlName="project">
<bit-option value="" [label]="'selectPlaceholder' | i18n"></bit-option>
<bit-option
*ngFor="let p of projects"
[icon]="p.id === this.newProjectGuid ? 'bwi-plus-circle' : ''"
[value]="p.id"
[label]="p.name"
>
</bit-option>
</bit-select>
</bit-form-field>
<bit-form-field *ngIf="addNewProject == true">
<bit-label>{{ "projectName" | i18n }}</bit-label>
<input formControlName="newProjectName" bitInput />
</bit-form-field>
</bit-tab>
<bit-tab label="{{ 'people' | i18n }}">
<p>
{{ "secretPeopleDescription" | i18n }}
</p>
<sm-access-policy-selector
formControlName="peopleAccessPolicies"
[addButtonMode]="false"
[items]="peopleAccessPolicyItems"
[label]="'people' | i18n"
[hint]="'projectPeopleSelectHint' | i18n"
[columnTitle]="'name' | i18n"
[emptyMessage]="'secretPeopleEmptyMessage' | i18n"
>
</bit-option>
</bit-select>
</bit-form-field>
</sm-access-policy-selector>
</bit-tab>
<bit-form-field *ngIf="addNewProject == true">
<bit-label>{{ "projectName" | i18n }}</bit-label>
<input formControlName="newProjectName" bitInput />
</bit-form-field>
<bit-tab label="{{ 'machineAccounts' | i18n }}">
<p>
{{ "secretMachineAccountsDescription" | i18n }}
</p>
<sm-access-policy-selector
formControlName="serviceAccountAccessPolicies"
[addButtonMode]="false"
[items]="serviceAccountAccessPolicyItems"
[label]="'machineAccounts' | i18n"
[hint]="'projectMachineAccountsSelectHint' | i18n"
[columnTitle]="'machineAccounts' | i18n"
[emptyMessage]="'secretMachineAccountsEmptyMessage' | i18n"
>
</sm-access-policy-selector>
</bit-tab>
</bit-tab-group>
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton buttonType="primary" bitFormButton>
<button [loading]="loading" type="submit" bitButton buttonType="primary" bitFormButton>
{{ "save" | i18n }}
</button>
<button
type="button"
bitButton
buttonType="secondary"
bitFormButton
bitDialogClose
[disabled]="false"
>
<button bitButton buttonType="secondary" type="button" bitDialogClose>
{{ "cancel" | i18n }}
</button>
<button
*ngIf="deleteButtonIsVisible"
class="tw-ml-auto"
[disabled]="loading"
type="button"
bitIconButton="bwi-trash"
buttonType="danger"
bitIconButton="bwi-trash"
bitFormButton
(click)="openDeleteSecretDialog()"
[appA11yTitle]="'delete' | i18n"
[bitAction]="delete"
></button>
</ng-container>
</bit-dialog>

View File

@ -1,5 +1,5 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { ChangeDetectorRef, Component, Inject, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { lastValueFrom, Subject, takeUntil } from "rxjs";
@ -9,12 +9,25 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, BitValidators } from "@bitwarden/components";
import { SecretAccessPoliciesView } from "../../models/view/access-policies/secret-access-policies.view";
import { ProjectListView } from "../../models/view/project-list.view";
import { ProjectView } from "../../models/view/project.view";
import { SecretListView } from "../../models/view/secret-list.view";
import { SecretProjectView } from "../../models/view/secret-project.view";
import { SecretView } from "../../models/view/secret.view";
import { ProjectService } from "../../projects/project.service";
import { AccessPolicySelectorService } from "../../shared/access-policies/access-policy-selector/access-policy-selector.service";
import {
ApItemValueType,
convertToSecretAccessPoliciesView,
} from "../../shared/access-policies/access-policy-selector/models/ap-item-value.type";
import {
ApItemViewType,
convertPotentialGranteesToApItemViewType,
convertSecretAccessPoliciesToApItemViews,
} from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type";
import { ApItemEnum } from "../../shared/access-policies/access-policy-selector/models/enums/ap-item.enum";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import { SecretService } from "../secret.service";
import { SecretDeleteDialogComponent, SecretDeleteOperation } from "./secret-delete.component";
@ -24,6 +37,12 @@ export enum OperationType {
Edit,
}
export enum SecretDialogTabType {
NameValuePair = 0,
People = 1,
ServiceAccounts = 2,
}
export interface SecretOperation {
organizationId: string;
operation: OperationType;
@ -36,6 +55,12 @@ export interface SecretOperation {
templateUrl: "./secret-dialog.component.html",
})
export class SecretDialogComponent implements OnInit {
loading = true;
projects: ProjectListView[];
addNewProject = false;
newProjectGuid = Utils.newGuid();
tabIndex: SecretDialogTabType = SecretDialogTabType.NameValuePair;
protected formGroup = new FormGroup({
name: new FormControl("", {
validators: [Validators.required, Validators.maxLength(500), BitValidators.trimValidator],
@ -51,77 +76,60 @@ export class SecretDialogComponent implements OnInit {
validators: [Validators.maxLength(500), BitValidators.trimValidator],
updateOn: "submit",
}),
peopleAccessPolicies: new FormControl([] as ApItemValueType[]),
serviceAccountAccessPolicies: new FormControl([] as ApItemValueType[]),
});
protected peopleAccessPolicyItems: ApItemViewType[];
protected serviceAccountAccessPolicyItems: ApItemViewType[];
private destroy$ = new Subject<void>();
private loading = true;
projects: ProjectListView[];
addNewProject = false;
newProjectGuid = Utils.newGuid();
private currentPeopleAccessPolicies: ApItemViewType[];
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: SecretOperation,
private secretService: SecretService,
private changeDetectorRef: ChangeDetectorRef,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private projectService: ProjectService,
private dialogService: DialogService,
private organizationService: OrganizationService,
private accessPolicyService: AccessPolicyService,
private accessPolicySelectorService: AccessPolicySelectorService,
) {}
get title() {
return this.data.operation === OperationType.Add ? "newSecret" : "editSecret";
}
get subtitle(): string | undefined {
if (this.data.operation === OperationType.Edit) {
return this.formGroup.get("name").value;
}
}
get deleteButtonIsVisible(): boolean {
return this.data.operation === OperationType.Edit;
}
async ngOnInit() {
this.loading = true;
if (this.data.operation === OperationType.Edit && this.data.secretId) {
await this.loadData();
await this.loadEditDialog();
} else if (this.data.operation !== OperationType.Add) {
this.dialogRef.close();
throw new Error(`The secret dialog was not called with the appropriate operation values.`);
} else if (this.data.operation == OperationType.Add) {
await this.loadProjects(true);
if (this.data.projectId == null || this.data.projectId == "") {
this.addNewProjectOptionToProjectsDropDown();
}
}
if (this.data.projectId) {
this.formGroup.get("project").setValue(this.data.projectId);
} else if (this.data.operation === OperationType.Add) {
await this.loadAddDialog();
}
if ((await this.organizationService.get(this.data.organizationId))?.isAdmin) {
this.formGroup.get("project").removeValidators(Validators.required);
this.formGroup.get("project").updateValueAndValidity();
}
}
async loadData() {
this.formGroup.disable();
const secret: SecretView = await this.secretService.getBySecretId(this.data.secretId);
await this.loadProjects(secret.write);
this.formGroup.setValue({
name: secret.name,
value: secret.value,
notes: secret.note,
project: secret.projects[0]?.id ?? "",
newProjectName: "",
});
this.loading = false;
if (secret.write) {
this.formGroup.enable();
}
}
async loadProjects(filterByPermission: boolean) {
this.projects = await this.projectService
.getProjects(this.data.organizationId)
.then((projects) => projects.sort((a, b) => a.name.localeCompare(b.name)));
if (filterByPermission) {
this.projects = this.projects.filter((p) => p.write);
}
}
ngOnDestroy(): void {
@ -129,6 +137,152 @@ export class SecretDialogComponent implements OnInit {
this.destroy$.complete();
}
submit = async () => {
if (!this.data.organizationEnabled) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("secretsCannotCreate"));
return;
}
if (this.isFormInvalid()) {
return;
}
const secretView = this.getSecretView();
const secretAccessPoliciesView = convertToSecretAccessPoliciesView([
...this.formGroup.value.peopleAccessPolicies,
...this.formGroup.value.serviceAccountAccessPolicies,
]);
const showAccessRemovalWarning =
this.data.operation === OperationType.Edit &&
(await this.accessPolicySelectorService.showSecretAccessRemovalWarning(
this.data.organizationId,
this.currentPeopleAccessPolicies,
this.formGroup.value.peopleAccessPolicies,
));
if (showAccessRemovalWarning) {
const confirmed = await this.showWarning();
if (!confirmed) {
return;
}
}
if (this.addNewProject) {
const newProject = await this.createProject(this.getNewProjectView());
secretView.projects = [newProject];
}
if (this.data.operation === OperationType.Add) {
await this.createSecret(secretView, secretAccessPoliciesView);
} else {
secretView.id = this.data.secretId;
await this.updateSecret(secretView, secretAccessPoliciesView);
}
this.dialogRef.close();
};
delete = async () => {
const secretListView: SecretListView[] = this.getSecretListView();
const dialogRef = this.dialogService.open<unknown, SecretDeleteOperation>(
SecretDeleteDialogComponent,
{
data: {
secrets: secretListView,
},
},
);
await lastValueFrom(dialogRef.closed).then(
(closeData) => closeData !== undefined && this.dialogRef.close(),
);
};
private async loadEditDialog() {
const secret = await this.secretService.getBySecretId(this.data.secretId);
await this.loadProjects(secret.projects);
const currentAccessPolicies = await this.getCurrentAccessPolicies(
this.data.organizationId,
this.data.secretId,
);
this.currentPeopleAccessPolicies = currentAccessPolicies.filter(
(p) => p.type === ApItemEnum.User || p.type === ApItemEnum.Group,
);
const currentServiceAccountPolicies = currentAccessPolicies.filter(
(p) => p.type === ApItemEnum.ServiceAccount,
);
this.peopleAccessPolicyItems = await this.getPeoplePotentialGrantees(this.data.organizationId);
this.serviceAccountAccessPolicyItems = await this.getServiceAccountItems(
this.data.organizationId,
currentServiceAccountPolicies,
);
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
// potentialGrantees, otherwise no selected values will be patched below
this.changeDetectorRef.detectChanges();
this.formGroup.patchValue({
name: secret.name,
value: secret.value,
notes: secret.note,
project: secret.projects[0]?.id ?? "",
newProjectName: "",
peopleAccessPolicies: this.currentPeopleAccessPolicies.map((m) => ({
type: m.type,
id: m.id,
permission: m.permission,
currentUser: m.type === ApItemEnum.User ? m.currentUser : null,
currentUserInGroup: m.type === ApItemEnum.Group ? m.currentUserInGroup : null,
})),
serviceAccountAccessPolicies: currentServiceAccountPolicies.map((m) => ({
type: m.type,
id: m.id,
permission: m.permission,
})),
});
}
private async loadAddDialog() {
await this.loadProjects();
this.peopleAccessPolicyItems = await this.getPeoplePotentialGrantees(this.data.organizationId);
this.serviceAccountAccessPolicyItems = await this.getServiceAccountItems(
this.data.organizationId,
);
if (
this.data.projectId === null ||
this.data.projectId === "" ||
this.data.projectId === undefined
) {
this.addNewProjectOptionToProjectsDropDown();
}
if (this.data.projectId) {
this.formGroup.get("project").setValue(this.data.projectId);
}
}
private async loadProjects(currentProjects?: SecretProjectView[]) {
this.projects = await this.projectService
.getProjects(this.data.organizationId)
.then((projects) => projects.filter((p) => p.write));
if (currentProjects?.length > 0) {
const currentProject = currentProjects?.[0];
if (this.projects.find((p) => p.id === currentProject.id) === undefined) {
const listView = new ProjectListView();
listView.id = currentProject.id;
listView.name = currentProject.name;
this.projects.push(listView);
}
}
this.projects = this.projects.sort((a, b) => a.name.localeCompare(b.name));
}
private addNewProjectOptionToProjectsDropDown() {
this.formGroup
.get("project")
@ -155,70 +309,15 @@ export class SecretDialogComponent implements OnInit {
this.formGroup.get("newProjectName").updateValueAndValidity();
}
get title() {
return this.data.operation === OperationType.Add ? "newSecret" : "editSecret";
}
get showSpinner() {
return this.data.operation === OperationType.Edit && this.loading;
}
submit = async () => {
if (!this.data.organizationEnabled) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("secretsCannotCreate"));
return;
}
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
const secretView = this.getSecretView();
if (this.addNewProject) {
const newProject = await this.createProject(this.getNewProjectView());
secretView.projects = [newProject];
}
if (this.data.operation === OperationType.Add) {
await this.createSecret(secretView);
} else {
secretView.id = this.data.secretId;
await this.updateSecret(secretView);
}
this.dialogRef.close();
};
get deleteButtonIsVisible(): boolean {
return this.data.operation === OperationType.Edit;
}
private async createProject(projectView: ProjectView) {
return await this.projectService.create(this.data.organizationId, projectView);
}
protected async openDeleteSecretDialog() {
const secretListView: SecretListView[] = this.getSecretListView();
const dialogRef = this.dialogService.open<unknown, SecretDeleteOperation>(
SecretDeleteDialogComponent,
{
data: {
secrets: secretListView,
},
},
);
// If the secret is deleted, chain close this dialog after the delete dialog
await lastValueFrom(dialogRef.closed).then(
(closeData) => closeData !== undefined && this.dialogRef.close(),
);
}
private async createSecret(secretView: SecretView) {
await this.secretService.create(this.data.organizationId, secretView);
private async createSecret(
secretView: SecretView,
secretAccessPoliciesView: SecretAccessPoliciesView,
) {
await this.secretService.create(this.data.organizationId, secretView, secretAccessPoliciesView);
this.platformUtilsService.showToast("success", null, this.i18nService.t("secretCreated"));
}
@ -229,8 +328,11 @@ export class SecretDialogComponent implements OnInit {
return projectView;
}
private async updateSecret(secretView: SecretView) {
await this.secretService.update(this.data.organizationId, secretView);
private async updateSecret(
secretView: SecretView,
secretAccessPoliciesView: SecretAccessPoliciesView,
) {
await this.secretService.update(this.data.organizationId, secretView, secretAccessPoliciesView);
this.platformUtilsService.showToast("success", null, this.i18nService.t("secretEdited"));
}
@ -265,4 +367,63 @@ export class SecretDialogComponent implements OnInit {
secretListViews.push(secretListView);
return secretListViews;
}
private async getCurrentAccessPolicies(
organizationId: string,
secretId: string,
): Promise<ApItemViewType[]> {
return convertSecretAccessPoliciesToApItemViews(
await this.accessPolicyService.getSecretAccessPolicies(organizationId, secretId),
);
}
private async getPeoplePotentialGrantees(organizationId: string): Promise<ApItemViewType[]> {
return convertPotentialGranteesToApItemViewType(
await this.accessPolicyService.getPeoplePotentialGrantees(organizationId),
);
}
private async getServiceAccountItems(
organizationId: string,
currentAccessPolicies?: ApItemViewType[],
): Promise<ApItemViewType[]> {
const potentialGrantees = convertPotentialGranteesToApItemViewType(
await this.accessPolicyService.getServiceAccountsPotentialGrantees(organizationId),
);
const items = [...potentialGrantees];
if (currentAccessPolicies) {
for (const policy of currentAccessPolicies) {
const exists = potentialGrantees.some((grantee) => grantee.id === policy.id);
if (!exists) {
items.push(policy);
}
}
}
return items;
}
private async showWarning(): Promise<boolean> {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "smAccessRemovalWarningSecretTitle" },
content: { key: "smAccessRemovalSecretMessage" },
acceptButtonText: { key: "removeAccess" },
cancelButtonText: { key: "cancel" },
type: "warning",
});
return confirmed;
}
private isFormInvalid(): boolean {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid && this.tabIndex !== SecretDialogTabType.NameValuePair) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("nameValuePair")),
);
}
return this.formGroup.invalid;
}
}

View File

@ -1,6 +1,9 @@
import { SecretAccessPoliciesRequest } from "../../shared/access-policies/models/requests/secret-access-policies.request";
export class SecretRequest {
key: string;
value: string;
note: string;
projectIds?: string[];
accessPoliciesRequests: SecretAccessPoliciesRequest;
}

View File

@ -0,0 +1,125 @@
import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SecretAccessPoliciesView } from "../models/view/access-policies/secret-access-policies.view";
import { SecretView } from "../models/view/secret.view";
import { AccessPolicyService } from "../shared/access-policies/access-policy.service";
import { SecretService } from "./secret.service";
describe("SecretService", () => {
let sut: SecretService;
const cryptoService = mock<CryptoService>();
const apiService = mock<ApiService>();
const encryptService = mock<EncryptService>();
const accessPolicyService = mock<AccessPolicyService>();
beforeEach(() => {
jest.resetAllMocks();
sut = new SecretService(cryptoService, apiService, encryptService, accessPolicyService);
encryptService.encrypt.mockResolvedValue({
encryptedString: "mockEncryptedString",
} as EncString);
encryptService.decryptToUtf8.mockResolvedValue(mockUnencryptedData);
});
it("instantiates", () => {
expect(sut).not.toBeFalsy();
});
describe("create", () => {
it("emits the secret created", async () => {
apiService.send.mockResolvedValue(mockedSecretResponse);
sut.secret$.subscribe((secret) => {
expect(secret).toBeDefined();
expect(secret).toEqual(expectedSecretView);
});
await sut.create("organizationId", secretView, secretAccessPoliciesView);
});
});
describe("update", () => {
it("emits the secret updated", async () => {
apiService.send.mockResolvedValue(mockedSecretResponse);
sut.secret$.subscribe((secret) => {
expect(secret).toBeDefined();
expect(secret).toEqual(expectedSecretView);
});
await sut.update("organizationId", secretView, secretAccessPoliciesView);
});
});
});
const mockedSecretResponse: any = {
id: "001f835c-aa41-4f25-bfbf-b18d0103a1db",
organizationId: "da0eea55-8604-4307-8a24-b187015e3071",
key: "mockEncryptedString",
value: "mockEncryptedString",
note: "mockEncryptedString",
creationDate: "2024-07-12T15:45:17.49823Z",
revisionDate: "2024-07-12T15:45:17.49823Z",
projects: [
{
id: "502d93ae-a084-490a-8a64-b187015eb69c",
name: "mockEncryptedString",
},
],
read: true,
write: true,
object: "secret",
};
const secretView: SecretView = {
id: "001f835c-aa41-4f25-bfbf-b18d0103a1db",
organizationId: "da0eea55-8604-4307-8a24-b187015e3071",
name: "key",
value: "value",
note: "note",
creationDate: "2024-06-12T15:45:17.49823Z",
revisionDate: "2024-06-12T15:45:17.49823Z",
projects: [
{
id: "502d93ae-a084-490a-8a64-b187015eb69c",
name: "project-name",
},
],
read: true,
write: true,
};
const secretAccessPoliciesView: SecretAccessPoliciesView = {
userAccessPolicies: [],
groupAccessPolicies: [],
serviceAccountAccessPolicies: [],
};
const mockUnencryptedData = "mockUnEncryptedString";
const expectedSecretView: SecretView = {
id: "001f835c-aa41-4f25-bfbf-b18d0103a1db",
organizationId: "da0eea55-8604-4307-8a24-b187015e3071",
name: mockUnencryptedData,
value: mockUnencryptedData,
note: mockUnencryptedData,
creationDate: "2024-07-12T15:45:17.49823Z",
revisionDate: "2024-07-12T15:45:17.49823Z",
projects: [
{
id: "502d93ae-a084-490a-8a64-b187015eb69c",
name: mockUnencryptedData,
},
],
read: true,
write: true,
};

View File

@ -7,9 +7,11 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { SecretAccessPoliciesView } from "../models/view/access-policies/secret-access-policies.view";
import { SecretListView } from "../models/view/secret-list.view";
import { SecretProjectView } from "../models/view/secret-project.view";
import { SecretView } from "../models/view/secret.view";
import { AccessPolicyService } from "../shared/access-policies/access-policy.service";
import { BulkOperationStatus } from "../shared/dialogs/bulk-status-dialog.component";
import { SecretRequest } from "./requests/secret.request";
@ -30,6 +32,7 @@ export class SecretService {
private cryptoService: CryptoService,
private apiService: ApiService,
private encryptService: EncryptService,
private accessPolicyService: AccessPolicyService,
) {}
async getBySecretId(secretId: string): Promise<SecretView> {
@ -65,8 +68,16 @@ export class SecretService {
return await this.createSecretsListView(organizationId, results);
}
async create(organizationId: string, secretView: SecretView) {
const request = await this.getSecretRequest(organizationId, secretView);
async create(
organizationId: string,
secretView: SecretView,
secretAccessPoliciesView: SecretAccessPoliciesView,
) {
const request = await this.getSecretRequest(
organizationId,
secretView,
secretAccessPoliciesView,
);
const r = await this.apiService.send(
"POST",
"/organizations/" + organizationId + "/secrets",
@ -77,8 +88,16 @@ export class SecretService {
this._secret.next(await this.createSecretView(new SecretResponse(r)));
}
async update(organizationId: string, secretView: SecretView) {
const request = await this.getSecretRequest(organizationId, secretView);
async update(
organizationId: string,
secretView: SecretView,
secretAccessPoliciesView: SecretAccessPoliciesView,
) {
const request = await this.getSecretRequest(
organizationId,
secretView,
secretAccessPoliciesView,
);
const r = await this.apiService.send("PUT", "/secrets/" + secretView.id, request, true, true);
this._secret.next(await this.createSecretView(new SecretResponse(r)));
}
@ -140,6 +159,7 @@ export class SecretService {
private async getSecretRequest(
organizationId: string,
secretView: SecretView,
secretAccessPoliciesView: SecretAccessPoliciesView,
): Promise<SecretRequest> {
const orgKey = await this.getOrganizationKey(organizationId);
const request = new SecretRequest();
@ -155,6 +175,9 @@ export class SecretService {
secretView.projects?.forEach((e) => request.projectIds.push(e.id));
request.accessPoliciesRequests =
this.accessPolicyService.getSecretAccessPoliciesRequest(secretAccessPoliciesView);
return request;
}

View File

@ -2,9 +2,9 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-my-4 tw-max-w-xl">
<app-callout type="info" title="{{ 'exportingOrganizationSecretDataTitle' | i18n }}">
<bit-callout type="info" title="{{ 'exportingOrganizationSecretDataTitle' | i18n }}">
{{ "exportingOrganizationSecretDataDescription" | i18n: orgName }}
</app-callout>
</bit-callout>
</div>
<bit-form-field class="tw-max-w-sm">

View File

@ -64,10 +64,12 @@ describe("AccessPolicySelectorService", () => {
const selectedPolicyValues: ApItemValueType[] = [];
selectedPolicyValues.push(
createApItemValueType({
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType(
{
permission: ApPermissionEnum.CanRead,
},
true,
),
);
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
@ -80,15 +82,17 @@ describe("AccessPolicySelectorService", () => {
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
permission: ApPermissionEnum.CanReadWrite,
currentUser: true,
}),
createApItemValueType(
{
permission: ApPermissionEnum.CanReadWrite,
},
true,
),
];
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
expect(result).toBe(true);
expect(result).toBe(false);
});
it("returns true when current user isn't owner/admin and a group Read policy is submitted that the user is a member of", async () => {
@ -96,12 +100,15 @@ describe("AccessPolicySelectorService", () => {
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanRead,
currentUserInGroup: true,
}),
createApItemValueType(
{
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanRead,
},
false,
true,
),
];
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
@ -114,12 +121,15 @@ describe("AccessPolicySelectorService", () => {
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
currentUserInGroup: true,
}),
createApItemValueType(
{
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
},
false,
true,
),
];
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
@ -132,12 +142,15 @@ describe("AccessPolicySelectorService", () => {
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
currentUserInGroup: false,
}),
createApItemValueType(
{
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
},
false,
false,
),
];
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
@ -150,16 +163,21 @@ describe("AccessPolicySelectorService", () => {
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType({
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
currentUserInGroup: true,
}),
createApItemValueType(
{
permission: ApPermissionEnum.CanRead,
},
true,
),
createApItemValueType(
{
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
},
false,
true,
),
];
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
@ -172,16 +190,21 @@ describe("AccessPolicySelectorService", () => {
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType({
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
currentUserInGroup: false,
}),
createApItemValueType(
{
permission: ApPermissionEnum.CanRead,
},
true,
),
createApItemValueType(
{
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
},
false,
false,
),
];
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
@ -194,16 +217,21 @@ describe("AccessPolicySelectorService", () => {
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType({
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanRead,
currentUserInGroup: true,
}),
createApItemValueType(
{
permission: ApPermissionEnum.CanRead,
},
true,
),
createApItemValueType(
{
id: "groupId",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanRead,
},
false,
true,
),
];
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
@ -211,6 +239,246 @@ describe("AccessPolicySelectorService", () => {
expect(result).toBe(true);
});
});
describe("showSecretAccessRemovalWarning", () => {
it("returns false when there are no current access policies", async () => {
const org = orgFactory();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [];
const selectedPolicyValues: ApItemValueType[] = [];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(false);
});
it("returns false when current user is admin", async () => {
const org = orgFactory();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(false);
});
it("returns false when current user is owner", async () => {
const org = orgFactory();
org.type = OrganizationUserType.Owner;
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(false);
});
it("returns false when current non-admin user doesn't have Read, Write access with current access policies -- user policy", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(false);
});
it("returns false when current non-admin user doesn't have Read, Write access with current access policies -- group policy", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType(
{
id: "example",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanRead,
},
false,
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(false);
});
it("returns true when current non-admin user has Read, Write access with current access policies and doesn't with selected -- user policy", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType(
{
id: "example",
permission: ApPermissionEnum.CanReadWrite,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
type: ApItemEnum.User,
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(true);
});
it("returns true when current non-admin user has Read, Write access with current access policies and doesn't with selected -- group policy", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType(
{
id: "example",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
},
false,
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType(
{
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanRead,
},
false,
true,
),
];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(true);
});
it("returns false when current non-admin user has Read, Write access with current access policies and does with selected -- user policy", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType(
{
id: "example",
permission: ApPermissionEnum.CanReadWrite,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType(
{
type: ApItemEnum.User,
permission: ApPermissionEnum.CanReadWrite,
},
true,
),
];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(false);
});
it("returns false when current non-admin user has Read, Write access with current access policies and does with selected -- group policy", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType(
{
id: "example",
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
},
false,
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType(
{
type: ApItemEnum.Group,
permission: ApPermissionEnum.CanReadWrite,
},
false,
true,
),
];
const result = await sut.showSecretAccessRemovalWarning(
org.id,
currentAccessPolicies,
selectedPolicyValues,
);
expect(result).toBe(false);
});
});
describe("isAccessRemoval", () => {
it("returns false when there are no previous policies and no selected policies", async () => {
const currentAccessPolicies: ApItemViewType[] = [];
@ -223,11 +491,13 @@ describe("AccessPolicySelectorService", () => {
it("returns false when there are no previous policies", async () => {
const currentAccessPolicies: ApItemViewType[] = [];
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
id: "example",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const result = sut.isAccessRemoval(currentAccessPolicies, selectedPolicyValues);
@ -236,18 +506,22 @@ describe("AccessPolicySelectorService", () => {
});
it("returns false when previous policies and selected policies are the same", async () => {
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType({
id: "example",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemViewType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
id: "example",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const result = sut.isAccessRemoval(currentAccessPolicies, selectedPolicyValues);
@ -256,23 +530,29 @@ describe("AccessPolicySelectorService", () => {
});
it("returns false when previous policies are still selected", async () => {
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType({
id: "example",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemViewType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
id: "example",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType({
id: "example-2",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
createApItemValueType(
{
id: "example-2",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const result = sut.isAccessRemoval(currentAccessPolicies, selectedPolicyValues);
@ -281,23 +561,29 @@ describe("AccessPolicySelectorService", () => {
});
it("returns true when previous policies are not selected", async () => {
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType({
id: "example",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemViewType(
{
id: "example",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
id: "test",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType({
id: "example-2",
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemValueType(
{
id: "test",
permission: ApPermissionEnum.CanRead,
},
true,
),
createApItemValueType(
{
id: "example-2",
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const result = sut.isAccessRemoval(currentAccessPolicies, selectedPolicyValues);
@ -306,10 +592,12 @@ describe("AccessPolicySelectorService", () => {
});
it("returns true when there are previous policies and nothing was selected", async () => {
const currentAccessPolicies: ApItemViewType[] = [
createApItemViewType({
permission: ApPermissionEnum.CanRead,
currentUser: true,
}),
createApItemViewType(
{
permission: ApPermissionEnum.CanRead,
},
true,
),
];
const selectedPolicyValues: ApItemValueType[] = [];
@ -331,17 +619,31 @@ const orgFactory = (props: Partial<Organization> = {}) =>
props,
);
function createApItemValueType(options: Partial<ApItemValueType> = {}) {
return {
function createApItemValueType(
options: Partial<ApItemValueType> = {},
currentUser = false,
currentUserInGroup = false,
) {
const item: ApItemValueType = {
id: options?.id ?? "test",
type: options?.type ?? ApItemEnum.User,
permission: options?.permission ?? ApPermissionEnum.CanRead,
currentUserInGroup: options?.currentUserInGroup ?? false,
};
if (item.type === ApItemEnum.User) {
item.currentUser = currentUser;
}
if (item.type === ApItemEnum.Group) {
item.currentUserInGroup = currentUserInGroup;
}
return item;
}
function createApItemViewType(options: Partial<ApItemViewType> = {}) {
return {
function createApItemViewType(
options: Partial<ApItemViewType> = {},
currentUser = false,
currentUserInGroup = false,
) {
const item: ApItemViewType = {
id: options?.id ?? "test",
listName: options?.listName ?? "test",
labelName: options?.labelName ?? "test",
@ -349,6 +651,13 @@ function createApItemViewType(options: Partial<ApItemViewType> = {}) {
permission: options?.permission ?? ApPermissionEnum.CanRead,
readOnly: options?.readOnly ?? false,
};
if (item.type === ApItemEnum.User) {
item.currentUser = currentUser;
}
if (item.type === ApItemEnum.Group) {
item.currentUserInGroup = currentUserInGroup;
}
return item;
}
function setupUserOrg() {

View File

@ -22,26 +22,29 @@ export class AccessPolicySelectorService {
return false;
}
const selectedUserReadWritePolicy = selectedPoliciesValues.find(
(s) =>
s.type === ApItemEnum.User &&
s.currentUser &&
s.permission === ApPermissionEnum.CanReadWrite,
);
if (!this.userHasReadWriteAccess(selectedPoliciesValues)) {
return true;
}
const selectedGroupReadWritePolicies = selectedPoliciesValues.filter(
(s) =>
s.type === ApItemEnum.Group &&
s.permission == ApPermissionEnum.CanReadWrite &&
s.currentUserInGroup,
);
return false;
}
if (selectedGroupReadWritePolicies == null || selectedGroupReadWritePolicies.length == 0) {
if (selectedUserReadWritePolicy == null) {
return true;
} else {
return false;
}
async showSecretAccessRemovalWarning(
organizationId: string,
current: ApItemViewType[],
selectedPoliciesValues: ApItemValueType[],
): Promise<boolean> {
if (current.length === 0) {
return false;
}
const organization = await this.organizationService.get(organizationId);
if (organization.isOwner || organization.isAdmin || !this.userHasReadWriteAccess(current)) {
return false;
}
if (!this.userHasReadWriteAccess(selectedPoliciesValues)) {
return true;
}
return false;
@ -67,4 +70,25 @@ export class AccessPolicySelectorService {
const selectedIds = selected.map((x) => x.id);
return !currentIds.every((id) => selectedIds.includes(id));
}
private userHasReadWriteAccess(policies: ApItemValueType[] | ApItemViewType[]): boolean {
const userReadWritePolicy = (policies as Array<ApItemValueType | ApItemViewType>).find(
(s) =>
s.type === ApItemEnum.User &&
s.currentUser &&
s.permission === ApPermissionEnum.CanReadWrite,
);
const groupReadWritePolicies = (policies as Array<ApItemValueType | ApItemViewType>).filter(
(s) =>
s.type === ApItemEnum.Group &&
s.permission === ApPermissionEnum.CanReadWrite &&
s.currentUserInGroup,
);
if (groupReadWritePolicies.length > 0 || userReadWritePolicy !== undefined) {
return true;
}
return false;
}
}

View File

@ -27,6 +27,7 @@ import { ServiceAccountGrantedPoliciesRequest } from "../access-policies/models/
import { AccessPolicyRequest } from "./models/requests/access-policy.request";
import { ProjectServiceAccountsAccessPoliciesRequest } from "./models/requests/project-service-accounts-access-policies.request";
import { SecretAccessPoliciesRequest } from "./models/requests/secret-access-policies.request";
import {
GroupAccessPolicyResponse,
UserAccessPolicyResponse,
@ -233,6 +234,20 @@ export class AccessPolicyService {
return await this.createPotentialGranteeViews(organizationId, results.data);
}
getSecretAccessPoliciesRequest(view: SecretAccessPoliciesView): SecretAccessPoliciesRequest {
return {
userAccessPolicyRequests: view.userAccessPolicies.map((ap) => {
return this.getAccessPolicyRequest(ap.organizationUserId, ap);
}),
groupAccessPolicyRequests: view.groupAccessPolicies.map((ap) => {
return this.getAccessPolicyRequest(ap.groupId, ap);
}),
serviceAccountAccessPolicyRequests: view.serviceAccountAccessPolicies.map((ap) => {
return this.getAccessPolicyRequest(ap.serviceAccountId, ap);
}),
};
}
private async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId);
}

View File

@ -0,0 +1,7 @@
import { AccessPolicyRequest } from "./access-policy.request";
export class SecretAccessPoliciesRequest {
userAccessPolicyRequests: AccessPolicyRequest[];
groupAccessPolicyRequests: AccessPolicyRequest[];
serviceAccountAccessPolicyRequests: AccessPolicyRequest[];
}

View File

@ -14,7 +14,9 @@
"@bitwarden/components": ["../../libs/components/src"],
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
"@bitwarden/vault-export-core": [
"../../libs/tools/export/vault-export/vault-export-core/src"
],

View File

@ -32,6 +32,9 @@ module.exports = {
"<rootDir>/libs/components/jest.config.js",
"<rootDir>/libs/tools/export/vault-export/vault-export-core/jest.config.js",
"<rootDir>/libs/tools/generator/core/jest.config.js",
"<rootDir>/libs/tools/generator/extensions/history/jest.config.js",
"<rootDir>/libs/tools/generator/extensions/legacy/jest.config.js",
"<rootDir>/libs/tools/generator/extensions/navigation/jest.config.js",
"<rootDir>/libs/importer/jest.config.js",
"<rootDir>/libs/platform/jest.config.js",
"<rootDir>/libs/node/jest.config.js",

View File

@ -41,7 +41,7 @@ export class RemovePasswordComponent implements OnInit {
this.loading = false;
}
async convert() {
convert = async () => {
this.continuing = true;
this.actionPromise = this.keyConnectorService.migrateUser();
@ -59,9 +59,9 @@ export class RemovePasswordComponent implements OnInit {
} catch (e) {
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), e.message);
}
}
};
async leave() {
leave = async () => {
const confirmed = await this.dialogService.openSimpleDialog({
title: this.organization.name,
content: { key: "leaveOrganizationConfirmation" },
@ -84,5 +84,5 @@ export class RemovePasswordComponent implements OnInit {
} catch (e) {
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), e);
}
}
};
}

View File

@ -3,3 +3,4 @@ export * from "./login-email.service";
export * from "./login-strategy.service";
export * from "./user-decryption-options.service.abstraction";
export * from "./auth-request.service.abstraction";
export * from "./user-key-rotation-data-provider.abstraction";

View File

@ -0,0 +1,23 @@
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
/**
* Constructs key rotation requests for data encryption by the user key.
* @typeparam TRequest A request model that contains re-encrypted data, must have an id property
*/
export interface UserKeyRotationDataProvider<
TRequest extends { id: string } | { organizationId: string },
> {
/**
* Provides re-encrypted data for the user key rotation process
* @param originalUserKey The original user key, useful for decrypting data
* @param newUserKey The new user key to use for re-encryption
* @param userId The owner of the data, useful for fetching data
* @returns A list of data that has been re-encrypted with the new user key
*/
getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<TRequest[]>;
}

View File

@ -269,6 +269,15 @@ export abstract class CryptoService {
*/
abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey>;
/**
* Gets an observable stream of the given users decrypted private key with legacy support,
* will emit null if the user doesn't have a UserKey to decrypt the encrypted private key
* or null if the user doesn't have an encrypted private key at all.
*
* @param userId The user id of the user to get the data for.
*/
abstract userPrivateKeyWithLegacySupport$(userId: UserId): Observable<UserPrivateKey>;
/**
* Generates a fingerprint phrase for the user based on their public key
* @param fingerprintMaterial Fingerprint material

View File

@ -929,6 +929,10 @@ export class CryptoService implements CryptoServiceAbstraction {
return this.userPrivateKeyHelper$(userId, false).pipe(map((keys) => keys?.userPrivateKey));
}
userPrivateKeyWithLegacySupport$(userId: UserId): Observable<UserPrivateKey> {
return this.userPrivateKeyHelper$(userId, true).pipe(map((keys) => keys?.userPrivateKey));
}
private userPrivateKeyHelper$(userId: UserId, legacySupport: boolean) {
const userKey$ = legacySupport ? this.userKeyWithLegacySupport$(userId) : this.userKey$(userId);
return userKey$.pipe(
@ -1010,7 +1014,7 @@ export class CryptoService implements CryptoServiceAbstraction {
}
orgKeys$(userId: UserId) {
return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys));
return this.cipherDecryptionKeys$(userId, true).pipe(map((keys) => keys?.orgKeys));
}
cipherDecryptionKeys$(

View File

@ -1,14 +1,17 @@
import { Observable } from "rxjs";
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { SendData } from "../models/data/send.data";
import { Send } from "../models/domain/send";
import { SendWithIdRequest } from "../models/request/send-with-id.request";
import { SendView } from "../models/view/send.view";
export abstract class SendService {
export abstract class SendService implements UserKeyRotationDataProvider<SendWithIdRequest> {
sends$: Observable<Send[]>;
sendViews$: Observable<SendView[]>;
@ -31,7 +34,11 @@ export abstract class SendService {
* @throws Error if the new user key is null or undefined
* @returns A list of user sends that have been re-encrypted with the new user key
*/
getRotatedKeys: (newUserKey: UserKey) => Promise<SendWithIdRequest[]>;
getRotatedData: (
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
) => Promise<SendWithIdRequest[]>;
/**
* @deprecated Do not call this, use the sends$ observable collection
*/

View File

@ -400,8 +400,11 @@ describe("SendService", () => {
expect(sends[0]).toMatchObject(testSendViewData("1", "Test Send"));
});
describe("getRotatedKeys", () => {
describe("getRotatedData", () => {
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
let encryptedKey: EncString;
beforeEach(() => {
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
encryptedKey = new EncString("Re-encrypted Send Key");
@ -409,27 +412,30 @@ describe("SendService", () => {
});
it("returns re-encrypted user sends", async () => {
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const result = await sendService.getRotatedKeys(newUserKey);
const result = await sendService.getRotatedData(originalUserKey, newUserKey, mockUserId);
expect(result).toMatchObject([{ id: "1", key: "Re-encrypted Send Key" }]);
});
it("returns null if there are no sends", async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sendService.replace(null);
it("returns empty array if there are no sends", async () => {
await sendService.replace(null);
await awaitAsync();
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const result = await sendService.getRotatedKeys(newUserKey);
const result = await sendService.getRotatedData(originalUserKey, newUserKey, mockUserId);
expect(result).toEqual([]);
});
it("throws if the original user key is null", async () => {
await expect(sendService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
"Original user key is required for rotation.",
);
});
it("throws if the new user key is null", async () => {
await expect(sendService.getRotatedKeys(null)).rejects.toThrowError(
await expect(sendService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
"New user key is required for rotation.",
);
});

View File

@ -9,6 +9,7 @@ import { Utils } from "../../../platform/misc/utils";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { SendType } from "../enums/send-type";
import { SendData } from "../models/data/send.data";
@ -258,12 +259,17 @@ export class SendService implements InternalSendServiceAbstraction {
await this.stateProvider.setEncryptedSends(sends);
}
async getRotatedKeys(newUserKey: UserKey): Promise<SendWithIdRequest[]> {
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<SendWithIdRequest[]> {
if (newUserKey == null) {
throw new Error("New user key is required for rotation.");
}
const originalUserKey = await this.cryptoService.getUserKey();
if (originalUserKey == null) {
throw new Error("Original user key is required for rotation.");
}
const req = await firstValueFrom(
this.sends$.pipe(

View File

@ -1,19 +1,22 @@
import { Observable } from "rxjs";
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
import { LocalData } from "@bitwarden/common/vault/models/data/local.data";
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
import { UserKey } from "../../types/key";
import { CipherType } from "../enums/cipher-type";
import { CipherData } from "../models/data/cipher.data";
import { Cipher } from "../models/domain/cipher";
import { Field } from "../models/domain/field";
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
export abstract class CipherService {
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
cipherViews$: Observable<Record<CipherId, CipherView>>;
ciphers$: Observable<Record<CipherId, CipherData>>;
localData$: Observable<Record<CipherId, LocalData>>;
@ -146,4 +149,17 @@ export abstract class CipherService {
restoreManyWithServer: (ids: string[], orgId?: string) => Promise<void>;
getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise<any>;
setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise<void>;
/**
* Returns user ciphers re-encrypted with the new user key.
* @param originalUserKey the original user key
* @param newUserKey the new user key
* @param userId the user id
* @throws Error if new user key is null
* @returns a list of user ciphers that have been re-encrypted with the new user key
*/
getRotatedData: (
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
) => Promise<CipherWithIdRequest[]>;
}

View File

@ -1,11 +1,16 @@
import { Observable } from "rxjs";
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { FolderData } from "../../models/data/folder.data";
import { Folder } from "../../models/domain/folder";
import { FolderWithIdRequest } from "../../models/request/folder-with-id.request";
import { FolderView } from "../../models/view/folder.view";
export abstract class FolderService {
export abstract class FolderService implements UserKeyRotationDataProvider<FolderWithIdRequest> {
folders$: Observable<Folder[]>;
folderViews$: Observable<FolderView[]>;
@ -22,6 +27,19 @@ export abstract class FolderService {
*/
getAllDecryptedFromState: () => Promise<FolderView[]>;
decryptFolders: (folders: Folder[]) => Promise<FolderView[]>;
/**
* Returns user folders re-encrypted with the new user key.
* @param originalUserKey the original user key
* @param newUserKey the new user key
* @param userId the user id
* @throws Error if new user key is null
* @returns a list of user folders that have been re-encrypted with the new user key
*/
getRotatedData: (
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
) => Promise<FolderWithIdRequest[]>;
}
export abstract class InternalFolderService extends FolderService {

View File

@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
@ -10,7 +10,7 @@ import { AutofillSettingsService } from "../../autofill/services/autofill-settin
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { CipherDecryptionKeys, CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
@ -19,8 +19,8 @@ import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../platform/services/container.service";
import { UserId } from "../../types/guid";
import { CipherKey, OrgKey } from "../../types/key";
import { CipherId, UserId } from "../../types/guid";
import { CipherKey, OrgKey, UserKey } from "../../types/key";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { FieldType } from "../enums";
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
@ -331,6 +331,64 @@ describe("Cipher Service", () => {
});
});
});
describe("getRotatedData", () => {
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
let decryptedCiphers: BehaviorSubject<Record<CipherId, CipherView>>;
let encryptedKey: EncString;
beforeEach(() => {
setEncryptionKeyFlag(true);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
searchService.indexedEntityId$ = of(null);
stateService.getUserId.mockResolvedValue(mockUserId);
const keys = {
userKey: originalUserKey,
} as CipherDecryptionKeys;
cryptoService.cipherDecryptionKeys$.mockReturnValue(of(keys));
const cipher1 = new CipherView(cipherObj);
cipher1.id = "Cipher 1";
const cipher2 = new CipherView(cipherObj);
cipher2.id = "Cipher 2";
decryptedCiphers = new BehaviorSubject({
Cipher1: cipher1,
Cipher2: cipher2,
});
cipherService.cipherViews$ = decryptedCiphers;
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
encryptedKey = new EncString("Re-encrypted Cipher Key");
encryptService.encrypt.mockResolvedValue(encryptedKey);
cryptoService.makeCipherKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,
);
});
it("returns re-encrypted user ciphers", async () => {
const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);
expect(result[0]).toMatchObject({ id: "Cipher 1", key: "Re-encrypted Cipher Key" });
expect(result[1]).toMatchObject({ id: "Cipher 2", key: "Re-encrypted Cipher Key" });
});
it("throws if the original user key is null", async () => {
await expect(cipherService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
"Original user key is required to rotate ciphers",
);
});
it("throws if the new user key is null", async () => {
await expect(cipherService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
"New user key is required to rotate ciphers",
);
});
});
});
function setEncryptionKeyFlag(value: boolean) {

View File

@ -56,6 +56,7 @@ import { CipherCollectionsRequest } from "../models/request/cipher-collections.r
import { CipherCreateRequest } from "../models/request/cipher-create.request";
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
import { CipherShareRequest } from "../models/request/cipher-share.request";
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
import { CipherRequest } from "../models/request/cipher.request";
import { CipherResponse } from "../models/response/cipher.response";
import { AttachmentView } from "../models/view/attachment.view";
@ -1168,6 +1169,34 @@ export class CipherService implements CipherServiceAbstraction {
});
}
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<CipherWithIdRequest[]> {
if (originalUserKey == null) {
throw new Error("Original user key is required to rotate ciphers");
}
if (newUserKey == null) {
throw new Error("New user key is required to rotate ciphers");
}
let encryptedCiphers: CipherWithIdRequest[] = [];
const ciphers = await this.getAllDecrypted();
if (!ciphers || ciphers.length === 0) {
return encryptedCiphers;
}
encryptedCiphers = await Promise.all(
ciphers.map(async (cipher) => {
const encryptedCipher = await this.encrypt(cipher, newUserKey, originalUserKey);
return new CipherWithIdRequest(encryptedCipher);
}),
);
return encryptedCiphers;
}
// Helpers
// In the case of a cipher that is being shared with an organization, we want to decrypt the

View File

@ -178,6 +178,29 @@ describe("Folder Service", () => {
// });
});
describe("getRotatedData", () => {
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
let encryptedKey: EncString;
beforeEach(() => {
encryptedKey = new EncString("Re-encrypted Folder");
cryptoService.encrypt.mockResolvedValue(encryptedKey);
});
it("returns re-encrypted user folders", async () => {
const result = await folderService.getRotatedData(originalUserKey, newUserKey, mockUserId);
expect(result[0]).toMatchObject({ id: "1", name: "Re-encrypted Folder" });
});
it("throws if the new user key is null", async () => {
await expect(folderService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
"New user key is required for rotation.",
);
});
});
function folderData(id: string, name: string) {
const data = new FolderData({} as any);
data.id = id;

View File

@ -6,12 +6,14 @@ import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { FolderData } from "../../../vault/models/data/folder.data";
import { Folder } from "../../../vault/models/domain/folder";
import { FolderView } from "../../../vault/models/view/folder.view";
import { Cipher } from "../../models/domain/cipher";
import { FolderWithIdRequest } from "../../models/request/folder-with-id.request";
import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state";
export class FolderService implements InternalFolderServiceAbstraction {
@ -170,4 +172,27 @@ export class FolderService implements InternalFolderServiceAbstraction {
decryptedFolders.push(noneFolder);
return decryptedFolders;
}
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<FolderWithIdRequest[]> {
if (newUserKey == null) {
throw new Error("New user key is required for rotation.");
}
let encryptedFolders: FolderWithIdRequest[] = [];
const folders = await firstValueFrom(this.folderViews$);
if (!folders) {
return encryptedFolders;
}
encryptedFolders = await Promise.all(
folders.map(async (folder) => {
const encryptedFolder = await this.encrypt(folder, newUserKey);
return new FolderWithIdRequest(encryptedFolder);
}),
);
return encryptedFolders;
}
}

View File

@ -10,12 +10,14 @@ export type Sort = {
fn?: SortFn;
};
export type FilterFn<T> = (data: T) => boolean;
// Loosely based on CDK TableDataSource
// https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
export class TableDataSource<T> extends DataSource<T> {
private readonly _data: BehaviorSubject<T[]>;
private readonly _sort: BehaviorSubject<Sort>;
private readonly _filter = new BehaviorSubject<string>("");
private readonly _filter = new BehaviorSubject<string | FilterFn<T>>(null);
private readonly _renderData = new BehaviorSubject<T[]>([]);
private _renderChangesSubscription: Subscription | null = null;
@ -55,11 +57,15 @@ export class TableDataSource<T> extends DataSource<T> {
return this._sort.value;
}
/**
* Filter to apply to the `data`.
*
* If a string is provided, it will be converted to a filter using {@link simpleStringFilter}
**/
get filter() {
return this._filter.value;
}
set filter(filter: string) {
set filter(filter: string | FilterFn<T>) {
this._filter.next(filter);
// Normally the `filteredData` is updated by the re-render
// subscription, but that won't happen if it's inactive.
@ -95,10 +101,11 @@ export class TableDataSource<T> extends DataSource<T> {
}
private filterData(data: T[]): T[] {
this.filteredData =
this.filter == null || this.filter === ""
? data
: data.filter((obj) => this.filterPredicate(obj, this.filter));
const filter =
typeof this.filter === "string"
? TableDataSource.simpleStringFilter(this.filter)
: this.filter;
this.filteredData = this.filter == null ? data : data.filter((obj) => filter(obj));
return this.filteredData;
}
@ -207,36 +214,39 @@ export class TableDataSource<T> extends DataSource<T> {
}
/**
* Copied from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
* Modified from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
* License: MIT
* Copyright (c) 2022 Google LLC.
*
* Checks if a data object matches the data source's filter string. By default, each data object
* @param filter the string to search for
* @returns a function that checks if a data object matches the provided `filter` string. Each data object
* is converted to a string of its properties and returns true if the filter has
* at least one occurrence in that string. By default, the filter string has its whitespace
* trimmed and the match is case-insensitive. May be overridden for a custom implementation of
* filter matching.
* @param data Data object used to check against the filter.
* @param filter Filter string that has been set on the data source.
* @returns Whether the filter matches against the data
* at least one occurrence in that string. The filter string has its whitespace
* trimmed and the match is case-insensitive.
*/
protected filterPredicate(data: T, filter: string): boolean {
// Transform the data into a lowercase string of all property values.
const dataStr = Object.keys(data as unknown as Record<string, any>)
.reduce((currentTerm: string, key: string) => {
// Use an obscure Unicode character to delimit the words in the concatenated string.
// This avoids matches where the values of two columns combined will match the user's query
// (e.g. `Flute` and `Stop` will match `Test`). The character is intended to be something
// that has a very low chance of being typed in by somebody in a text field. This one in
// particular is "White up-pointing triangle with dot" from
// https://en.wikipedia.org/wiki/List_of_Unicode_characters
return currentTerm + (data as unknown as Record<string, any>)[key] + "◬";
}, "")
.toLowerCase();
static readonly simpleStringFilter = <T>(filter: string): FilterFn<T> => {
return (data: T): boolean => {
if (!filter) {
return true;
}
// Transform the filter by converting it to lowercase and removing whitespace.
const transformedFilter = filter.trim().toLowerCase();
// Transform the data into a lowercase string of all property values.
const dataStr = Object.keys(data as unknown as Record<string, any>)
.reduce((currentTerm: string, key: string) => {
// Use an obscure Unicode character to delimit the words in the concatenated string.
// This avoids matches where the values of two columns combined will match the user's query
// (e.g. `Flute` and `Stop` will match `Test`). The character is intended to be something
// that has a very low chance of being typed in by somebody in a text field. This one in
// particular is "White up-pointing triangle with dot" from
// https://en.wikipedia.org/wiki/List_of_Unicode_characters
return currentTerm + (data as unknown as Record<string, any>)[key] + "◬";
}, "")
.toLowerCase();
return dataStr.indexOf(transformedFilter) != -1;
}
// Transform the filter by converting it to lowercase and removing whitespace.
const transformedFilter = filter.trim().toLowerCase();
return dataStr.indexOf(transformedFilter) != -1;
};
};
}

View File

@ -121,11 +121,20 @@ const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1);
### Filtering
The `TableDataSource` supports a rudimentary filtering capability most commonly used to implement a
search function. It works by converting each entry into a string of it's properties. The string is
then compared against the filter value using a simple `indexOf`check.
Filtering is supported by passing a filter predicate to `filter`.
```ts
dataSource.filter = (data) => data.orgType === "family";
```
Rudimentary string filtering is supported out of the box with `TableDataSource.simpleStringFilter`.
It works by converting each entry into a string of it's properties. The provided string is then
compared against the filter value using a simple `indexOf` check. For convienence, you can also just
pass a string directly.
```ts
dataSource.filter = TableDataSource.simpleStringFilter("search value");
// or
dataSource.filter = "search value";
```

View File

@ -12,7 +12,9 @@
"@bitwarden/components": ["../components/src"],
"@bitwarden/generator-components": ["../tools/generator/components/src"],
"@bitwarden/generator-core": ["../tools/generator/core/src"],
"@bitwarden/generator-extensions": ["../tools/generator/extensions/src"],
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"],
"@bitwarden/vault-export-ui": ["../tools/export/vault-export/vault-export-ui/src"],
"@bitwarden/importer/core": ["../importer/src"],

View File

@ -1,7 +1,7 @@
{
"name": "@bitwarden/generator-components",
"version": "0.0.0",
"description": "Angular components for the Bitwarden generators",
"description": "Angular components for the Bitwarden credential generators",
"keywords": [
"bitwarden"
],

View File

@ -1,7 +1,7 @@
{
"name": "@bitwarden/generator-core",
"version": "0.0.0",
"description": "TODO",
"description": "Common Bitwarden credential generation logic",
"keywords": [
"bitwarden"
],

View File

@ -1,5 +1,3 @@
export { GeneratorHistoryService } from "../../../extensions/src/history/generator-history.abstraction";
export { GeneratorNavigationService } from "../../../extensions/src/navigation/generator-navigation.service.abstraction";
export { GeneratorService } from "./generator.service.abstraction";
export { GeneratorStrategy } from "./generator-strategy.abstraction";
export { PolicyEvaluator } from "./policy-evaluator.abstraction";

View File

@ -1,13 +1,13 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../../../shared/tsconfig.libs");
const { compilerOptions } = require("../../../../shared/tsconfig.libs");
/** @type {import('jest').Config} */
module.exports = {
testMatch: ["**/+(*.)+(spec).+(ts)"],
preset: "ts-jest",
testEnvironment: "../../../shared/test.environment.ts",
testEnvironment: "../../../../shared/test.environment.ts",
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/../../",
prefix: "<rootDir>/../../../",
}),
};

View File

@ -1,7 +1,7 @@
{
"name": "@bitwarden/generator-extensions",
"name": "@bitwarden/generator-history",
"version": "0.0.0",
"description": "TODO",
"description": "Bitwarden credential generator history service",
"keywords": [
"bitwarden"
],

View File

@ -1,4 +1,4 @@
import { GeneratorCategory, GeneratedCredential } from "./";
import { GeneratorCategory, GeneratedCredential } from ".";
describe("GeneratedCredential", () => {
describe("constructor", () => {

Some files were not shown because too many files have changed in this diff Show More