1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-12-05 09:14:28 +01:00

Merge branch 'main' into PM-24749-UI-in-progress-dialog

This commit is contained in:
John Harrington 2025-12-04 20:12:02 -07:00 committed by GitHub
commit d63566cb2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
137 changed files with 2843 additions and 663 deletions

View File

@ -175,9 +175,23 @@ jobs:
- name: Check out repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Free disk space for build
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/.ghcup
sudo rm -rf /usr/share/miniconda
sudo rm -rf /usr/share/az_*
sudo rm -rf /usr/local/julia*
sudo rm -rf /usr/lib/mono
sudo rm -rf /usr/lib/heroku
sudo rm -rf /usr/local/aws-cli
sudo rm -rf /usr/local/aws-sam-cli
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
@ -249,9 +263,11 @@ jobs:
PKG_CONFIG_ALLOW_CROSS: true
PKG_CONFIG_ALL_STATIC: true
TARGET: musl
# Note: It is important that we use the release build because some compute heavy
# operations such as key derivation for oo7 on linux are too slow in debug mode
run: |
rustup target add x86_64-unknown-linux-musl
node build.js --target=x86_64-unknown-linux-musl
node build.js --target=x86_64-unknown-linux-musl --release
- name: Build application
run: npm run dist:lin
@ -412,9 +428,11 @@ jobs:
PKG_CONFIG_ALLOW_CROSS: true
PKG_CONFIG_ALL_STATIC: true
TARGET: musl
# Note: It is important that we use the release build because some compute heavy
# operations such as key derivation for oo7 on linux are too slow in debug mode
run: |
rustup target add aarch64-unknown-linux-musl
node build.js --target=aarch64-unknown-linux-musl
node build.js --target=aarch64-unknown-linux-musl --release
- name: Check index.d.ts generated
if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true'

View File

@ -1406,6 +1406,27 @@
"learnMore": {
"message": "Learn more"
},
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"later": {
"message": "Later"
},
"authenticatorKeyTotp": {
"message": "Authenticator key (TOTP)"
},

View File

@ -0,0 +1,193 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { platformPopoutGuard } from "./platform-popout.guard";
describe("platformPopoutGuard", () => {
let getPlatformInfoSpy: jest.SpyInstance;
let inPopoutSpy: jest.SpyInstance;
let inSidebarSpy: jest.SpyInstance;
let openPopoutSpy: jest.SpyInstance;
let closePopupSpy: jest.SpyInstance;
const mockRoute = {} as ActivatedRouteSnapshot;
const mockState: RouterStateSnapshot = {
url: "/login-with-passkey?param=value",
} as RouterStateSnapshot;
beforeEach(() => {
getPlatformInfoSpy = jest.spyOn(BrowserApi, "getPlatformInfo");
inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout");
inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar");
openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation();
TestBed.configureTestingModule({});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("when platform matches", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should open popout and block navigation when not already in popout or sidebar", async () => {
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(getPlatformInfoSpy).toHaveBeenCalled();
expect(inPopoutSpy).toHaveBeenCalledWith(window);
expect(inSidebarSpy).toHaveBeenCalledWith(window);
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
});
it("should allow navigation when already in popout", async () => {
inPopoutSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(closePopupSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it("should allow navigation when already in sidebar", async () => {
inSidebarSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(closePopupSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("when platform does not match", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should allow navigation without opening popout", async () => {
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(getPlatformInfoSpy).toHaveBeenCalled();
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("when forcePopout is true", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should open popout regardless of platform", async () => {
const guard = platformPopoutGuard(["linux"], true);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
});
it("should not open popout when already in popout", async () => {
inPopoutSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"], true);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("with multiple platforms", () => {
beforeEach(() => {
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it.each(["linux", "mac", "win"])(
"should open popout when platform is %s and included in platforms array",
async (platform) => {
getPlatformInfoSpy.mockResolvedValue({ os: platform });
const guard = platformPopoutGuard(["linux", "mac", "win"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
},
);
it("should not open popout when platform is not in the array", async () => {
getPlatformInfoSpy.mockResolvedValue({ os: "android" });
const guard = platformPopoutGuard(["linux", "mac"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("url handling", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should preserve query parameters in the popout url", async () => {
const stateWithQuery: RouterStateSnapshot = {
url: "/path?foo=bar&baz=qux",
} as RouterStateSnapshot;
const guard = platformPopoutGuard(["linux"]);
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/path?foo=bar&baz=qux&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
});
it("should handle urls without query parameters", async () => {
const stateWithoutQuery: RouterStateSnapshot = {
url: "/simple-path",
} as RouterStateSnapshot;
const guard = platformPopoutGuard(["linux"]);
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/simple-path?autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
});
});
});

View File

@ -0,0 +1,46 @@
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
/**
* Guard that forces a popout window for specific platforms.
* Useful when popup context would close during operations (e.g., WebAuthn on Linux).
*
* @param platforms - Array of platform OS strings (e.g., ["linux", "mac", "win"])
* @param forcePopout - If true, always force popout regardless of platform (useful for testing)
* @returns CanActivateFn that opens popout and blocks navigation if conditions met
*/
export function platformPopoutGuard(
platforms: string[],
forcePopout: boolean = false,
): CanActivateFn {
return async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
// Check if current platform matches
const platformInfo = await BrowserApi.getPlatformInfo();
const isPlatformMatch = platforms.includes(platformInfo.os);
// Check if already in popout/sidebar
const inPopout = BrowserPopupUtils.inPopout(window);
const inSidebar = BrowserPopupUtils.inSidebar(window);
// Open popout if conditions met
if ((isPlatformMatch || forcePopout) && !inPopout && !inSidebar) {
// Add autoClosePopout query param to signal the popout should close after completion
const [path, existingQuery] = state.url.split("?");
const params = new URLSearchParams(existingQuery || "");
params.set("autoClosePopout", "true");
const urlWithAutoClose = `${path}?${params.toString()}`;
// Open the popout window
await BrowserPopupUtils.openPopout(`popup/index.html#${urlWithAutoClose}`);
// Close the original popup window
BrowserApi.closePopup(window);
return false; // Block navigation - popout will reload
}
return true; // Allow navigation
};
}

View File

@ -627,11 +627,11 @@ export default class NotificationBackground {
}
const username: string | null = data.username || null;
const currentPassword = data.password || null;
const newPassword = data.newPassword || null;
const currentPasswordFieldValue = data.password || null;
const newPasswordFieldValue = data.newPassword || null;
if (authStatus === AuthenticationStatus.Locked && newPassword !== null) {
await this.pushChangePasswordToQueue(null, loginDomain, newPassword, tab, true);
if (authStatus === AuthenticationStatus.Locked && newPasswordFieldValue !== null) {
await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true);
return true;
}
@ -657,35 +657,49 @@ export default class NotificationBackground {
const [cipher] = ciphers;
if (
username !== null &&
newPassword === null &&
cipher.login.username === normalizedUsername &&
cipher.login.password === currentPassword
newPasswordFieldValue === null &&
cipher.login.username.toLowerCase() === normalizedUsername &&
cipher.login.password === currentPasswordFieldValue
) {
// Assumed to be a login
return false;
}
}
if (currentPassword && !newPassword) {
if (
ciphers.length > 0 &&
currentPasswordFieldValue?.length &&
// Only use current password for change if no new password present.
if (ciphers.length > 0) {
await this.pushChangePasswordToQueue(
ciphers.map((cipher) => cipher.id),
loginDomain,
currentPassword,
tab,
);
return true;
!newPasswordFieldValue
) {
const currentPasswordMatchesAnExistingValue = ciphers.some(
(cipher) =>
cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue,
);
// The password entered matched a stored cipher value with
// the same username (no change)
if (currentPasswordMatchesAnExistingValue) {
return false;
}
await this.pushChangePasswordToQueue(
ciphers.map((cipher) => cipher.id),
loginDomain,
currentPasswordFieldValue,
tab,
);
return true;
}
if (newPassword) {
if (newPasswordFieldValue) {
// Otherwise include all known ciphers.
if (ciphers.length > 0) {
await this.pushChangePasswordToQueue(
ciphers.map((cipher) => cipher.id),
loginDomain,
newPassword,
newPasswordFieldValue,
tab,
);

View File

@ -262,11 +262,30 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*/
private notificationDataIncompleteOnBeforeRequest = (tabId: number) => {
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
return (
!modifyLoginData ||
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Add) ||
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Change)
if (!modifyLoginData) {
return true;
}
const shouldAttemptAddNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Add,
);
if (shouldAttemptAddNotification) {
return false;
}
const shouldAttemptChangeNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Change,
);
if (shouldAttemptChangeNotification) {
return false;
}
return false;
};
/**
@ -454,15 +473,27 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
modifyLoginData: ModifyLoginCipherFormData,
notificationType: NotificationType,
): boolean => {
// Intentionally not stripping whitespace characters here as they
// represent user entry.
const usernameFieldHasValue = !!(modifyLoginData?.username || "").length;
const passwordFieldHasValue = !!(modifyLoginData?.password || "").length;
const newPasswordFieldHasValue = !!(modifyLoginData?.newPassword || "").length;
const canBeUserLogin = usernameFieldHasValue && passwordFieldHasValue;
const canBePasswordUpdate = passwordFieldHasValue && newPasswordFieldHasValue;
switch (notificationType) {
// `Add` case included because all forms with cached usernames (from previous
// visits) will appear to be "password only" and otherwise trigger the new login
// save notification.
case NotificationTypes.Add:
return (
modifyLoginData?.username && !!(modifyLoginData.password || modifyLoginData.newPassword)
);
// Can be values for nonstored login or account creation
return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue);
case NotificationTypes.Change:
return !!(modifyLoginData.password || modifyLoginData.newPassword);
// Can be login with nonstored login changes or account password update
return canBeUserLogin || canBePasswordUpdate;
case NotificationTypes.AtRiskPassword:
return !modifyLoginData.newPassword;
return !newPasswordFieldHasValue;
case NotificationTypes.Unlock:
// Unlock notifications are handled separately and do not require form data
return false;

View File

@ -39,6 +39,7 @@ export class AutoFillConstants {
"otpcode",
"onetimepassword",
"security_code",
"second-factor",
"twofactor",
"twofa",
"twofactorcode",

View File

@ -1603,14 +1603,14 @@ describe("AutofillOverlayContentService", () => {
it("skips triggering submission if a button is not found", async () => {
const submitButton = document.querySelector("button");
submitButton.remove();
submitButton?.remove();
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
"formFieldSubmitted",
@ -1627,7 +1627,7 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock,
);
await flushPromises();
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted",
@ -1641,7 +1641,7 @@ describe("AutofillOverlayContentService", () => {
<div id="shadow-root"></div>
<button id="button-el">Change Password</button>
</div>`;
const shadowRoot = document.getElementById("shadow-root").attachShadow({ mode: "open" });
const shadowRoot = document.getElementById("shadow-root")!.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<input type="password" id="password-field-1" placeholder="new password" />
`;
@ -1668,7 +1668,7 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock,
);
await flushPromises();
buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
buttonElement?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted",
@ -1716,6 +1716,85 @@ describe("AutofillOverlayContentService", () => {
});
});
describe("refreshMenuLayerPosition", () => {
it("calls refreshTopLayerPosition on the inline menu content service", () => {
autofillOverlayContentService.refreshMenuLayerPosition();
expect(inlineMenuContentService.refreshTopLayerPosition).toHaveBeenCalled();
});
it("does not throw if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
expect(() => serviceWithoutInlineMenu.refreshMenuLayerPosition()).not.toThrow();
});
});
describe("getOwnedInlineMenuTagNames", () => {
it("returns tag names from the inline menu content service", () => {
inlineMenuContentService.getOwnedTagNames.mockReturnValue(["div", "span"]);
const result = autofillOverlayContentService.getOwnedInlineMenuTagNames();
expect(result).toEqual(["div", "span"]);
});
it("returns an empty array if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const result = serviceWithoutInlineMenu.getOwnedInlineMenuTagNames();
expect(result).toEqual([]);
});
});
describe("getUnownedTopLayerItems", () => {
it("returns unowned top layer items from the inline menu content service", () => {
const mockElements = document.querySelectorAll("div");
inlineMenuContentService.getUnownedTopLayerItems.mockReturnValue(mockElements);
const result = autofillOverlayContentService.getUnownedTopLayerItems(true);
expect(result).toEqual(mockElements);
expect(inlineMenuContentService.getUnownedTopLayerItems).toHaveBeenCalledWith(true);
});
it("returns undefined if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const result = serviceWithoutInlineMenu.getUnownedTopLayerItems();
expect(result).toBeUndefined();
});
});
describe("clearUserFilledFields", () => {
it("deletes all user filled fields", () => {
const mockElement1 = document.createElement("input") as FillableFormFieldElement;
const mockElement2 = document.createElement("input") as FillableFormFieldElement;
autofillOverlayContentService["userFilledFields"] = {
username: mockElement1,
password: mockElement2,
};
autofillOverlayContentService.clearUserFilledFields();
expect(autofillOverlayContentService["userFilledFields"]).toEqual({});
});
});
describe("handleOverlayRepositionEvent", () => {
const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE];
repositionEvents.forEach((repositionEvent) => {
@ -2049,7 +2128,7 @@ describe("AutofillOverlayContentService", () => {
});
it("skips focusing an element if no recently focused field exists", async () => {
autofillOverlayContentService["mostRecentlyFocusedField"] = undefined;
(autofillOverlayContentService as any)["mostRecentlyFocusedField"] = null;
sendMockExtensionMessage({
command: "redirectAutofillInlineMenuFocusOut",
@ -2149,7 +2228,6 @@ describe("AutofillOverlayContentService", () => {
});
it("returns null if the sub frame URL cannot be parsed correctly", async () => {
delete globalThis.location;
globalThis.location = { href: "invalid-base" } as Location;
sendMockExtensionMessage(
{

View File

@ -945,7 +945,8 @@ export class InlineMenuFieldQualificationService
!fieldType ||
!this.usernameFieldTypes.has(fieldType) ||
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) ||
this.fieldHasDisqualifyingAttributeValue(field)
this.fieldHasDisqualifyingAttributeValue(field) ||
this.isTotpField(field)
) {
return false;
}

View File

@ -48,6 +48,7 @@ import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/ke
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant";
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
import { platformPopoutGuard } from "../auth/popup/guards/platform-popout.guard";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
@ -414,7 +415,7 @@ const routes: Routes = [
},
{
path: AuthRoute.LoginWithPasskey,
canActivate: [unauthGuardFn(unauthRouteOverrides)],
canActivate: [unauthGuardFn(unauthRouteOverrides), platformPopoutGuard(["linux"])],
data: {
pageIcon: TwoFactorAuthSecurityKeyIcon,
pageTitle: {

View File

@ -381,4 +381,88 @@ describe("AddEditV2Component", () => {
expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]);
});
});
describe("reloadAddEditCipherData", () => {
beforeEach(fakeAsync(() => {
addEditCipherInfo$.next({
cipher: {
name: "InitialName",
type: CipherType.Login,
login: {
password: "initialPassword",
username: "initialUsername",
uris: [{ uri: "https://initial.com" }],
},
},
} as AddEditCipherInfo);
queryParams$.next({});
tick();
cipherServiceMock.setAddEditCipherInfo.mockClear();
}));
it("replaces all initialValues with new data, clearing stale fields", fakeAsync(() => {
const newCipherInfo = {
cipher: {
name: "UpdatedName",
type: CipherType.Login,
login: {
password: "updatedPassword",
uris: [{ uri: "https://updated.com" }],
},
},
} as AddEditCipherInfo;
addEditCipherInfo$.next(newCipherInfo);
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(component.config.initialValues).toEqual({
name: "UpdatedName",
password: "updatedPassword",
loginUri: "https://updated.com",
} as OptionalInitialValues);
expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledWith(null, "UserId");
}));
it("does not reload data if config is not set", fakeAsync(() => {
component.config = null;
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
}));
it("does not reload data if latestCipherInfo is null", fakeAsync(() => {
addEditCipherInfo$.next(null);
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(component.config.initialValues).toEqual({
name: "InitialName",
password: "initialPassword",
username: "initialUsername",
loginUri: "https://initial.com",
} as OptionalInitialValues);
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
}));
it("ignores messages with different commands", fakeAsync(() => {
const initialValues = component.config.initialValues;
const messageListener = component["messageListener"];
messageListener({ command: "someOtherCommand" });
tick();
expect(component.config.initialValues).toBe(initialValues);
}));
});
});

View File

@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Component, OnInit, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute, Params, Router } from "@angular/router";
@ -158,7 +158,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
IconButtonModule,
],
})
export class AddEditV2Component implements OnInit {
export class AddEditV2Component implements OnInit, OnDestroy {
headerText: string;
config: CipherFormConfig;
canDeleteCipher$: Observable<boolean>;
@ -200,12 +200,58 @@ export class AddEditV2Component implements OnInit {
this.subscribeToParams();
}
private messageListener: (message: any) => void;
async ngOnInit() {
this.fido2PopoutSessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (BrowserPopupUtils.inPopout(window)) {
this.popupCloseWarningService.enable();
}
// Listen for messages to reload cipher data when the pop up is already open
this.messageListener = async (message: any) => {
if (message?.command === "reloadAddEditCipherData") {
try {
await this.reloadCipherData();
} catch (error) {
this.logService.error("Failed to reload cipher data", error);
}
}
};
BrowserApi.addListener(chrome.runtime.onMessage, this.messageListener);
}
ngOnDestroy() {
if (this.messageListener) {
BrowserApi.removeListener(chrome.runtime.onMessage, this.messageListener);
}
}
/**
* Reloads the cipher data when the popup is already open and new form data is submitted.
* This completely replaces the initialValues to clear any stale data from the previous submission.
*/
private async reloadCipherData() {
if (!this.config) {
return;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const latestCipherInfo = await firstValueFrom(
this.cipherService.addEditCipherInfo$(activeUserId),
);
if (latestCipherInfo != null) {
this.config = {
...this.config,
initialValues: mapAddEditCipherInfoToInitialValues(latestCipherInfo),
};
// Be sure to clear the "cached" cipher info, so it doesn't get used again
await this.cipherService.setAddEditCipherInfo(null, activeUserId);
}
}
/**

View File

@ -108,7 +108,7 @@ describe("ItemMoreOptionsComponent", () => {
{ provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } },
{
provide: CipherArchiveService,
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) },
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: of(true) },
},
{ provide: ToastService, useValue: { showToast: () => {} } },
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },

View File

@ -141,7 +141,7 @@ export class ItemMoreOptionsComponent {
}),
);
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$();
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$;
protected canArchive$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,

View File

@ -49,7 +49,7 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy {
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
);
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$());
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$);
protected readonly userHasArchivedItems = toSignal(
this.userId$.pipe(

View File

@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
import { CipherType } from "@bitwarden/common/vault/enums";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import {
@ -23,6 +24,19 @@ describe("VaultPopoutWindow", () => {
.spyOn(BrowserPopupUtils, "closeSingleActionPopout")
.mockImplementation();
beforeEach(() => {
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]);
jest.spyOn(BrowserApi, "updateWindowProperties").mockResolvedValue();
global.chrome = {
...global.chrome,
runtime: {
...global.chrome?.runtime,
sendMessage: jest.fn().mockResolvedValue(undefined),
getURL: jest.fn((path) => `chrome-extension://extension-id/${path}`),
},
};
});
afterEach(() => {
jest.clearAllMocks();
});
@ -123,6 +137,32 @@ describe("VaultPopoutWindow", () => {
},
);
});
it("sends a message to refresh data when the popup is already open", async () => {
const existingPopupTab = {
id: 123,
windowId: 456,
url: `chrome-extension://extension-id/popup/index.html#/edit-cipher?singleActionPopout=${VaultPopoutType.addEditVaultItem}_${CipherType.Login}`,
} as chrome.tabs.Tab;
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([existingPopupTab]);
const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage");
const updateWindowSpy = jest.spyOn(BrowserApi, "updateWindowProperties");
await openAddEditVaultItemPopout(
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://jest-testing-website.com" }),
{
cipherType: CipherType.Login,
},
);
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith({
command: "reloadAddEditCipherData",
data: { cipherId: undefined, cipherType: CipherType.Login },
});
expect(updateWindowSpy).toHaveBeenCalledWith(456, { focused: true });
});
});
describe("closeAddEditVaultItemPopout", () => {

View File

@ -115,10 +115,26 @@ async function openAddEditVaultItemPopout(
addEditCipherUrl += formatQueryString("uri", url);
}
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
singleActionKey,
senderWindowId: windowId,
});
const extensionUrl = chrome.runtime.getURL("popup/index.html");
const existingPopupTabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` });
const existingPopup = existingPopupTabs.find((tab) =>
tab.url?.includes(`singleActionPopout=${singleActionKey}`),
);
// Check if the an existing popup is already open
try {
await chrome.runtime.sendMessage({
command: "reloadAddEditCipherData",
data: { cipherId, cipherType },
});
await BrowserApi.updateWindowProperties(existingPopup.windowId, {
focused: true,
});
} catch {
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
singleActionKey,
senderWindowId: windowId,
});
}
}
/**

View File

@ -75,7 +75,7 @@
"inquirer": "8.2.6",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"koa": "2.16.3",
"koa": "3.1.1",
"koa-bodyparser": "4.4.1",
"koa-json": "2.0.2",
"lowdb": "1.0.0",
@ -83,7 +83,7 @@
"multer": "2.0.2",
"node-fetch": "2.6.12",
"node-forge": "1.3.2",
"open": "10.1.2",
"open": "11.0.0",
"papaparse": "5.5.3",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",

View File

@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@ -81,6 +82,7 @@ export class LoginCommand {
protected ssoUrlService: SsoUrlService,
protected i18nService: I18nService,
protected masterPasswordService: MasterPasswordServiceAbstraction,
protected encryptedMigrator: EncryptedMigrator,
) {}
async run(email: string, password: string, options: OptionValues) {
@ -367,6 +369,8 @@ export class LoginCommand {
}
}
await this.encryptedMigrator.runMigrations(response.userId, password);
return await this.handleSuccessResponse(response);
} catch (e) {
if (

View File

@ -182,6 +182,7 @@ export abstract class BaseProgram {
this.serviceContainer.organizationApiService,
this.serviceContainer.logout,
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@ -40,6 +41,7 @@ describe("UnlockCommand", () => {
const organizationApiService = mock<OrganizationApiServiceAbstraction>();
const logout = jest.fn();
const i18nService = mock<I18nService>();
const encryptedMigrator = mock<EncryptedMigrator>();
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
const configService = mock<ConfigService>();
@ -92,6 +94,7 @@ describe("UnlockCommand", () => {
organizationApiService,
logout,
i18nService,
encryptedMigrator,
masterPasswordUnlockService,
configService,
);

View File

@ -9,6 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@ -38,6 +39,7 @@ export class UnlockCommand {
private organizationApiService: OrganizationApiServiceAbstraction,
private logout: () => Promise<void>,
private i18nService: I18nService,
private encryptedMigrator: EncryptedMigrator,
private masterPasswordUnlockService: MasterPasswordUnlockService,
private configService: ConfigService,
) {}
@ -116,6 +118,8 @@ export class UnlockCommand {
}
}
await this.encryptedMigrator.runMigrations(userId, password);
return this.successResponse();
}

View File

@ -176,6 +176,7 @@ export class OssServeConfigurator {
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@ -195,6 +195,7 @@ export class Program extends BaseProgram {
this.serviceContainer.ssoUrlService,
this.serviceContainer.i18nService,
this.serviceContainer.masterPasswordService,
this.serviceContainer.encryptedMigrator,
);
const response = await command.run(email, password, options);
this.processResponse(response, true);
@ -311,6 +312,7 @@ export class Program extends BaseProgram {
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@ -76,6 +76,10 @@ import {
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@ -324,6 +328,7 @@ export class ServiceContainer {
cipherEncryptionService: CipherEncryptionService;
restrictedItemTypesService: RestrictedItemTypesService;
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
encryptedMigrator: EncryptedMigrator;
securityStateService: SecurityStateService;
masterPasswordUnlockService: MasterPasswordUnlockService;
cipherArchiveService: CipherArchiveService;
@ -975,6 +980,16 @@ export class ServiceContainer {
);
this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService);
const changeKdfApiService = new DefaultChangeKdfApiService(this.apiService);
const changeKdfService = new DefaultChangeKdfService(changeKdfApiService, this.sdkService);
this.encryptedMigrator = new DefaultEncryptedMigrator(
this.kdfConfigService,
changeKdfService,
this.logService,
this.configService,
this.masterPasswordService,
this.syncService,
);
}
async logout() {

View File

@ -61,8 +61,8 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
for (browser, config) in SUPPORTED_BROWSER_MAP.iter() {
let data_dir = get_browser_data_dir(config)?;
if data_dir.exists() {
let data_dir = get_and_validate_data_dir(config);
if data_dir.is_ok() {
browsers.push((*browser).to_string());
}
}
@ -114,7 +114,7 @@ pub async fn import_logins(
#[derive(Debug, Clone, Copy)]
pub(crate) struct BrowserConfig {
pub name: &'static str,
pub data_dir: &'static str,
pub data_dir: &'static [&'static str],
}
pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock<
@ -126,11 +126,19 @@ pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock<
.collect::<std::collections::HashMap<_, _>>()
});
fn get_browser_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
let dir = dirs::home_dir()
.ok_or_else(|| anyhow!("Home directory not found"))?
.join(config.data_dir);
Ok(dir)
fn get_and_validate_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
for data_dir in config.data_dir.iter() {
let dir = dirs::home_dir()
.ok_or_else(|| anyhow!("Home directory not found"))?
.join(data_dir);
if dir.exists() {
return Ok(dir);
}
}
Err(anyhow!(
"Browser user data directory '{:?}' not found",
config.data_dir
))
}
//
@ -174,13 +182,7 @@ fn load_local_state_for_browser(browser_name: &String) -> Result<(PathBuf, Local
.get(browser_name.as_str())
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
let data_dir = get_browser_data_dir(config)?;
if !data_dir.exists() {
return Err(anyhow!(
"Browser user data directory '{}' not found",
data_dir.display()
));
}
let data_dir = get_and_validate_data_dir(config)?;
let local_state = load_local_state(&data_dir)?;

View File

@ -18,19 +18,22 @@ use crate::{
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Chrome",
data_dir: ".config/google-chrome",
data_dir: &[".config/google-chrome"],
},
BrowserConfig {
name: "Chromium",
data_dir: "snap/chromium/common/chromium",
data_dir: &["snap/chromium/common/chromium"],
},
BrowserConfig {
name: "Brave",
data_dir: "snap/brave/current/.config/BraveSoftware/Brave-Browser",
data_dir: &[
"snap/brave/current/.config/BraveSoftware/Brave-Browser",
".config/BraveSoftware/Brave-Browser",
],
},
BrowserConfig {
name: "Opera",
data_dir: "snap/opera/current/.config/opera",
data_dir: &["snap/opera/current/.config/opera", ".config/opera"],
},
];

View File

@ -14,31 +14,31 @@ use crate::{
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Chrome",
data_dir: "Library/Application Support/Google/Chrome",
data_dir: &["Library/Application Support/Google/Chrome"],
},
BrowserConfig {
name: "Chromium",
data_dir: "Library/Application Support/Chromium",
data_dir: &["Library/Application Support/Chromium"],
},
BrowserConfig {
name: "Microsoft Edge",
data_dir: "Library/Application Support/Microsoft Edge",
data_dir: &["Library/Application Support/Microsoft Edge"],
},
BrowserConfig {
name: "Brave",
data_dir: "Library/Application Support/BraveSoftware/Brave-Browser",
data_dir: &["Library/Application Support/BraveSoftware/Brave-Browser"],
},
BrowserConfig {
name: "Arc",
data_dir: "Library/Application Support/Arc/User Data",
data_dir: &["Library/Application Support/Arc/User Data"],
},
BrowserConfig {
name: "Opera",
data_dir: "Library/Application Support/com.operasoftware.Opera",
data_dir: &["Library/Application Support/com.operasoftware.Opera"],
},
BrowserConfig {
name: "Vivaldi",
data_dir: "Library/Application Support/Vivaldi",
data_dir: &["Library/Application Support/Vivaldi"],
},
];

View File

@ -25,27 +25,27 @@ pub use signature::*;
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Brave",
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
data_dir: &["AppData/Local/BraveSoftware/Brave-Browser/User Data"],
},
BrowserConfig {
name: "Chrome",
data_dir: "AppData/Local/Google/Chrome/User Data",
data_dir: &["AppData/Local/Google/Chrome/User Data"],
},
BrowserConfig {
name: "Chromium",
data_dir: "AppData/Local/Chromium/User Data",
data_dir: &["AppData/Local/Chromium/User Data"],
},
BrowserConfig {
name: "Microsoft Edge",
data_dir: "AppData/Local/Microsoft/Edge/User Data",
data_dir: &["AppData/Local/Microsoft/Edge/User Data"],
},
BrowserConfig {
name: "Opera",
data_dir: "AppData/Roaming/Opera Software/Opera Stable",
data_dir: &["AppData/Roaming/Opera Software/Opera Stable"],
},
BrowserConfig {
name: "Vivaldi",
data_dir: "AppData/Local/Vivaldi/User Data",
data_dir: &["AppData/Local/Vivaldi/User Data"],
},
];

View File

@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "",
"scripts": {
"build": "node scripts/build.js",
"build": "napi build --platform --js false",
"test": "cargo test"
},
"author": "",

View File

@ -1,14 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { execSync } = require('child_process');
const args = process.argv.slice(2);
const isRelease = args.includes('--release');
if (isRelease) {
console.log('Building release mode.');
} else {
console.log('Building debug mode.');
process.env.RUST_LOG = 'debug';
}
execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env });

View File

@ -961,7 +961,7 @@ pub mod logging {
};
use tracing::Level;
use tracing_subscriber::{
filter::EnvFilter,
filter::{EnvFilter, LevelFilter},
fmt::format::{DefaultVisitor, Writer},
layer::SubscriberExt,
util::SubscriberInitExt,
@ -1049,17 +1049,9 @@ pub mod logging {
pub fn init_napi_log(js_log_fn: ThreadsafeFunction<(LogLevel, String), CalleeHandled>) {
let _ = JS_LOGGER.0.set(js_log_fn);
// the log level hierarchy is determined by:
// - if RUST_LOG is detected at runtime
// - if RUST_LOG is provided at compile time
// - default to INFO
let filter = EnvFilter::builder()
.with_default_directive(
option_env!("RUST_LOG")
.unwrap_or("info")
.parse()
.expect("should provide valid log level at compile time."),
)
// set the default log level to INFO.
.with_default_directive(LevelFilter::INFO.into())
// parse directives from the RUST_LOG environment variable,
// overriding the default directive for matching targets.
.from_env_lossy();

View File

@ -32,8 +32,9 @@
<string>/Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
<string>/Library/Application Support/net.imput.helium</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true/>

View File

@ -1093,6 +1093,24 @@
"learnMore": {
"message": "Learn more"
},
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"featureUnavailable": {
"message": "Feature unavailable"
},

View File

@ -314,6 +314,7 @@ export class NativeMessagingMain {
"Microsoft Edge Canary": `${this.homedir()}/Library/Application\ Support/Microsoft\ Edge\ Canary/`,
Vivaldi: `${this.homedir()}/Library/Application\ Support/Vivaldi/`,
Zen: `${this.homedir()}/Library/Application\ Support/Zen/`,
Helium: `${this.homedir()}/Library/Application\ Support/net.imput.helium/`,
};
/* eslint-enable no-useless-escape */
}

View File

@ -225,7 +225,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
switchMap((id) =>
combineLatest([
this.cipherArchiveService.userCanArchive$(id),
this.cipherArchiveService.hasArchiveFlagEnabled$(),
this.cipherArchiveService.hasArchiveFlagEnabled$,
]),
),
),

View File

@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
@ -59,6 +60,7 @@ export class VaultFilterComponent
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
protected cipherArchiveService: CipherArchiveService,
premiumUpgradePromptService: PremiumUpgradePromptService,
) {
super(
vaultFilterService,
@ -72,6 +74,7 @@ export class VaultFilterComponent
restrictedItemTypesService,
cipherService,
cipherArchiveService,
premiumUpgradePromptService,
);
}

View File

@ -2,12 +2,15 @@
<app-side-nav variant="secondary" *ngIf="organization$ | async as organization">
<bit-nav-logo [openIcon]="logo" route="." [label]="'adminConsole' | i18n"></bit-nav-logo>
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
<bit-nav-item
icon="bwi-dashboard"
*ngIf="organization.canAccessReports"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
></bit-nav-item>
@if (canShowAccessIntelligenceTab(organization)) {
<bit-nav-item
icon="bwi-dashboard"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
></bit-nav-item>
}
<bit-nav-item
icon="bwi-collection-shared"
[text]="'collections' | i18n"

View File

@ -8,6 +8,7 @@ import { combineLatest, filter, map, Observable, switchMap, withLatestFrom } fro
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AdminConsoleLogo } from "@bitwarden/assets/svg";
import {
canAccessAccessIntelligence,
canAccessBillingTab,
canAccessGroupsTab,
canAccessMembersTab,
@ -172,6 +173,10 @@ export class OrganizationLayoutComponent implements OnInit {
return canAccessBillingTab(organization);
}
canShowAccessIntelligenceTab(organization: Organization): boolean {
return canAccessAccessIntelligence(organization);
}
getReportTabLabel(organization: Organization): string {
return organization.useEvents ? "reporting" : "reports";
}

View File

@ -108,7 +108,7 @@ export class RecoverTwoFactorComponent implements OnInit {
message: this.i18nService.t("twoStepRecoverDisabled"),
});
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, this.masterPassword);
await this.router.navigate(["/settings/security/two-factor"]);
} catch (error: unknown) {

View File

@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";

View File

@ -5,7 +5,7 @@ import { firstValueFrom, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";

View File

@ -1096,6 +1096,9 @@ describe("KeyRotationService", () => {
mockKeyService.userSigningKey$.mockReturnValue(
new BehaviorSubject(TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey),
);
mockKeyService.userSignedPublicKey$.mockReturnValue(
new BehaviorSubject(TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 as SignedPublicKey),
);
mockSecurityStateService.accountSecurityState$.mockReturnValue(
new BehaviorSubject(TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState),
);
@ -1140,6 +1143,7 @@ describe("KeyRotationService", () => {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 as SignedPublicKey,
},
signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,

View File

@ -10,6 +10,7 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import {
SignedPublicKey,
SignedSecurityState,
UnsignedPublicKey,
WrappedPrivateKey,
@ -308,9 +309,11 @@ export class UserKeyRotationService {
userId: asUuid(userId),
kdfParams: kdfConfig.toSdkConfig(),
email: email,
privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
signingKey: undefined,
securityState: undefined,
accountCryptographicState: {
V1: {
private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
},
},
method: {
decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() },
},
@ -334,9 +337,15 @@ export class UserKeyRotationService {
userId: asUuid(userId),
kdfParams: kdfConfig.toSdkConfig(),
email: email,
privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
signingKey: cryptographicStateParameters.signingKey,
securityState: cryptographicStateParameters.securityState,
accountCryptographicState: {
V2: {
private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
signing_key: cryptographicStateParameters.signingKey,
security_state: cryptographicStateParameters.securityState,
signed_public_key:
cryptographicStateParameters.publicKeyEncryptionKeyPair.signedPublicKey,
},
},
method: {
decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() },
},
@ -632,6 +641,10 @@ export class UserKeyRotationService {
this.securityStateService.accountSecurityState$(user.id),
"User security state",
);
const signedPublicKey = await this.firstValueFromOrThrow(
this.keyService.userSignedPublicKey$(user.id),
"User signed public key",
);
return {
masterKeyKdfConfig,
@ -642,6 +655,7 @@ export class UserKeyRotationService {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: currentUserKeyWrappedPrivateKey,
publicKey: publicKey,
signedPublicKey: signedPublicKey!,
},
signingKey: signingKey!,
securityState: securityState!,
@ -679,6 +693,7 @@ export type V2CryptographicStateParameters = {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: WrappedPrivateKey;
publicKey: UnsignedPublicKey;
signedPublicKey: SignedPublicKey;
};
signingKey: WrappedSigningKey;
securityState: SignedSecurityState;

View File

@ -203,10 +203,22 @@
{{ "eventLogs" | i18n }}
</button>
@if (showArchiveButton) {
<button bitMenuItem (click)="archive()" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
@if (userCanArchive) {
<button bitMenuItem (click)="archive()" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
}
@if (!userCanArchive) {
<button bitMenuItem (click)="badge.promptForPremium($event)" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
<!-- Hide app-premium badge from accessibility tools as it results in a button within a button -->
<div slot="end" class="-tw-mt-0.5" aria-hidden>
<app-premium-badge #badge></app-premium-badge>
</div>
</button>
}
}
@if (showUnArchiveButton) {

View File

@ -72,6 +72,7 @@ describe("VaultCipherRowComponent", () => {
fixture = TestBed.createComponent(VaultCipherRowComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("archiveEnabled", false);
overlayContainer = TestBed.inject(OverlayContainer);
});

View File

@ -8,6 +8,7 @@ import {
OnInit,
Output,
ViewChild,
input,
} from "@angular/core";
import { CollectionView } from "@bitwarden/admin-console/common";
@ -101,8 +102,10 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() userCanArchive: boolean;
/** Archive feature is enabled */
readonly archiveEnabled = input.required<boolean>();
/**
* Enforge Org Data Ownership Policy Status
* Enforce Org Data Ownership Policy Status
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ -142,16 +145,21 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
}
protected get showArchiveButton() {
if (!this.archiveEnabled()) {
return false;
}
return (
this.userCanArchive &&
!CipherViewLikeUtils.isArchived(this.cipher) &&
!CipherViewLikeUtils.isDeleted(this.cipher) &&
!this.cipher.organizationId
!CipherViewLikeUtils.isArchived(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher)
);
}
// If item is archived always show unarchive button, even if user is not premium
protected get showUnArchiveButton() {
if (!this.archiveEnabled()) {
return false;
}
return CipherViewLikeUtils.isArchived(this.cipher);
}

View File

@ -179,6 +179,7 @@
(onEvent)="event($event)"
[userCanArchive]="userCanArchive"
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy"
[archiveEnabled]="archiveFeatureEnabled$ | async"
></tr>
</ng-container>
</ng-template>

View File

@ -4,6 +4,7 @@ import { of } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
@ -54,6 +55,12 @@ describe("VaultItemsComponent", () => {
t: (key: string) => key,
},
},
{
provide: CipherArchiveService,
useValue: {
hasArchiveFlagEnabled$: of(true),
},
},
],
});

View File

@ -7,6 +7,7 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs";
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
RestrictedCipherType,
@ -145,9 +146,12 @@ export class VaultItemsComponent<C extends CipherViewLike> {
protected disableMenu$: Observable<boolean>;
private restrictedTypes: RestrictedCipherType[] = [];
protected archiveFeatureEnabled$ = this.cipherArchiveService.hasArchiveFlagEnabled$;
constructor(
protected cipherAuthorizationService: CipherAuthorizationService,
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherArchiveService: CipherArchiveService,
) {
this.canDeleteSelected$ = this.selection.changed.pipe(
startWith(null),

View File

@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { ScrollLayoutDirective, TableModule } from "@bitwarden/components";
import { CopyCipherFieldDirective } from "@bitwarden/vault";
@ -29,6 +30,7 @@ import { VaultItemsComponent } from "./vault-items.component";
PipesModule,
CopyCipherFieldDirective,
ScrollLayoutDirective,
PremiumBadgeComponent,
],
declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent],
exports: [VaultItemsComponent],

View File

@ -30,6 +30,7 @@ import {
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@ -143,6 +144,12 @@ export default {
isCipherRestricted: () => false, // No restrictions for this story
},
},
{
provide: CipherArchiveService,
useValue: {
hasArchiveFlagEnabled$: of(true),
},
},
],
}),
applicationConfig({

View File

@ -19,8 +19,10 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
@ -170,6 +172,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
protected cipherArchiveService: CipherArchiveService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
) {}
async ngOnInit(): Promise<void> {
@ -252,14 +255,20 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
};
async buildAllFilters(): Promise<VaultFilterList> {
const hasArchiveFlag = await firstValueFrom(this.cipherArchiveService.hasArchiveFlagEnabled$());
const [userId, showArchive] = await firstValueFrom(
combineLatest([
this.accountService.activeAccount$.pipe(getUserId),
this.cipherArchiveService.hasArchiveFlagEnabled$,
]),
);
const builderFilter = {} as VaultFilterList;
builderFilter.organizationFilter = await this.addOrganizationFilter();
builderFilter.typeFilter = await this.addTypeFilter();
builderFilter.folderFilter = await this.addFolderFilter();
builderFilter.collectionFilter = await this.addCollectionFilter();
if (hasArchiveFlag) {
builderFilter.archiveFilter = await this.addArchiveFilter();
if (showArchive) {
builderFilter.archiveFilter = await this.addArchiveFilter(userId);
}
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;
@ -419,7 +428,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
return trashFilterSection;
}
protected async addArchiveFilter(): Promise<VaultFilterSection> {
protected async addArchiveFilter(userId: UserId): Promise<VaultFilterSection> {
const [hasArchivedCiphers, userHasPremium] = await firstValueFrom(
combineLatest([
this.cipherArchiveService
.archivedCiphers$(userId)
.pipe(map((archivedCiphers) => archivedCiphers.length > 0)),
this.cipherArchiveService.userHasPremium$(userId),
]),
);
const promptForPremiumOnFilter = !userHasPremium && !hasArchivedCiphers;
const archiveFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.buildTypeTree(
{
@ -442,6 +462,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
isSelectable: true,
},
action: this.applyTypeFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
premiumOptions: {
showBadgeForNonPremium: true,
blockFilterAction: promptForPremiumOnFilter
? async () => await this.premiumUpgradePromptService.promptForPremium()
: undefined,
},
};
return archiveFilterSection;
}

View File

@ -105,6 +105,9 @@
*ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)"
></ng-container>
</ng-container>
<ng-container *ngIf="premiumFeature">
<app-premium-badge></app-premium-badge>
</ng-container>
</span>
</span>
<ul

View File

@ -96,6 +96,11 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
}
async onFilterSelect(filterNode: TreeNode<VaultFilterType>) {
if (this.section?.premiumOptions?.blockFilterAction) {
await this.section.premiumOptions.blockFilterAction();
return;
}
await this.section?.action(filterNode);
}
@ -123,6 +128,10 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
return this.section?.options;
}
get premiumFeature() {
return this.section?.premiumOptions?.showBadgeForNonPremium;
}
get divider() {
return this.section?.divider;
}

View File

@ -47,6 +47,16 @@ export type VaultFilterSection = {
component: any;
};
divider?: boolean;
premiumOptions?: {
/** When true, the premium badge will show on the filter for non-premium users. */
showBadgeForNonPremium?: true;
/**
* Action to be called instead of applying the filter.
* Useful when the user does not have access to a filter (e.g., premium feature)
* and custom behavior is needed when invoking the filter.
*/
blockFilterAction?: () => Promise<void>;
};
};
export type VaultFilterList = {

View File

@ -1,5 +1,6 @@
import { NgModule } from "@angular/core";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { SearchModule } from "@bitwarden/components";
import { SharedModule } from "../../../../shared";
@ -7,7 +8,7 @@ import { SharedModule } from "../../../../shared";
import { VaultFilterSectionComponent } from "./components/vault-filter-section.component";
@NgModule({
imports: [SharedModule, SearchModule],
imports: [SharedModule, SearchModule, PremiumBadgeComponent],
declarations: [VaultFilterSectionComponent],
exports: [SharedModule, VaultFilterSectionComponent, SearchModule],
})

View File

@ -34,6 +34,16 @@
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<bit-callout
type="info"
[title]="'premiumSubscriptionEnded' | i18n"
*ngIf="showSubscriptionEndedMessaging$ | async"
>
<p>{{ "premiumSubscriptionEndedDesc" | i18n }}</p>
<a routerLink="/settings/subscription/premium" bitButton buttonType="primary">{{
"restartPremium" | i18n
}}</a>
</bit-callout>
<app-vault-items
#vaultItems
[ciphers]="ciphers"

View File

@ -84,7 +84,7 @@ import {
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal";
import {
AddEditFolderDialogComponent,
@ -177,6 +177,7 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
VaultItemsModule,
SharedModule,
OrganizationWarningsModule,
BannerComponent,
],
providers: [
RoutedVaultFilterService,
@ -230,13 +231,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
.pipe(map((a) => a?.id))
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
protected userCanArchive$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => {
return this.cipherArchiveService.userCanArchive$(userId);
}),
);
emptyState$ = combineLatest([
this.currentSearchText$,
this.routedVaultFilterService.filter$,
@ -295,14 +289,28 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}),
);
protected enforceOrgDataOwnershipPolicy$ = this.accountService.activeAccount$.pipe(
getUserId,
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
protected enforceOrgDataOwnershipPolicy$ = this.userId$.pipe(
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
),
);
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
protected userCanArchive$ = this.userId$.pipe(
switchMap((userId) => {
return this.cipherArchiveService.userCanArchive$(userId);
}),
);
protected showSubscriptionEndedMessaging$ = this.userId$.pipe(
switchMap((userId) =>
combineLatest([
this.routedVaultFilterBridgeService.activeFilter$,
this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)),
),
);
constructor(
private syncService: SyncService,
@ -438,13 +446,13 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
allowedCiphers$,
filter$,
this.currentSearchText$,
this.cipherArchiveService.hasArchiveFlagEnabled$(),
this.cipherArchiveService.hasArchiveFlagEnabled$,
]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText, archiveEnabled]) => {
concatMap(async ([ciphers, filter, searchText, showArchiveVault]) => {
const failedCiphers =
(await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? [];
const filterFunction = createFilterFunction(filter, archiveEnabled);
const filterFunction = createFilterFunction(filter, showArchiveVault);
// Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers];

View File

@ -3133,6 +3133,15 @@
}
}
},
"premiumSubscriptionEnded": {
"message": "Your Premium subscription ended"
},
"premiumSubscriptionEndedDesc": {
"message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault."
},
"restartPremium": {
"message": "Restart Premium"
},
"additionalStorageGb": {
"message": "Additional storage (GB)"
},
@ -4621,6 +4630,24 @@
"learnMore": {
"message": "Learn more"
},
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"deleteRecoverDesc": {
"message": "Enter your email address below to recover and delete your account."
},

View File

@ -2,7 +2,10 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { authGuard } from "@bitwarden/angular/auth/guards";
import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
canAccessAccessIntelligence,
canAccessSettingsTab,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { isEnterpriseOrgGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/is-enterprise-org.guard";
import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard";
import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/admin-console/organizations/layouts/organization-layout.component";
@ -79,7 +82,7 @@ const routes: Routes = [
},
{
path: "access-intelligence",
canActivate: [organizationPermissionsGuard((org) => org.canAccessReports)],
canActivate: [organizationPermissionsGuard(canAccessAccessIntelligence)],
loadChildren: () =>
import("../../dirt/access-intelligence/access-intelligence.module").then(
(m) => m.AccessIntelligenceModule,

View File

@ -2,18 +2,20 @@
@let provider = provider$ | async;
<app-header [title]="pageTitle">
<bit-search [placeholder]="'search' | i18n" [formControl]="searchControl"></bit-search>
<button
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="clientMenu"
[disabled]="isSuspensionActive"
[title]="isSuspensionActive ? ('providerIsDisabled' | i18n) : ''"
appA11yTitle="{{ 'add' | i18n }}"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "add" | i18n }}
</button>
@if (provider?.type === ProviderUserType.ProviderAdmin) {
<button
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="clientMenu"
[disabled]="isSuspensionActive"
[title]="isSuspensionActive ? ('providerIsDisabled' | i18n) : ''"
appA11yTitle="{{ 'add' | i18n }}"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "add" | i18n }}
</button>
}
<bit-menu #clientMenu>
<button
type="button"

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessAccessIntelligence } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard";
import { RiskInsightsComponent } from "./risk-insights.component";
@ -8,7 +9,7 @@ import { RiskInsightsComponent } from "./risk-insights.component";
const routes: Routes = [
{
path: "",
canActivate: [organizationPermissionsGuard((org) => org.canAccessReports)],
canActivate: [organizationPermissionsGuard(canAccessAccessIntelligence)],
component: RiskInsightsComponent,
data: {
titleId: "accessIntelligence",

View File

@ -2,7 +2,7 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@ -19,6 +19,7 @@ import { ClientType } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import {
@ -49,6 +50,7 @@ export type State = "assert" | "assertFailed";
})
export class LoginViaWebAuthnComponent implements OnInit {
protected currentState: State = "assert";
private shouldAutoClosePopout = false;
protected readonly Icons = {
TwoFactorAuthSecurityKeyIcon,
@ -70,6 +72,7 @@ export class LoginViaWebAuthnComponent implements OnInit {
constructor(
private webAuthnLoginService: WebAuthnLoginServiceAbstraction,
private router: Router,
private route: ActivatedRoute,
private logService: LogService,
private validationService: ValidationService,
private i18nService: I18nService,
@ -77,9 +80,14 @@ export class LoginViaWebAuthnComponent implements OnInit {
private keyService: KeyService,
private platformUtilsService: PlatformUtilsService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private messagingService: MessagingService,
) {}
ngOnInit(): void {
// Check if we should auto-close the popout after successful authentication
this.shouldAutoClosePopout =
this.route.snapshot.queryParamMap.get("autoClosePopout") === "true";
// 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
this.authenticate();
@ -120,7 +128,18 @@ export class LoginViaWebAuthnComponent implements OnInit {
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
if (userKey) {
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, null);
}
// If autoClosePopout is enabled and we're in a browser extension,
// re-open the regular popup and close this popout window
if (
this.shouldAutoClosePopout &&
this.platformUtilsService.getClientType() === ClientType.Browser
) {
this.messagingService.send("openPopup");
window.close();
return;
}
await this.router.navigate([this.successRoute]);

View File

@ -0,0 +1,9 @@
import { UserId } from "@bitwarden/common/types/guid";
export abstract class EncryptedMigrationsSchedulerService {
/**
* Runs migrations for a user if needed, handling both interactive and non-interactive cases
* @param userId The user ID to run migrations for
*/
abstract runMigrationsIfNeeded(userId: UserId): Promise<void>;
}

View File

@ -0,0 +1,270 @@
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { FakeAccountService } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import {
DefaultEncryptedMigrationsSchedulerService,
ENCRYPTED_MIGRATION_DISMISSED,
} from "./encrypted-migrations-scheduler.service";
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const accounts: Record<UserId, AccountInfo> = {
[SomeUser]: {
name: "some user",
email: "some.user@example.com",
emailVerified: true,
},
[AnotherUser]: {
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
},
};
describe("DefaultEncryptedMigrationsSchedulerService", () => {
let service: DefaultEncryptedMigrationsSchedulerService;
const mockAccountService = new FakeAccountService(accounts);
const mockAuthService = mock<AuthService>();
const mockEncryptedMigrator = mock<EncryptedMigrator>();
const mockStateProvider = mock<StateProvider>();
const mockSyncService = mock<SyncService>();
const mockDialogService = mock<DialogService>();
const mockToastService = mock<ToastService>();
const mockI18nService = mock<I18nService>();
const mockLogService = mock<LogService>();
const mockRouter = mock<Router>();
const mockUserId = "test-user-id" as UserId;
const mockMasterPassword = "test-master-password";
const createMockUserState = <T>(value: T): jest.Mocked<SingleUserState<T>> =>
({
state$: of(value),
userId: mockUserId,
update: jest.fn(),
combinedState$: of([mockUserId, value]),
}) as any;
beforeEach(() => {
const mockDialogRef = {
closed: of(mockMasterPassword),
};
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
mockI18nService.t.mockReturnValue("translated_migrationsFailed");
(mockRouter as any)["events"] = of({ url: "/vault" }) as any;
service = new DefaultEncryptedMigrationsSchedulerService(
mockSyncService,
mockAccountService,
mockStateProvider,
mockEncryptedMigrator,
mockAuthService,
mockLogService,
mockDialogService,
mockToastService,
mockI18nService,
mockRouter,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("runMigrationsIfNeeded", () => {
it("should return early if user is not unlocked", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Locked));
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).not.toHaveBeenCalled();
expect(mockLogService.info).not.toHaveBeenCalled();
});
it("should log and return when no migration is needed", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("noMigrationNeeded");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] No migrations needed for user ${mockUserId}`,
);
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
});
it("should run migrations without interaction when master password is not required", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
});
it("should run migrations with interaction when migration is needed", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
});
describe("runMigrationsWithoutInteraction", () => {
it("should run migrations without master password", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("should handle errors during migration without interaction", async () => {
const mockError = new Error("Migration failed");
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
expect(mockLogService.error).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Error during migration without interaction",
mockError,
);
});
});
describe("runMigrationsWithInteraction", () => {
beforeEach(() => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
});
it("should skip if migration was dismissed recently", async () => {
const recentDismissDate = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
const mockUserState = createMockUserState(recentDismissDate);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
mockUserId,
ENCRYPTED_MIGRATION_DISMISSED,
);
expect(mockLogService.info).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
);
expect(PromptMigrationPasswordComponent.open).not.toHaveBeenCalled();
});
it("should prompt for migration if dismissed date is older than 24 hours", async () => {
const oldDismissDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const mockUserState = createMockUserState(oldDismissDate);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
mockUserId,
ENCRYPTED_MIGRATION_DISMISSED,
);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
it("should prompt for migration if no dismiss date exists", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
it("should set dismiss date when empty password is provided", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
const mockDialogRef = {
closed: of(""), // Empty password
};
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
ENCRYPTED_MIGRATION_DISMISSED,
expect.any(Date),
mockUserId,
);
});
it("should handle errors during migration prompt and show toast", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
const mockError = new Error("Migration failed");
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
expect(mockLogService.error).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Error during migration prompt",
mockError,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "translated_migrationsFailed",
});
});
});
});

View File

@ -0,0 +1,188 @@
import { NavigationEnd, Router } from "@angular/router";
import {
combineLatest,
switchMap,
of,
firstValueFrom,
filter,
concatMap,
Observable,
map,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
UserKeyDefinition,
ENCRYPTED_MIGRATION_DISK,
StateProvider,
} from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { EncryptedMigrationsSchedulerService } from "./encrypted-migrations-scheduler.service.abstraction";
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>(
ENCRYPTED_MIGRATION_DISK,
"encryptedMigrationDismissed",
{
deserializer: (obj: string) => (obj != null ? new Date(obj) : null),
clearOn: [],
},
);
const DISMISS_TIME_HOURS = 24;
const VAULT_ROUTE = "/vault";
/**
* This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction,
* if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while,
* or regularly logs in without a master-password, when the migrations do require a master-password to run.
*/
export class DefaultEncryptedMigrationsSchedulerService
implements EncryptedMigrationsSchedulerService
{
isMigrating = false;
url$: Observable<string>;
constructor(
private syncService: SyncService,
private accountService: AccountService,
private stateProvider: StateProvider,
private encryptedMigrator: EncryptedMigrator,
private authService: AuthService,
private logService: LogService,
private dialogService: DialogService,
private toastService: ToastService,
private i18nService: I18nService,
private router: Router,
) {
this.url$ = this.router.events.pipe(
filter((event: any) => event instanceof NavigationEnd),
map((event: NavigationEnd) => event.url),
);
// For all accounts, if the auth status changes to unlocked or a sync happens, prompt for migration
this.accountService.accounts$
.pipe(
switchMap((accounts) => {
const userIds = Object.keys(accounts) as UserId[];
if (userIds.length === 0) {
return of([]);
}
return combineLatest(
userIds.map((userId) =>
combineLatest([
this.authService.authStatusFor$(userId),
this.syncService.lastSync$(userId).pipe(filter((lastSync) => lastSync != null)),
this.url$,
]).pipe(
filter(
([authStatus, _date, url]) =>
authStatus === AuthenticationStatus.Unlocked && url === VAULT_ROUTE,
),
concatMap(() => this.runMigrationsIfNeeded(userId)),
),
),
);
}),
)
.subscribe();
}
async runMigrationsIfNeeded(userId: UserId): Promise<void> {
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
if (this.isMigrating || this.encryptedMigrator.isRunningMigrations()) {
this.logService.info(
`[EncryptedMigrationsScheduler] Skipping migration check for user ${userId} because migrations are already in progress`,
);
return;
}
this.isMigrating = true;
switch (await this.encryptedMigrator.needsMigrations(userId)) {
case "noMigrationNeeded":
this.logService.info(
`[EncryptedMigrationsScheduler] No migrations needed for user ${userId}`,
);
break;
case "needsMigrationWithMasterPassword":
this.logService.info(
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
);
// If the user is unlocked, we can run migrations with the master password
await this.runMigrationsWithInteraction(userId);
break;
case "needsMigration":
this.logService.info(
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
);
// If the user is unlocked, we can prompt for the master password
await this.runMigrationsWithoutInteraction(userId);
break;
}
this.isMigrating = false;
}
private async runMigrationsWithoutInteraction(userId: UserId): Promise<void> {
try {
await this.encryptedMigrator.runMigrations(userId, null);
} catch (error) {
this.logService.error(
"[EncryptedMigrationsScheduler] Error during migration without interaction",
error,
);
}
}
private async runMigrationsWithInteraction(userId: UserId): Promise<void> {
// A dialog can be dismissed for a certain amount of time
const dismissedDate = await firstValueFrom(
this.stateProvider.getUser(userId, ENCRYPTED_MIGRATION_DISMISSED).state$,
);
if (dismissedDate != null) {
const now = new Date();
const timeDiff = now.getTime() - (dismissedDate as Date).getTime();
const hoursDiff = timeDiff / (1000 * 60 * 60);
if (hoursDiff < DISMISS_TIME_HOURS) {
this.logService.info(
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
);
return;
}
}
try {
const dialog = PromptMigrationPasswordComponent.open(this.dialogService);
const masterPassword = await firstValueFrom(dialog.closed);
if (Utils.isNullOrWhitespace(masterPassword)) {
await this.stateProvider.setUserState(ENCRYPTED_MIGRATION_DISMISSED, new Date(), userId);
} else {
await this.encryptedMigrator.runMigrations(
userId,
masterPassword === undefined ? null : masterPassword,
);
}
} catch (error) {
this.logService.error("[EncryptedMigrationsScheduler] Error during migration prompt", error);
// If migrations failed when the user actively was prompted, show a toast
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("migrationsFailed"),
});
}
}
}

View File

@ -0,0 +1,55 @@
<form [bitSubmit]="submit" [formGroup]="migrationPasswordForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
{{ "updateEncryptionSettingsTitle" | i18n }}
</div>
<div bitDialogContent>
<p>
{{ "updateEncryptionSettingsDesc" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/kdf-algorithms/"
target="_blank"
rel="noreferrer"
aria-label="external link"
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link" aria-hidden="true"></i>
</a>
</p>
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<bit-hint>{{ "confirmIdentityToContinue" | i18n }}</bit-hint>
<input
class="tw-font-mono"
bitInput
type="password"
formControlName="masterPassword"
[attr.title]="'masterPass' | i18n"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[attr.title]="'toggleVisibility' | i18n"
[attr.aria-label]="'toggleVisibility' | i18n"
></button>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button
type="submit"
bitButton
bitFormButton
buttonType="primary"
[disabled]="migrationPasswordForm.invalid"
>
<span>{{ "updateSettings" | i18n }}</span>
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "later" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,85 @@
import { CommonModule } from "@angular/common";
import { Component, inject, ChangeDetectionStrategy } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { filter, firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import {
LinkModule,
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogRef,
DialogService,
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
/**
* This is a generic prompt to run encryption migrations that require the master password.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "prompt-migration-password.component.html",
imports: [
DialogModule,
LinkModule,
CommonModule,
JslibModule,
ButtonModule,
IconButtonModule,
ReactiveFormsModule,
AsyncActionsModule,
FormFieldModule,
],
})
export class PromptMigrationPasswordComponent {
private dialogRef = inject(DialogRef<string>);
private formBuilder = inject(FormBuilder);
private uvService = inject(UserVerificationService);
private accountService = inject(AccountService);
migrationPasswordForm = this.formBuilder.group({
masterPassword: ["", [Validators.required]],
});
static open(dialogService: DialogService) {
return dialogService.open<string>(PromptMigrationPasswordComponent);
}
submit = async () => {
const masterPasswordControl = this.migrationPasswordForm.controls.masterPassword;
if (!masterPasswordControl.value || masterPasswordControl.invalid) {
return;
}
const { userId, email } = await firstValueFrom(
this.accountService.activeAccount$.pipe(
filter((account) => account != null),
map((account) => {
return {
userId: account!.id,
email: account!.email,
};
}),
),
);
if (
!(await this.uvService.verifyUserByMasterPassword(
{ type: VerificationType.MasterPassword, secret: masterPasswordControl.value },
userId,
email,
))
) {
return;
}
// Return the master password to the caller
this.dialogRef.close(masterPasswordControl.value);
};
}

View File

@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { APP_INITIALIZER, ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
@ -177,10 +178,12 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
@ -328,6 +331,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
DialogService,
ToastService,
} from "@bitwarden/components";
import {
@ -396,6 +400,8 @@ import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from ".
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
import { DefaultEncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service";
import { EncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { DocumentLangSetter } from "../platform/i18n";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@ -516,6 +522,23 @@ const safeProviders: SafeProvider[] = [
TokenServiceAbstraction,
],
}),
safeProvider({
provide: ChangeKdfService,
useClass: DefaultChangeKdfService,
deps: [ChangeKdfApiService, SdkService],
}),
safeProvider({
provide: EncryptedMigrator,
useClass: DefaultEncryptedMigrator,
deps: [
KdfConfigService,
ChangeKdfService,
LogService,
ConfigService,
MasterPasswordServiceAbstraction,
SyncService,
],
}),
safeProvider({
provide: LoginStrategyServiceAbstraction,
useClass: LoginStrategyService,
@ -1665,6 +1688,7 @@ const safeProviders: SafeProvider[] = [
SsoLoginServiceAbstraction,
SyncService,
UserAsymmetricKeysRegenerationService,
EncryptedMigrator,
LogService,
],
}),
@ -1735,6 +1759,28 @@ const safeProviders: SafeProvider[] = [
InternalMasterPasswordServiceAbstraction,
],
}),
safeProvider({
provide: EncryptedMigrationsSchedulerService,
useClass: DefaultEncryptedMigrationsSchedulerService,
deps: [
SyncService,
AccountService,
StateProvider,
EncryptedMigrator,
AuthServiceAbstraction,
LogService,
DialogService,
ToastService,
I18nServiceAbstraction,
Router,
],
}),
safeProvider({
provide: APP_INITIALIZER as SafeInjectionToken<() => Promise<void>>,
useFactory: (encryptedMigrationsScheduler: EncryptedMigrationsSchedulerService) => () => {},
deps: [EncryptedMigrationsSchedulerService],
multi: true,
}),
safeProvider({
provide: LockService,
useClass: DefaultLockService,

View File

@ -89,7 +89,7 @@ export class VaultFilterComponent implements OnInit {
this.collections = await this.initCollections();
this.showArchiveVaultFilter = await firstValueFrom(
this.cipherArchiveService.hasArchiveFlagEnabled$(),
this.cipherArchiveService.hasArchiveFlagEnabled$,
);
this.isLoaded = true;

View File

@ -822,7 +822,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
private async handleSuccessfulLoginNavigation(userId: UserId) {
await this.loginSuccessHandlerService.run(userId);
await this.loginSuccessHandlerService.run(userId, null);
await this.router.navigate(["vault"]);
}
}

View File

@ -382,7 +382,7 @@ export class LoginComponent implements OnInit, OnDestroy {
}
// User logged in successfully so execute side effects
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// Determine where to send the user next
// The AuthGuard will handle routing to change-password based on state

View File

@ -152,9 +152,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
return;
}
// 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
this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// TODO: PM-22663 use the new service to handle routing.
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));

View File

@ -206,7 +206,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
return;
}
await this.loginSuccessHandlerService.run(authenticationResult.userId);
await this.loginSuccessHandlerService.run(
authenticationResult.userId,
authenticationResult.masterPassword ?? null,
);
if (this.premiumInterest) {
await this.premiumInterestStateService.setPremiumInterest(

View File

@ -437,7 +437,7 @@ export class SsoComponent implements OnInit {
// Everything after the 2FA check is considered a successful login
// Just have to figure out where to send the user
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, null);
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
// - TDE login decryption options component

View File

@ -450,7 +450,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
}
// User is fully logged in so handle any post login logic before executing navigation
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component

View File

@ -5,6 +5,7 @@ export abstract class LoginSuccessHandlerService {
* Runs any service calls required after a successful login.
* Service calls that should be included in this method are only those required to be awaited after successful login.
* @param userId The user id.
* @param masterPassword The master password, if available. Null when logging in with SSO or other non-master-password methods.
*/
abstract run(userId: UserId): Promise<void>;
abstract run(userId: UserId, masterPassword: string | null): Promise<void>;
}

View File

@ -259,7 +259,7 @@ describe("LoginStrategy", () => {
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
UserDecryptionOptions.fromResponse(idTokenResponse),
UserDecryptionOptions.fromIdentityTokenResponse(idTokenResponse),
);
expect(masterPasswordService.mock.setMasterPasswordUnlockData).toHaveBeenCalledWith(
new MasterPasswordUnlockData(
@ -308,6 +308,7 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials);
const expected = new AuthResult();
expected.masterPassword = "password";
expected.userId = userId;
expected.resetMasterPassword = true;
expected.twoFactorProviders = null;
@ -323,6 +324,7 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials);
const expected = new AuthResult();
expected.masterPassword = "password";
expected.userId = userId;
expected.resetMasterPassword = false;
expected.twoFactorProviders = null;

View File

@ -108,6 +108,8 @@ export abstract class LoginStrategy {
data.tokenRequest.setTwoFactor(twoFactor);
this.cache.next(data);
const [authResult] = await this.startLogIn();
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
authResult.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
return authResult;
}
@ -197,7 +199,7 @@ export abstract class LoginStrategy {
// as the user decryption options help determine the available timeout actions.
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
UserDecryptionOptions.fromResponse(tokenResponse),
UserDecryptionOptions.fromIdentityTokenResponse(tokenResponse),
);
if (tokenResponse.userDecryptionOptions?.masterPasswordUnlock != null) {
@ -264,6 +266,9 @@ export abstract class LoginStrategy {
await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
this.messagingService.send("loggedIn");
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
// TODO: https://bitwarden.atlassian.net/browse/PM-27573
result.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
return result;
}

View File

@ -33,6 +33,8 @@ export class PasswordLoginStrategyData implements LoginStrategyData {
localMasterKeyHash: string;
/** The user's master key */
masterKey: MasterKey;
/** The user's master password */
masterPassword: string;
/**
* Tracks if the user needs to update their password due to
* a password that does not meet an organization's master password policy.
@ -83,6 +85,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
masterPassword,
email,
);
data.masterPassword = masterPassword;
data.userEnteredEmail = email;
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
@ -251,6 +254,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
this.cache.next(data);
const [authResult] = await this.startLogIn();
authResult.masterPassword = this.cache.value["masterPassword"] ?? null;
return authResult;
}

View File

@ -503,67 +503,6 @@ describe("SsoLoginStrategy", () => {
HasMasterPassword: false,
KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl },
});
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray,
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
await ssoLoginStrategy.logIn(credentials);
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl, userId);
});
it("converts new SSO user with no master password to Key Connector on first login", async () => {
tokenResponse.key = undefined;
tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLoginStrategy.logIn(credentials);
expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith(
{
kdfConfig: new Argon2KdfConfig(10, 64, 4),
keyConnectorUrl: keyConnectorUrl,
organizationId: ssoOrgId,
},
userId,
);
});
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray,
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await ssoLoginStrategy.logIn(credentials);
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
});
});
describe("Key Connector Pre-TDE", () => {
let tokenResponse: IdentityTokenResponse;
beforeEach(() => {
tokenResponse = identityTokenResponseFactory();
tokenResponse.userDecryptionOptions = null;
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {

View File

@ -157,22 +157,12 @@ export class SsoLoginStrategy extends LoginStrategy {
// In order for us to set the master key from Key Connector, we need to have a Key Connector URL
// and the user must not have a master password.
return userHasKeyConnectorUrl && !userHasMasterPassword;
} else {
// In pre-TDE versions of the server, the userDecryptionOptions will not be present.
// In this case, we can determine if the user has a master password and has a Key Connector URL by
// just checking the keyConnectorUrl property. This is because the server short-circuits on the response
// and will not pass back the URL in the response if the user has a master password.
// TODO: remove compatibility check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
return tokenResponse.keyConnectorUrl != null;
}
}
private getKeyConnectorUrl(tokenResponse: IdentityTokenResponse): string {
// TODO: remove tokenResponse.keyConnectorUrl reference after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
return (
tokenResponse.keyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl
);
return userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl;
}
// TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request)

View File

@ -112,10 +112,11 @@ export class UserDecryptionOptions {
* @throws If the response is nullish, this method will throw an error. User decryption options
* are required for client initialization.
*/
// TODO: Change response type to `UserDecryptionOptionsResponse` after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
static fromResponse(response: IdentityTokenResponse): UserDecryptionOptions {
static fromIdentityTokenResponse(response: IdentityTokenResponse): UserDecryptionOptions {
if (response == null) {
throw new Error("User Decryption Options are required for client initialization.");
throw new Error(
"User Decryption Options are required for client initialization. Response is nullish.",
);
}
const decryptionOptions = new UserDecryptionOptions();
@ -134,17 +135,9 @@ export class UserDecryptionOptions {
responseOptions.keyConnectorOption,
);
} else {
// If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so
// we must base our decryption options on the presence of the keyConnectorUrl.
// Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE
// server versions, a master password short-circuited the addition of the keyConnectorUrl to the response.
// TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const usingKeyConnector = response.keyConnectorUrl != null;
decryptionOptions.hasMasterPassword = !usingKeyConnector;
if (usingKeyConnector) {
decryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption();
decryptionOptions.keyConnectorOption.keyConnectorUrl = response.keyConnectorUrl;
}
throw new Error(
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
);
}
return decryptionOptions;
}

View File

@ -10,6 +10,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { PreloginResponse } from "@bitwarden/common/auth/models/response/prelogin.response";
import { UserDecryptionOptionsResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@ -496,6 +497,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);
apiService.postPrelogin.mockResolvedValue(
@ -563,6 +565,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);
@ -692,6 +695,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);

View File

@ -1,6 +1,7 @@
import { MockProxy, mock } from "jest-mock-extended";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
@ -19,6 +20,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let syncService: MockProxy<SyncService>;
let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
let encryptedMigrator: MockProxy<EncryptedMigrator>;
let logService: MockProxy<LogService>;
const userId = "USER_ID" as UserId;
@ -30,6 +32,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService = mock<SsoLoginServiceAbstraction>();
syncService = mock<SyncService>();
userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
encryptedMigrator = mock<EncryptedMigrator>();
logService = mock<LogService>();
service = new DefaultLoginSuccessHandlerService(
@ -38,6 +41,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService,
syncService,
userAsymmetricKeysRegenerationService,
encryptedMigrator,
logService,
);
@ -50,7 +54,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
describe("run", () => {
it("should call required services on successful login", async () => {
await service.run(userId);
await service.run(userId, null);
expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true });
expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId);
@ -58,7 +62,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
});
it("should get SSO email", async () => {
await service.run(userId);
await service.run(userId, null);
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
});
@ -68,8 +72,8 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService.getSsoEmail.mockResolvedValue(null);
});
it("should log error and return early", async () => {
await service.run(userId);
it("should not check SSO requirements", async () => {
await service.run(userId, null);
expect(logService.debug).toHaveBeenCalledWith("SSO login email not found.");
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
@ -82,7 +86,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
});
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
await service.run(userId);
await service.run(userId, null);
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();

View File

@ -1,4 +1,5 @@
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
@ -15,12 +16,19 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer
private ssoLoginService: SsoLoginServiceAbstraction,
private syncService: SyncService,
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
private encryptedMigrator: EncryptedMigrator,
private logService: LogService,
) {}
async run(userId: UserId): Promise<void> {
async run(userId: UserId, masterPassword: string | null): Promise<void> {
await this.syncService.fullSync(true, { skipTokenRefresh: true });
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
await this.loginEmailService.clearLoginEmail();
try {
await this.encryptedMigrator.runMigrations(userId, masterPassword);
} catch {
// Don't block login success on migration failure
}
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();

View File

@ -41,6 +41,18 @@ export function canAccessBillingTab(org: Organization): boolean {
return org.isOwner;
}
/**
* Access Intelligence is only available to:
* - Enterprise organizations
* - Users in those organizations with report access
*
* @param org The organization to verify access
* @returns If true can access the Access Intelligence feature
*/
export function canAccessAccessIntelligence(org: Organization): boolean {
return org.canUseAccessIntelligence && org.canAccessReports;
}
export function canAccessOrgAdmin(org: Organization): boolean {
// Admin console can only be accessed by Owners for disabled organizations
if (!org.enabled && !org.isOwner) {

View File

@ -402,4 +402,8 @@ export class Organization {
this.permissions.accessEventLogs)
);
}
get canUseAccessIntelligence() {
return this.productTierType === ProductTierType.Enterprise;
}
}

View File

@ -18,6 +18,8 @@ export class AuthResult {
email: string;
requiresEncryptionKeyMigration: boolean;
requiresDeviceVerification: boolean;
// The master-password used in the authentication process
masterPassword: string | null;
get requiresTwoFactor() {
return this.twoFactorProviders != null;

View File

@ -26,7 +26,6 @@ export class IdentityTokenResponse extends BaseResponse {
forcePasswordReset: boolean;
masterPasswordPolicy: MasterPasswordPolicyResponse;
apiUseKeyConnector: boolean;
keyConnectorUrl: string;
userDecryptionOptions?: UserDecryptionOptionsResponse;
@ -70,7 +69,7 @@ export class IdentityTokenResponse extends BaseResponse {
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy"),
);

View File

@ -35,5 +35,26 @@ describe("HibpApiService", () => {
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(BreachAccountResponse);
});
it("should return empty array when no breaches found (REST semantics)", async () => {
// Server now returns 200 OK with empty array [] instead of 404
const mockResponse: any[] = [];
const username = "safe@example.com";
apiService.send.mockResolvedValue(mockResponse);
const result = await sut.getHibpBreach(username);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/hibp/breach?username=" + encodeURIComponent(username),
null,
true,
true,
);
expect(result).toEqual([]);
expect(result).toBeInstanceOf(Array);
expect(result).toHaveLength(0);
});
});
});

View File

@ -91,7 +91,7 @@ export abstract class CryptoFunctionService {
abstract rsaEncrypt(
data: Uint8Array,
publicKey: Uint8Array,
algorithm: "sha1" | "sha256",
algorithm: "sha1",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
@ -100,10 +100,10 @@ export abstract class CryptoFunctionService {
abstract rsaDecrypt(
data: Uint8Array,
privateKey: Uint8Array,
algorithm: "sha1" | "sha256",
algorithm: "sha1",
): Promise<Uint8Array>;
abstract rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array>;
abstract rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]>;
abstract rsaGenerateKeyPair(length: 2048): Promise<[Uint8Array, Uint8Array]>;
/**
* Generates a key of the given length suitable for use in AES encryption
*/

View File

@ -252,15 +252,9 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption.");
}
let algorithm: "sha1" | "sha256";
switch (data.encryptionType) {
case EncryptionType.Rsa2048_OaepSha1_B64:
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
algorithm = "sha1";
break;
case EncryptionType.Rsa2048_OaepSha256_B64:
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64:
algorithm = "sha256";
break;
default:
throw new Error("Invalid encryption type.");
@ -270,6 +264,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("[Encrypt service] rsaDecrypt: No private key provided for decryption.");
}
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm);
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, "sha1");
}
}

View File

@ -299,7 +299,6 @@ describe("WebCrypto Function Service", () => {
});
describe("rsaGenerateKeyPair", () => {
testRsaGenerateKeyPair(1024);
testRsaGenerateKeyPair(2048);
// Generating 4096 bit keys can be slow. Commenting it out to save CI.
@ -495,7 +494,7 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) {
});
}
function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) {
function testRsaGenerateKeyPair(length: 2048) {
it(
"should successfully generate a " + length + " bit key pair",
async () => {

View File

@ -263,33 +263,19 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
async rsaEncrypt(
data: Uint8Array,
publicKey: Uint8Array,
algorithm: "sha1" | "sha256",
_algorithm: "sha1",
): Promise<Uint8Array> {
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
// We cannot use the proper types here.
const rsaParams = {
name: "RSA-OAEP",
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
};
const impKey = await this.subtle.importKey("spki", publicKey, rsaParams, false, ["encrypt"]);
const buffer = await this.subtle.encrypt(rsaParams, impKey, data);
return new Uint8Array(buffer);
await SdkLoadService.Ready;
return PureCrypto.rsa_encrypt_data(data, publicKey);
}
async rsaDecrypt(
data: Uint8Array,
privateKey: Uint8Array,
algorithm: "sha1" | "sha256",
_algorithm: "sha1",
): Promise<Uint8Array> {
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
// We cannot use the proper types here.
const rsaParams = {
name: "RSA-OAEP",
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
};
const impKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, false, ["decrypt"]);
const buffer = await this.subtle.decrypt(rsaParams, impKey, data);
return new Uint8Array(buffer);
await SdkLoadService.Ready;
return PureCrypto.rsa_decrypt_data(data, privateKey);
}
async rsaExtractPublicKey(privateKey: Uint8Array): Promise<UnsignedPublicKey> {
@ -297,6 +283,13 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
return PureCrypto.rsa_extract_public_key(privateKey) as UnsignedPublicKey;
}
async rsaGenerateKeyPair(_length: 2048): Promise<[UnsignedPublicKey, Uint8Array]> {
await SdkLoadService.Ready;
const privateKey = PureCrypto.rsa_generate_keypair();
const publicKey = await this.rsaExtractPublicKey(privateKey);
return [publicKey, privateKey];
}
async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise<CsprngArray> {
if (bitLength === 512) {
// 512 bit keys are not supported in WebCrypto, so we concat two 256 bit keys
@ -314,20 +307,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
return new Uint8Array(rawKey) as CsprngArray;
}
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> {
const rsaParams = {
name: "RSA-OAEP",
modulusLength: length,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
// Have to specify some algorithm
hash: { name: this.toWebCryptoAlgorithm("sha1") },
};
const keyPair = await this.subtle.generateKey(rsaParams, true, ["encrypt", "decrypt"]);
const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey);
const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey);
return [new Uint8Array(publicKey), new Uint8Array(privateKey)];
}
randomBytes(length: number): Promise<CsprngArray> {
const arr = new Uint8Array(length);
this.crypto.getRandomValues(arr);

View File

@ -0,0 +1,194 @@
import { mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { SyncService } from "../../platform/sync";
import { UserId } from "../../types/guid";
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { DefaultEncryptedMigrator } from "./default-encrypted-migrator";
import { EncryptedMigration } from "./migrations/encrypted-migration";
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
jest.mock("./migrations/minimum-kdf-migration");
describe("EncryptedMigrator", () => {
const mockKdfConfigService = mock<KdfConfigService>();
const mockChangeKdfService = mock<ChangeKdfService>();
const mockLogService = mock<LogService>();
const configService = mock<ConfigService>();
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
const syncService = mock<SyncService>();
let sut: DefaultEncryptedMigrator;
const mockMigration = mock<MinimumKdfMigration>();
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockMasterPassword = "masterPassword123";
beforeEach(() => {
jest.clearAllMocks();
// Mock the MinimumKdfMigration constructor to return our mock
(MinimumKdfMigration as jest.MockedClass<typeof MinimumKdfMigration>).mockImplementation(
() => mockMigration,
);
sut = new DefaultEncryptedMigrator(
mockKdfConfigService,
mockChangeKdfService,
mockLogService,
configService,
masterPasswordService,
syncService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("runMigrations", () => {
it("should throw error when userId is null", async () => {
await expect(sut.runMigrations(null as any, null)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.runMigrations(undefined as any, null)).rejects.toThrow("userId");
});
it("should not run migration when needsMigration returns 'noMigrationNeeded'", async () => {
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
await sut.runMigrations(mockUserId, null);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
});
it("should run migration when needsMigration returns 'needsMigration'", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigration");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
});
it("should run migration when needsMigration returns 'needsMigrationWithMasterPassword'", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
});
it("should throw error when migration needs master password but null is provided", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
await sut.runMigrations(mockUserId, null);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
});
it("should run multiple migrations", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigration");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
expect(mockSecondMigration.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
});
describe("needsMigrations", () => {
it("should return 'noMigrationNeeded' when no migrations are needed", async () => {
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigration' when at least one migration needs to run", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigration");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigration");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigrationWithMasterPassword' when at least one migration needs master password", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should prioritize 'needsMigrationWithMasterPassword' over 'needsMigration'", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigration' when some migrations need running but none need master password", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigration");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigration");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should throw error when userId is null", async () => {
await expect(sut.needsMigrations(null as any)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.needsMigrations(undefined as any)).rejects.toThrow("userId");
});
});
});

View File

@ -0,0 +1,113 @@
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { assertNonNullish } from "../../auth/utils";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { SyncService } from "../../platform/sync";
import { UserId } from "../../types/guid";
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { EncryptedMigrator } from "./encrypted-migrator.abstraction";
import { EncryptedMigration, MigrationRequirement } from "./migrations/encrypted-migration";
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
export class DefaultEncryptedMigrator implements EncryptedMigrator {
private migrations: { name: string; migration: EncryptedMigration }[] = [];
private isRunningMigration = false;
constructor(
readonly kdfConfigService: KdfConfigService,
readonly changeKdfService: ChangeKdfService,
private readonly logService: LogService,
readonly configService: ConfigService,
readonly masterPasswordService: MasterPasswordServiceAbstraction,
readonly syncService: SyncService,
) {
// Register migrations here
this.migrations.push({
name: "Minimum PBKDF2 Iteration Count Migration",
migration: new MinimumKdfMigration(
kdfConfigService,
changeKdfService,
logService,
configService,
masterPasswordService,
),
});
}
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
assertNonNullish(userId, "userId");
// Ensure that the requirements for running all migrations are met
const needsMigration = await this.needsMigrations(userId);
if (needsMigration === "noMigrationNeeded") {
return;
} else if (needsMigration === "needsMigrationWithMasterPassword" && masterPassword == null) {
// If a migration needs a password, but none is provided, the migrations are skipped. If a manual caller
// during a login / unlock flow calls without a master password in a login / unlock strategy that has no
// password, such as biometric unlock, the migrations are skipped.
//
// The fallback to this, the encrypted migrations scheduler, will first check if a migration needs a password
// and then prompt the user. If the user enters their password, runMigrations is called again with the password.
return;
}
try {
// No concurrent migrations allowed, so acquire a service-wide lock
if (this.isRunningMigration) {
return;
}
this.isRunningMigration = true;
// Run all migrations sequentially in the order they were registered
this.logService.mark("[Encrypted Migrator] Start");
this.logService.info(`[Encrypted Migrator] Starting migrations for user: ${userId}`);
let ranMigration = false;
for (const { name, migration } of this.migrations) {
if ((await migration.needsMigration(userId)) !== "noMigrationNeeded") {
this.logService.info(`[Encrypted Migrator] Running migration: ${name}`);
const start = performance.now();
await migration.runMigrations(userId, masterPassword);
this.logService.measure(start, "[Encrypted Migrator]", name, "ExecutionTime");
ranMigration = true;
}
}
this.logService.mark("[Encrypted Migrator] Finish");
this.logService.info(`[Encrypted Migrator] Completed migrations for user: ${userId}`);
if (ranMigration) {
await this.syncService.fullSync(true);
}
} catch (error) {
this.logService.error(
`[Encrypted Migrator] Error running migrations for user: ${userId}`,
error,
);
throw error; // Re-throw the error to be handled by the caller
} finally {
this.isRunningMigration = false;
}
}
async needsMigrations(userId: UserId): Promise<MigrationRequirement> {
assertNonNullish(userId, "userId");
const migrationRequirements = await Promise.all(
this.migrations.map(async ({ migration }) => migration.needsMigration(userId)),
);
if (migrationRequirements.includes("needsMigrationWithMasterPassword")) {
return "needsMigrationWithMasterPassword";
} else if (migrationRequirements.includes("needsMigration")) {
return "needsMigration";
} else {
return "noMigrationNeeded";
}
}
isRunningMigrations(): boolean {
return this.isRunningMigration;
}
}

View File

@ -0,0 +1,32 @@
import { UserId } from "../../types/guid";
import { MigrationRequirement } from "./migrations/encrypted-migration";
export abstract class EncryptedMigrator {
/**
* Runs migrations on a decrypted user, with the cryptographic state initialized.
* This only runs the migrations that are needed for the user.
* This needs to be run after the decrypted user key has been set to state.
*
* If the master password is required but not provided, the migrations will not run, and the function will return early.
* If migrations are already running, the migrations will not run again, and the function will return early.
*
* @param userId The ID of the user to run migrations for.
* @param masterPassword The user's current master password.
* @throws If the user does not exist
* @throws If the user is locked or logged out
* @throws If a migration fails
*/
abstract runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
/**
* Checks if the user needs to run any migrations.
* This is used to determine if the user should be prompted to run migrations.
* @param userId The ID of the user to check migrations for.
*/
abstract needsMigrations(userId: UserId): Promise<MigrationRequirement>;
/**
* Indicates whether migrations are currently running.
*/
abstract isRunningMigrations(): boolean;
}

View File

@ -0,0 +1,36 @@
import { UserId } from "../../../types/guid";
/**
* @internal
* IMPORTANT: Please read this when implementing new migrations.
*
* An encrypted migration defines an online migration that mutates the persistent state of the user on the server, or locally.
* It should only be run once per user (or for local migrations, once per device). Migrations get scheduled automatically,
* during actions such as login and unlock, or during sync.
*
* Migrations can require the master-password, which is provided by the user if required.
* Migrations are run as soon as possible non-lazily, and MAY block unlock / login, if they have to run.
*
* Most importantly, implementing a migration should be done such that concurrent migrations may fail, but must never
* leave the user in a broken state. Locally, these are scheduled with an application-global lock. However, no such guarantees
* are made for the server, and other devices may run the migration concurrently.
*
* When adding a migration, it *MUST* be feature-flagged for the initial roll-out.
*/
export interface EncryptedMigration {
/**
* Runs the migration.
* @throws If the migration fails, such as when no network is available.
* @throws If the requirements for migration are not met (e.g. the user is locked)
*/
runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
/**
* Returns whether the migration needs to be run for the user, and if it does, whether the master password is required.
*/
needsMigration(userId: UserId): Promise<MigrationRequirement>;
}
export type MigrationRequirement =
| "needsMigration"
| "needsMigrationWithMasterPassword"
| "noMigrationNeeded";

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