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:
commit
d63566cb2e
22
.github/workflows/build-desktop.yml
vendored
22
.github/workflows/build-desktop.yml
vendored
@ -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'
|
||||
|
||||
@ -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)"
|
||||
},
|
||||
|
||||
193
apps/browser/src/auth/popup/guards/platform-popout.guard.spec.ts
Normal file
193
apps/browser/src/auth/popup/guards/platform-popout.guard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/browser/src/auth/popup/guards/platform-popout.guard.ts
Normal file
46
apps/browser/src/auth/popup/guards/platform-popout.guard.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -39,6 +39,7 @@ export class AutoFillConstants {
|
||||
"otpcode",
|
||||
"onetimepassword",
|
||||
"security_code",
|
||||
"second-factor",
|
||||
"twofactor",
|
||||
"twofa",
|
||||
"twofactorcode",
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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) } },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)?;
|
||||
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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": "",
|
||||
|
||||
@ -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 });
|
||||
@ -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();
|
||||
|
||||
@ -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/>
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
@ -225,7 +225,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
|
||||
switchMap((id) =>
|
||||
combineLatest([
|
||||
this.cipherArchiveService.userCanArchive$(id),
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$(),
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$,
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -72,6 +72,7 @@ describe("VaultCipherRowComponent", () => {
|
||||
|
||||
fixture = TestBed.createComponent(VaultCipherRowComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput("archiveEnabled", false);
|
||||
overlayContainer = TestBed.inject(OverlayContainer);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -179,6 +179,7 @@
|
||||
(onEvent)="event($event)"
|
||||
[userCanArchive]="userCanArchive"
|
||||
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy"
|
||||
[archiveEnabled]="archiveFeatureEnabled$ | async"
|
||||
></tr>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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."
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -402,4 +402,8 @@ export class Organization {
|
||||
this.permissions.accessEventLogs)
|
||||
);
|
||||
}
|
||||
|
||||
get canUseAccessIntelligence() {
|
||||
return this.productTierType === ProductTierType.Enterprise;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"),
|
||||
);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user