mirror of
https://github.com/bitwarden/browser
synced 2026-02-08 12:40:26 +00:00
Merge branch 'main' into km/cose-upgrade
This commit is contained in:
2
.github/renovate.json5
vendored
2
.github/renovate.json5
vendored
@@ -312,6 +312,7 @@
|
||||
"@angular/platform-browser",
|
||||
"@angular/platform",
|
||||
"@angular/router",
|
||||
"axe-playwright",
|
||||
"@compodoc/compodoc",
|
||||
"@ng-select/ng-select",
|
||||
"@storybook/addon-a11y",
|
||||
@@ -320,6 +321,7 @@
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-interactions",
|
||||
"@storybook/addon-links",
|
||||
"@storybook/test-runner",
|
||||
"@storybook/addon-themes",
|
||||
"@storybook/angular",
|
||||
"@storybook/manager-api",
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
run: npm test -- --coverage --maxWorkers=3
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2
|
||||
|
||||
- name: Upload results to codecov.io
|
||||
uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2
|
||||
uses: codecov/test-results-action@f2dba722c67b86c6caa034178c6e4d35335f6706 # v1.1.0
|
||||
|
||||
rust:
|
||||
name: Run Rust tests on ${{ matrix.os }}
|
||||
|
||||
@@ -26,6 +26,9 @@ const preview: Preview = {
|
||||
wrapperDecorator,
|
||||
],
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: "#storybook-root",
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
|
||||
47
.storybook/test-runner.ts
Normal file
47
.storybook/test-runner.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { type TestRunnerConfig } from "@storybook/test-runner";
|
||||
import { injectAxe, checkA11y } from "axe-playwright";
|
||||
|
||||
const testRunnerConfig: TestRunnerConfig = {
|
||||
setup() {},
|
||||
|
||||
async preVisit(page, context) {
|
||||
return await injectAxe(page);
|
||||
},
|
||||
|
||||
async postVisit(page, context) {
|
||||
await page.waitForSelector("#storybook-root");
|
||||
// https://github.com/abhinaba-ghosh/axe-playwright#parameters-on-checka11y-axerun
|
||||
await checkA11y(
|
||||
// Playwright page instance.
|
||||
page,
|
||||
|
||||
// context
|
||||
"#storybook-root",
|
||||
|
||||
// axeOptions, see https://www.deque.com/axe/core-documentation/api-documentation/#parameters-axerun
|
||||
{
|
||||
detailedReport: true,
|
||||
detailedReportOptions: {
|
||||
// Includes the full html for invalid nodes
|
||||
html: true,
|
||||
},
|
||||
verbose: false,
|
||||
},
|
||||
|
||||
// skipFailures
|
||||
false,
|
||||
|
||||
// reporter "v2" is terminal reporter, "html" writes results to file
|
||||
"v2",
|
||||
|
||||
// axeHtmlReporterOptions
|
||||
// NOTE: set reporter param (above) to "html" to activate these options
|
||||
{
|
||||
outputDir: "reports/a11y",
|
||||
reportFileName: `${context.id}.html`,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default testRunnerConfig;
|
||||
@@ -1088,22 +1088,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"loginSaveConfirmation": {
|
||||
"message": "$ITEMNAME$ saved to Bitwarden.",
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"content": "$1"
|
||||
}
|
||||
},
|
||||
"notificationLoginSaveConfirmation": {
|
||||
"message": "saved to Bitwarden.",
|
||||
|
||||
"description": "Shown to user after item is saved."
|
||||
},
|
||||
"loginUpdatedConfirmation": {
|
||||
"message": "$ITEMNAME$ updated in Bitwarden.",
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"content": "$1"
|
||||
}
|
||||
},
|
||||
"notificationLoginUpdatedConfirmation": {
|
||||
"message": "updated in Bitwarden.",
|
||||
"description": "Shown to user after item is updated."
|
||||
},
|
||||
"saveAsNewLoginAction": {
|
||||
@@ -1589,6 +1580,24 @@
|
||||
"autofillSuggestionsSectionTitle": {
|
||||
"message": "Autofill suggestions"
|
||||
},
|
||||
"autofillSpotlightTitle": {
|
||||
"message": "Easily find autofill suggestions"
|
||||
},
|
||||
"autofillSpotlightDesc": {
|
||||
"message": "Turn off your browser's autofill settings, so they don't conflict with Bitwarden."
|
||||
},
|
||||
"turnOffBrowserAutofill": {
|
||||
"message": "Turn off $BROWSER$ autofill",
|
||||
"placeholders": {
|
||||
"browser": {
|
||||
"content": "$1",
|
||||
"example": "Chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"turnOffAutofill": {
|
||||
"message": "Turn off autofill"
|
||||
},
|
||||
"showInlineMenuLabel": {
|
||||
"message": "Show autofill suggestions on form fields"
|
||||
},
|
||||
@@ -4533,6 +4542,12 @@
|
||||
"downloadFromBitwardenNow": {
|
||||
"message": "Download from bitwarden.com now"
|
||||
},
|
||||
"getItOnGooglePlay": {
|
||||
"message": "Get it on Google Play"
|
||||
},
|
||||
"downloadOnTheAppStore": {
|
||||
"message": "Download on the App Store"
|
||||
},
|
||||
"permanentlyDeleteAttachmentConfirmation": {
|
||||
"message": "Are you sure you want to permanently delete this attachment?"
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
@@ -828,6 +829,7 @@ describe("NotificationBackground", () => {
|
||||
id: "testId",
|
||||
name: "testItemName",
|
||||
login: { username: "testUser" },
|
||||
reprompt: CipherRepromptType.None,
|
||||
});
|
||||
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
|
||||
|
||||
@@ -842,6 +844,7 @@ describe("NotificationBackground", () => {
|
||||
message.edit,
|
||||
sender.tab,
|
||||
"testId",
|
||||
false,
|
||||
);
|
||||
expect(updateWithServerSpy).toHaveBeenCalled();
|
||||
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
|
||||
@@ -855,6 +858,55 @@ describe("NotificationBackground", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prompts the user for master password entry if the notification message type is for ChangePassword and the cipher reprompt is enabled", async () => {
|
||||
const tab = createChromeTabMock({ id: 1, url: "https://example.com" });
|
||||
const sender = mock<chrome.runtime.MessageSender>({ tab });
|
||||
const message: NotificationBackgroundExtensionMessage = {
|
||||
command: "bgSaveCipher",
|
||||
edit: false,
|
||||
folder: "folder-id",
|
||||
};
|
||||
const queueMessage = mock<AddChangePasswordQueueMessage>({
|
||||
type: NotificationQueueMessageType.ChangePassword,
|
||||
tab,
|
||||
domain: "example.com",
|
||||
newPassword: "newPassword",
|
||||
});
|
||||
notificationBackground["notificationQueue"] = [queueMessage];
|
||||
const cipherView = mock<CipherView>({
|
||||
id: "testId",
|
||||
name: "testItemName",
|
||||
login: { username: "testUser" },
|
||||
reprompt: CipherRepromptType.Password,
|
||||
});
|
||||
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(editItemSpy).not.toHaveBeenCalled();
|
||||
expect(autofillService.isPasswordRepromptRequired).toHaveBeenCalled();
|
||||
expect(createWithServerSpy).not.toHaveBeenCalled();
|
||||
expect(updatePasswordSpy).toHaveBeenCalledWith(
|
||||
cipherView,
|
||||
queueMessage.newPassword,
|
||||
message.edit,
|
||||
sender.tab,
|
||||
"testId",
|
||||
false,
|
||||
);
|
||||
expect(updateWithServerSpy).not.toHaveBeenCalled();
|
||||
expect(tabSendMessageDataSpy).not.toHaveBeenCalledWith(
|
||||
sender.tab,
|
||||
"saveCipherAttemptCompleted",
|
||||
{
|
||||
itemName: "testItemName",
|
||||
cipherId: cipherView.id,
|
||||
task: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("completes password update notification with a security task notice if any are present for the cipher, and dismisses tasks for the updated cipher", async () => {
|
||||
const mockCipherId = "testId";
|
||||
const mockOrgId = "testOrgId";
|
||||
@@ -905,6 +957,7 @@ describe("NotificationBackground", () => {
|
||||
id: mockCipherId,
|
||||
organizationId: mockOrgId,
|
||||
name: "Test Item",
|
||||
reprompt: CipherRepromptType.None,
|
||||
});
|
||||
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
|
||||
|
||||
@@ -919,6 +972,7 @@ describe("NotificationBackground", () => {
|
||||
message.edit,
|
||||
sender.tab,
|
||||
mockCipherId,
|
||||
false,
|
||||
);
|
||||
expect(updateWithServerSpy).toHaveBeenCalled();
|
||||
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
|
||||
@@ -1000,6 +1054,7 @@ describe("NotificationBackground", () => {
|
||||
message.edit,
|
||||
sender.tab,
|
||||
"testId",
|
||||
false,
|
||||
);
|
||||
expect(editItemSpy).toHaveBeenCalled();
|
||||
expect(updateWithServerSpy).not.toHaveBeenCalled();
|
||||
@@ -1170,7 +1225,7 @@ describe("NotificationBackground", () => {
|
||||
newPassword: "newPassword",
|
||||
});
|
||||
notificationBackground["notificationQueue"] = [queueMessage];
|
||||
const cipherView = mock<CipherView>();
|
||||
const cipherView = mock<CipherView>({ reprompt: CipherRepromptType.None });
|
||||
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
|
||||
const errorMessage = "fetch error";
|
||||
updateWithServerSpy.mockImplementation(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ExtensionCommand,
|
||||
ExtensionCommandType,
|
||||
NOTIFICATION_BAR_LIFESPAN_MS,
|
||||
UPDATE_PASSWORD,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
|
||||
@@ -104,6 +105,8 @@ export default class NotificationBackground {
|
||||
this.removeTabFromNotificationQueue(sender.tab),
|
||||
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
|
||||
bgSaveCipher: ({ message, sender }) => this.handleSaveCipherMessage(message, sender),
|
||||
bgHandleReprompt: ({ message, sender }: any) =>
|
||||
this.handleCipherUpdateRepromptResponse(message),
|
||||
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
|
||||
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
|
||||
collectPageDetailsResponse: ({ message }) =>
|
||||
@@ -631,6 +634,17 @@ export default class NotificationBackground {
|
||||
await this.saveOrUpdateCredentials(sender.tab, message.edit, message.folder);
|
||||
}
|
||||
|
||||
async handleCipherUpdateRepromptResponse(message: NotificationBackgroundExtensionMessage) {
|
||||
if (message.success) {
|
||||
await this.saveOrUpdateCredentials(message.tab, false, undefined, true);
|
||||
} else {
|
||||
await BrowserApi.tabSendMessageData(message.tab, "saveCipherAttemptCompleted", {
|
||||
error: "Password reprompt failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves or updates credentials based on the message within the
|
||||
* notification queue that is associated with the specified tab.
|
||||
@@ -639,7 +653,12 @@ export default class NotificationBackground {
|
||||
* @param edit - Identifies if the credentials should be edited or simply added
|
||||
* @param folderId - The folder to add the cipher to
|
||||
*/
|
||||
private async saveOrUpdateCredentials(tab: chrome.tabs.Tab, edit: boolean, folderId?: string) {
|
||||
private async saveOrUpdateCredentials(
|
||||
tab: chrome.tabs.Tab,
|
||||
edit: boolean,
|
||||
folderId?: string,
|
||||
skipReprompt: boolean = false,
|
||||
) {
|
||||
for (let i = this.notificationQueue.length - 1; i >= 0; i--) {
|
||||
const queueMessage = this.notificationQueue[i];
|
||||
if (
|
||||
@@ -654,18 +673,26 @@ export default class NotificationBackground {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.notificationQueue.splice(i, 1);
|
||||
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
|
||||
if (queueMessage.type === NotificationQueueMessageType.ChangePassword) {
|
||||
const cipherView = await this.getDecryptedCipherById(queueMessage.cipherId, activeUserId);
|
||||
await this.updatePassword(cipherView, queueMessage.newPassword, edit, tab, activeUserId);
|
||||
|
||||
await this.updatePassword(
|
||||
cipherView,
|
||||
queueMessage.newPassword,
|
||||
edit,
|
||||
tab,
|
||||
activeUserId,
|
||||
skipReprompt,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.notificationQueue.splice(i, 1);
|
||||
|
||||
// If the vault was locked, check if a cipher needs updating instead of creating a new one
|
||||
if (queueMessage.wasVaultLocked) {
|
||||
const allCiphers = await this.cipherService.getAllDecryptedForUrl(
|
||||
@@ -725,6 +752,7 @@ export default class NotificationBackground {
|
||||
edit: boolean,
|
||||
tab: chrome.tabs.Tab,
|
||||
userId: UserId,
|
||||
skipReprompt: boolean = false,
|
||||
) {
|
||||
cipherView.login.password = newPassword;
|
||||
|
||||
@@ -758,6 +786,12 @@ export default class NotificationBackground {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (cipherView.reprompt && !skipReprompt) {
|
||||
await this.autofillService.isPasswordRepromptRequired(cipherView, tab, UPDATE_PASSWORD);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cipherService.updateWithServer(cipher);
|
||||
|
||||
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
|
||||
|
||||
@@ -144,17 +144,17 @@ export const border = {
|
||||
export const typography = {
|
||||
body1: `
|
||||
line-height: 24px;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
`,
|
||||
body2: `
|
||||
line-height: 20px;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
`,
|
||||
helperMedium: `
|
||||
line-height: 16px;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -107,9 +107,9 @@ export const mockI18n = {
|
||||
collection: "Collection",
|
||||
folder: "Folder",
|
||||
loginSaveSuccess: "Login saved",
|
||||
loginSaveConfirmation: "$ITEMNAME$ saved to Bitwarden.",
|
||||
notificationLoginSaveConfirmation: "saved to Bitwarden.",
|
||||
loginUpdateSuccess: "Login updated",
|
||||
loginUpdatedConfirmation: "$ITEMNAME$ updated in Bitwarden.",
|
||||
notificationLoginUpdatedConfirmation: "updated in Bitwarden.",
|
||||
loginUpdateTaskSuccess:
|
||||
"Great job! You took the steps to make you and $ORGANIZATION$ more secure.",
|
||||
loginUpdateTaskSuccessAdditional:
|
||||
|
||||
@@ -113,9 +113,14 @@ function getConfirmationMessage(i18n: I18n, type?: NotificationType, error?: str
|
||||
if (error) {
|
||||
return i18n.saveFailureDetails;
|
||||
}
|
||||
|
||||
/* @TODO This partial string return and later concatenation with the cipher name is needed
|
||||
* to handle cipher name overflow cases, but is risky for i18n concerns. Fix concatenation
|
||||
* with cipher name overflow when a tag replacement solution is available.
|
||||
*/
|
||||
return type === NotificationTypes.Add
|
||||
? i18n.loginSaveConfirmation
|
||||
: i18n.loginUpdatedConfirmation;
|
||||
? i18n.notificationLoginSaveConfirmation
|
||||
: i18n.notificationLoginUpdatedConfirmation;
|
||||
}
|
||||
|
||||
function getHeaderMessage(i18n: I18n, type?: NotificationType, error?: string) {
|
||||
|
||||
@@ -28,28 +28,31 @@ export function NotificationConfirmationMessage({
|
||||
<div class=${containerStyles}>
|
||||
${message || buttonText
|
||||
? html`
|
||||
<span class=${itemNameStyles(theme)} title=${itemName}> ${itemName} </span>
|
||||
<span
|
||||
title=${message || buttonText}
|
||||
class=${notificationConfirmationMessageStyles(theme)}
|
||||
>
|
||||
${message || nothing}
|
||||
${buttonText
|
||||
? html`
|
||||
<a
|
||||
title=${buttonText}
|
||||
class=${notificationConfirmationButtonTextStyles(theme)}
|
||||
@click=${handleClick}
|
||||
@keydown=${(e: KeyboardEvent) => handleButtonKeyDown(e, () => handleClick(e))}
|
||||
aria-label=${buttonAria}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
${buttonText}
|
||||
</a>
|
||||
`
|
||||
: nothing}
|
||||
</span>
|
||||
<div class=${singleLineWrapperStyles}>
|
||||
<span class=${itemNameStyles(theme)} title=${itemName}> ${itemName} </span>
|
||||
<span
|
||||
title=${message || buttonText}
|
||||
class=${notificationConfirmationMessageStyles(theme)}
|
||||
>
|
||||
${message || nothing}
|
||||
${buttonText
|
||||
? html`
|
||||
<a
|
||||
title=${buttonText}
|
||||
class=${notificationConfirmationButtonTextStyles(theme)}
|
||||
@click=${handleClick}
|
||||
@keydown=${(e: KeyboardEvent) =>
|
||||
handleButtonKeyDown(e, () => handleClick(e))}
|
||||
aria-label=${buttonAria}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
${buttonText}
|
||||
</a>
|
||||
`
|
||||
: nothing}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${messageDetails
|
||||
@@ -61,18 +64,23 @@ export function NotificationConfirmationMessage({
|
||||
|
||||
const containerStyles = css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: ${spacing[1]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const singleLineWrapperStyles = css`
|
||||
display: inline;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const baseTextStyles = css`
|
||||
overflow-x: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 24px;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
@@ -81,6 +89,9 @@ const notificationConfirmationMessageStyles = (theme: Theme) => css`
|
||||
|
||||
color: ${themes[theme].text.main};
|
||||
font-weight: 400;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
display: inline;
|
||||
`;
|
||||
|
||||
const itemNameStyles = (theme: Theme) => css`
|
||||
@@ -90,6 +101,10 @@ const itemNameStyles = (theme: Theme) => css`
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
`;
|
||||
|
||||
const notificationConfirmationButtonTextStyles = (theme: Theme) => css`
|
||||
|
||||
@@ -94,7 +94,7 @@ const notificationContainerStyles = (theme: Theme) => css`
|
||||
}
|
||||
|
||||
[class*="${notificationBodyClassPrefix}-"] {
|
||||
margin: ${spacing["3"]} 0 ${spacing["1.5"]} ${spacing["3"]};
|
||||
margin: ${spacing["3"]} 0 0 ${spacing["3"]};
|
||||
padding-right: ${spacing["3"]};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
NotificationTypes,
|
||||
} from "../../../notification/abstractions/notification-bar";
|
||||
import { OrgView, FolderView, I18n, CollectionView } from "../common-types";
|
||||
import { spacing, themes } from "../constants/styles";
|
||||
import { spacing } from "../constants/styles";
|
||||
|
||||
import { NotificationButtonRow } from "./button-row";
|
||||
|
||||
@@ -37,7 +37,7 @@ export function NotificationFooter({
|
||||
const primaryButtonText = i18n.saveAction;
|
||||
|
||||
return html`
|
||||
<div class=${notificationFooterStyles({ theme })}>
|
||||
<div class=${notificationFooterStyles({ isChangeNotification })}>
|
||||
${!isChangeNotification
|
||||
? NotificationButtonRow({
|
||||
collections,
|
||||
@@ -56,13 +56,16 @@ export function NotificationFooter({
|
||||
`;
|
||||
}
|
||||
|
||||
const notificationFooterStyles = ({ theme }: { theme: Theme }) => css`
|
||||
const notificationFooterStyles = ({
|
||||
isChangeNotification,
|
||||
}: {
|
||||
isChangeNotification: boolean;
|
||||
}) => css`
|
||||
display: flex;
|
||||
background-color: ${themes[theme].background.alt};
|
||||
padding: 0 ${spacing[3]} ${spacing[3]} ${spacing[3]};
|
||||
padding: ${spacing[2]} ${spacing[4]} ${isChangeNotification ? spacing[1] : spacing[4]}
|
||||
${spacing[4]};
|
||||
|
||||
:last-child {
|
||||
border-radius: 0 0 ${spacing["4"]} ${spacing["4"]};
|
||||
padding-bottom: ${spacing[4]};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -19,7 +19,7 @@ const notificationHeaderMessageStyles = (theme: Theme) => css`
|
||||
line-height: 28px;
|
||||
white-space: nowrap;
|
||||
color: ${themes[theme].text.main};
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
@@ -62,7 +62,7 @@ const buttonRowStyles = css`
|
||||
|
||||
> button {
|
||||
max-width: min-content;
|
||||
flex: 1 1 50%;
|
||||
flex: 1 1 25%;
|
||||
}
|
||||
|
||||
> div {
|
||||
|
||||
@@ -59,9 +59,7 @@ function getI18n() {
|
||||
collection: chrome.i18n.getMessage("collection"),
|
||||
folder: chrome.i18n.getMessage("folder"),
|
||||
loginSaveSuccess: chrome.i18n.getMessage("loginSaveSuccess"),
|
||||
loginSaveConfirmation: chrome.i18n.getMessage("loginSaveConfirmation"),
|
||||
loginUpdateSuccess: chrome.i18n.getMessage("loginUpdateSuccess"),
|
||||
loginUpdateConfirmation: chrome.i18n.getMessage("loginUpdatedConfirmation"),
|
||||
loginUpdateTaskSuccess: chrome.i18n.getMessage("loginUpdateTaskSuccess"),
|
||||
loginUpdateTaskSuccessAdditional: chrome.i18n.getMessage("loginUpdateTaskSuccessAdditional"),
|
||||
nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"),
|
||||
@@ -74,6 +72,10 @@ function getI18n() {
|
||||
notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"),
|
||||
notificationEdit: chrome.i18n.getMessage("edit"),
|
||||
notificationEditTooltip: chrome.i18n.getMessage("notificationEditTooltip"),
|
||||
notificationLoginSaveConfirmation: chrome.i18n.getMessage("notificationLoginSaveConfirmation"),
|
||||
notificationLoginUpdatedConfirmation: chrome.i18n.getMessage(
|
||||
"notificationLoginUpdatedConfirmation",
|
||||
),
|
||||
notificationUnlock: chrome.i18n.getMessage("notificationUnlock"),
|
||||
notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"),
|
||||
notificationViewAria: chrome.i18n.getMessage("notificationViewAria"),
|
||||
|
||||
@@ -15,6 +15,7 @@ export type NotificationsExtensionMessage = {
|
||||
typeData?: NotificationTypeData;
|
||||
height?: number;
|
||||
error?: string;
|
||||
closedByUser?: boolean;
|
||||
fadeOutNotification?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -106,13 +106,15 @@ export class OverlayNotificationsContentService
|
||||
* @param message - The message containing the data for closing the notification bar.
|
||||
*/
|
||||
private handleCloseNotificationBarMessage(message: NotificationsExtensionMessage) {
|
||||
const closedByUser =
|
||||
typeof message.data?.closedByUser === "boolean" ? message.data.closedByUser : true;
|
||||
if (message.data?.fadeOutNotification) {
|
||||
setElementStyles(this.notificationBarIframeElement, { opacity: "0" }, true);
|
||||
globalThis.setTimeout(() => this.closeNotificationBar(true), 150);
|
||||
globalThis.setTimeout(() => this.closeNotificationBar(closedByUser), 150);
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeNotificationBar(true);
|
||||
this.closeNotificationBar(closedByUser);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
</popup-header>
|
||||
|
||||
<div class="tw-bg-background-alt">
|
||||
<div *ngIf="!defaultBrowserAutofillDisabled && (showSpotlightNudge$ | async)" class="tw-mb-6">
|
||||
<bit-spotlight
|
||||
[title]="'autofillSpotlightTitle' | i18n"
|
||||
[subtitle]="'autofillSpotlightDesc' | i18n"
|
||||
[buttonText]="spotlightButtonText"
|
||||
(onDismiss)="dismissSpotlight()"
|
||||
(onButtonClick)="openURI($event, disablePasswordManagerURI)"
|
||||
[buttonIcon]="spotlightButtonIcon"
|
||||
></bit-spotlight>
|
||||
</div>
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "autofillSuggestionsSectionTitle" | i18n }}</h2>
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
FormControl,
|
||||
} from "@angular/forms";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { Observable, filter, firstValueFrom, map, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import {
|
||||
AutofillOverlayVisibility,
|
||||
BrowserClientVendors,
|
||||
@@ -53,7 +55,9 @@ import {
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { SpotlightComponent, VaultNudgesService, VaultNudgeType } from "@bitwarden/vault";
|
||||
|
||||
import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
@@ -81,6 +85,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
ReactiveFormsModule,
|
||||
SpotlightComponent,
|
||||
],
|
||||
})
|
||||
export class AutofillComponent implements OnInit {
|
||||
@@ -100,6 +105,14 @@ export class AutofillComponent implements OnInit {
|
||||
protected browserClientIsUnknown: boolean;
|
||||
protected autofillOnPageLoadFromPolicy$ =
|
||||
this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$;
|
||||
protected showSpotlightNudge$: Observable<boolean> = this.accountService.activeAccount$.pipe(
|
||||
filter((account): account is Account => account !== null),
|
||||
switchMap((account) =>
|
||||
this.vaultNudgesService
|
||||
.showNudge$(VaultNudgeType.AutofillNudge, account.id)
|
||||
.pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed)),
|
||||
),
|
||||
);
|
||||
|
||||
protected autofillOnPageLoadForm = new FormGroup({
|
||||
autofillOnPageLoad: new FormControl(),
|
||||
@@ -142,6 +155,9 @@ export class AutofillComponent implements OnInit {
|
||||
private configService: ConfigService,
|
||||
private formBuilder: FormBuilder,
|
||||
private destroyRef: DestroyRef,
|
||||
private vaultNudgesService: VaultNudgesService,
|
||||
private accountService: AccountService,
|
||||
private autofillBrowserSettingsService: AutofillBrowserSettingsService,
|
||||
) {
|
||||
this.autofillOnPageLoadOptions = [
|
||||
{ name: this.i18nService.t("autoFillOnPageLoadYes"), value: true },
|
||||
@@ -165,7 +181,7 @@ export class AutofillComponent implements OnInit {
|
||||
{ name: i18nService.t("never"), value: UriMatchStrategy.Never },
|
||||
];
|
||||
|
||||
this.browserClientVendor = this.getBrowserClientVendor();
|
||||
this.browserClientVendor = BrowserApi.getBrowserClientVendor(window);
|
||||
this.disablePasswordManagerURI = DisablePasswordManagerUris[this.browserClientVendor];
|
||||
this.browserShortcutsURI = BrowserShortcutsUris[this.browserClientVendor];
|
||||
this.browserClientIsUnknown = this.browserClientVendor === BrowserClientVendors.Unknown;
|
||||
@@ -173,7 +189,11 @@ export class AutofillComponent implements OnInit {
|
||||
|
||||
async ngOnInit() {
|
||||
this.canOverrideBrowserAutofillSetting = !this.browserClientIsUnknown;
|
||||
this.defaultBrowserAutofillDisabled = await this.browserAutofillSettingCurrentlyOverridden();
|
||||
|
||||
this.defaultBrowserAutofillDisabled =
|
||||
await this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden(
|
||||
this.browserClientVendor,
|
||||
);
|
||||
|
||||
this.inlineMenuVisibility = await firstValueFrom(
|
||||
this.autofillSettingsService.inlineMenuVisibility$,
|
||||
@@ -308,6 +328,27 @@ export class AutofillComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
get spotlightButtonIcon() {
|
||||
if (this.browserClientVendor === BrowserClientVendors.Unknown) {
|
||||
return "bwi-external-link";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get spotlightButtonText() {
|
||||
if (this.browserClientVendor === BrowserClientVendors.Unknown) {
|
||||
return this.i18nService.t("turnOffAutofill");
|
||||
}
|
||||
return this.i18nService.t("turnOffBrowserAutofill", this.browserClientVendor);
|
||||
}
|
||||
|
||||
async dismissSpotlight() {
|
||||
await this.vaultNudgesService.dismissNudge(
|
||||
VaultNudgeType.AutofillNudge,
|
||||
await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)),
|
||||
);
|
||||
}
|
||||
|
||||
async updateInlineMenuVisibility() {
|
||||
if (!this.enableInlineMenu) {
|
||||
this.enableInlineMenuOnIconSelect = false;
|
||||
@@ -346,26 +387,6 @@ export class AutofillComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private getBrowserClientVendor(): BrowserClientVendor {
|
||||
if (this.platformUtilsService.isChrome()) {
|
||||
return BrowserClientVendors.Chrome;
|
||||
}
|
||||
|
||||
if (this.platformUtilsService.isOpera()) {
|
||||
return BrowserClientVendors.Opera;
|
||||
}
|
||||
|
||||
if (this.platformUtilsService.isEdge()) {
|
||||
return BrowserClientVendors.Edge;
|
||||
}
|
||||
|
||||
if (this.platformUtilsService.isVivaldi()) {
|
||||
return BrowserClientVendors.Vivaldi;
|
||||
}
|
||||
|
||||
return BrowserClientVendors.Unknown;
|
||||
}
|
||||
|
||||
protected async openURI(event: Event, uri: BrowserShortcutsUri | DisablePasswordManagerUri) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -422,7 +443,7 @@ export class AutofillComponent implements OnInit {
|
||||
if (
|
||||
this.inlineMenuVisibility === AutofillOverlayVisibility.Off ||
|
||||
!this.canOverrideBrowserAutofillSetting ||
|
||||
(await this.browserAutofillSettingCurrentlyOverridden())
|
||||
this.defaultBrowserAutofillDisabled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -460,6 +481,9 @@ export class AutofillComponent implements OnInit {
|
||||
}
|
||||
|
||||
await BrowserApi.updateDefaultBrowserAutofillSettings(!this.defaultBrowserAutofillDisabled);
|
||||
this.autofillBrowserSettingsService.setDefaultBrowserAutofillDisabled(
|
||||
this.defaultBrowserAutofillDisabled,
|
||||
);
|
||||
}
|
||||
|
||||
private handleOverrideDialogAccept = async () => {
|
||||
@@ -467,18 +491,6 @@ export class AutofillComponent implements OnInit {
|
||||
await this.updateDefaultBrowserAutofillDisabled();
|
||||
};
|
||||
|
||||
async browserAutofillSettingCurrentlyOverridden() {
|
||||
if (!this.canOverrideBrowserAutofillSetting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await this.privacyPermissionGranted())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await BrowserApi.browserAutofillSettingsOverridden();
|
||||
}
|
||||
|
||||
async privacyPermissionGranted(): Promise<boolean> {
|
||||
return await BrowserApi.permissionsGranted(["privacy"]);
|
||||
}
|
||||
|
||||
@@ -87,5 +87,9 @@ export abstract class AutofillService {
|
||||
cipherType?: CipherType,
|
||||
) => Promise<string | null>;
|
||||
setAutoFillOnPageLoadOrgPolicy: () => Promise<void>;
|
||||
isPasswordRepromptRequired: (cipher: CipherView, tab: chrome.tabs.Tab) => Promise<boolean>;
|
||||
isPasswordRepromptRequired: (
|
||||
cipher: CipherView,
|
||||
tab: chrome.tabs.Tab,
|
||||
action?: string,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import { BrowserClientVendors } from "@bitwarden/common/autofill/constants";
|
||||
import { BrowserClientVendor } from "@bitwarden/common/autofill/types";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
/**
|
||||
* Service class for various Autofill-related browser API operations.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class AutofillBrowserSettingsService {
|
||||
async isBrowserAutofillSettingOverridden(browserClient: BrowserClientVendor) {
|
||||
return (
|
||||
browserClient !== BrowserClientVendors.Unknown &&
|
||||
(await BrowserApi.browserAutofillSettingsOverridden())
|
||||
);
|
||||
}
|
||||
|
||||
private _defaultBrowserAutofillDisabled$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
defaultBrowserAutofillDisabled$: Observable<boolean> =
|
||||
this._defaultBrowserAutofillDisabled$.asObservable();
|
||||
|
||||
setDefaultBrowserAutofillDisabled(value: boolean) {
|
||||
this._defaultBrowserAutofillDisabled$.next(value);
|
||||
}
|
||||
}
|
||||
@@ -593,15 +593,20 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
*
|
||||
* @param cipher - The cipher to autofill
|
||||
* @param tab - The tab to autofill
|
||||
* @param action - override for default action once reprompt is completed successfully
|
||||
*/
|
||||
async isPasswordRepromptRequired(cipher: CipherView, tab: chrome.tabs.Tab): Promise<boolean> {
|
||||
async isPasswordRepromptRequired(
|
||||
cipher: CipherView,
|
||||
tab: chrome.tabs.Tab,
|
||||
action?: string,
|
||||
): Promise<boolean> {
|
||||
const userHasMasterPasswordAndKeyHash =
|
||||
await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
|
||||
if (cipher.reprompt === CipherRepromptType.Password && userHasMasterPasswordAndKeyHash) {
|
||||
if (!this.isDebouncingPasswordRepromptPopout()) {
|
||||
await this.openVaultItemPasswordRepromptPopout(tab, {
|
||||
cipherId: cipher.id,
|
||||
action: "autofill",
|
||||
action: action ?? "autofill",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
$dark-icon-themes: "theme_dark";
|
||||
|
||||
$font-family-sans-serif: "DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-source-code-pro: "Source Code Pro", monospace;
|
||||
$font-size-base: 14px;
|
||||
|
||||
|
||||
@@ -720,6 +720,7 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
(logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId),
|
||||
this.vaultTimeoutSettingsService,
|
||||
{ createRequest: (url, request) => new Request(url, request) },
|
||||
);
|
||||
|
||||
this.fileUploadService = new FileUploadService(this.logService, this.apiService);
|
||||
|
||||
@@ -357,7 +357,7 @@ export class NativeMessagingBackground {
|
||||
await this.secureCommunication();
|
||||
}
|
||||
|
||||
return await this.encryptService.encrypt(
|
||||
return await this.encryptService.encryptString(
|
||||
JSON.stringify(message),
|
||||
this.secureChannel!.sharedSecret!,
|
||||
);
|
||||
@@ -401,10 +401,9 @@ export class NativeMessagingBackground {
|
||||
return;
|
||||
}
|
||||
message = JSON.parse(
|
||||
await this.encryptService.decryptToUtf8(
|
||||
await this.encryptService.decryptString(
|
||||
rawMessage as EncString,
|
||||
this.secureChannel.sharedSecret,
|
||||
"ipc-desktop-ipc-channel-key",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { BrowserClientVendors } from "@bitwarden/common/autofill/constants";
|
||||
import { BrowserClientVendor } from "@bitwarden/common/autofill/types";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { isBrowserSafariApi } from "@bitwarden/platform";
|
||||
|
||||
@@ -131,6 +133,27 @@ export class BrowserApi {
|
||||
});
|
||||
}
|
||||
|
||||
static getBrowserClientVendor(clientWindow: Window): BrowserClientVendor {
|
||||
const device = BrowserPlatformUtilsService.getDevice(clientWindow);
|
||||
|
||||
switch (device) {
|
||||
case DeviceType.ChromeExtension:
|
||||
case DeviceType.ChromeBrowser:
|
||||
return BrowserClientVendors.Chrome;
|
||||
case DeviceType.OperaExtension:
|
||||
case DeviceType.OperaBrowser:
|
||||
return BrowserClientVendors.Opera;
|
||||
case DeviceType.EdgeExtension:
|
||||
case DeviceType.EdgeBrowser:
|
||||
return BrowserClientVendors.Edge;
|
||||
case DeviceType.VivaldiExtension:
|
||||
case DeviceType.VivaldiBrowser:
|
||||
return BrowserClientVendors.Vivaldi;
|
||||
default:
|
||||
return BrowserClientVendors.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tab with the given id.
|
||||
*
|
||||
@@ -641,6 +664,10 @@ export class BrowserApi {
|
||||
* Identifies if the browser autofill settings are overridden by the extension.
|
||||
*/
|
||||
static async browserAutofillSettingsOverridden(): Promise<boolean> {
|
||||
if (!(await BrowserApi.permissionsGranted(["privacy"]))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const checkOverrideStatus = (details: chrome.types.ChromeSettingGetResult<boolean>) =>
|
||||
details.levelOfControl === "controlled_by_this_extension" && !details.value;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
FormCacheOptions,
|
||||
SignalCacheOptions,
|
||||
ViewCacheService,
|
||||
} from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
} from "@bitwarden/angular/platform/view-cache";
|
||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
@@ -82,7 +82,7 @@ export class PopupViewCacheService implements ViewCacheService {
|
||||
initialValue,
|
||||
persistNavigation,
|
||||
} = options;
|
||||
const cachedValue = this.cache[key]
|
||||
const cachedValue = this.cache[key]?.value
|
||||
? deserializer(JSON.parse(this.cache[key].value))
|
||||
: initialValue;
|
||||
const _signal = signal(cachedValue);
|
||||
|
||||
@@ -26,6 +26,10 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
|
||||
return this.deviceCache;
|
||||
}
|
||||
|
||||
// ORDERING MATTERS HERE
|
||||
// Ordered from most specific to least specific. We try to discern the greatest detail
|
||||
// for the type of extension the user is on by checking specific cases first and as we go down
|
||||
// the list we hope to catch all by the most generic clients they could be on.
|
||||
if (BrowserPlatformUtilsService.isFirefox()) {
|
||||
this.deviceCache = DeviceType.FirefoxExtension;
|
||||
} else if (BrowserPlatformUtilsService.isOpera(globalContext)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'DM Sans\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
font-size="18" text-anchor="middle">
|
||||
Loading...
|
||||
</text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
$dark-icon-themes: "theme_dark";
|
||||
|
||||
$font-family-sans-serif: "DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
$font-size-base: 16px;
|
||||
$font-size-large: 18px;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Router } from "@angular/router";
|
||||
import { merge, of, Subject } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
|
||||
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import {
|
||||
CLIENT_TYPE,
|
||||
DEFAULT_VAULT_TIMEOUT,
|
||||
|
||||
@@ -17,7 +17,15 @@
|
||||
<bit-item>
|
||||
<a bit-item-content routerLink="/autofill">
|
||||
<i slot="start" class="bwi bwi-check-circle" aria-hidden="true"></i>
|
||||
{{ "autofill" | i18n }}
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<p class="tw-pr-2">{{ "autofill" | i18n }}</p>
|
||||
<span
|
||||
*ngIf="!isBrowserAutofillSettingOverridden && (showAutofillBadge$ | async)"
|
||||
bitBadge
|
||||
variant="notification"
|
||||
>1</span
|
||||
>
|
||||
</div>
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { filter, firstValueFrom, Observable, shareReplay, switchMap } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -12,6 +20,8 @@ import { BadgeComponent, ItemModule } from "@bitwarden/components";
|
||||
import { NudgeStatus, VaultNudgesService, VaultNudgeType } from "@bitwarden/vault";
|
||||
|
||||
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
|
||||
import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
||||
@@ -31,8 +41,10 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
|
||||
BadgeComponent,
|
||||
],
|
||||
})
|
||||
export class SettingsV2Component {
|
||||
export class SettingsV2Component implements OnInit {
|
||||
VaultNudgeType = VaultNudgeType;
|
||||
activeUserId: UserId | null = null;
|
||||
protected isBrowserAutofillSettingOverridden = false;
|
||||
|
||||
private authenticatedAccount$: Observable<Account> = this.accountService.activeAccount$.pipe(
|
||||
filter((account): account is Account => account !== null),
|
||||
@@ -51,6 +63,19 @@ export class SettingsV2Component {
|
||||
),
|
||||
);
|
||||
|
||||
showAutofillBadge$: Observable<boolean> = combineLatest([
|
||||
this.autofillBrowserSettingsService.defaultBrowserAutofillDisabled$,
|
||||
this.authenticatedAccount$,
|
||||
]).pipe(
|
||||
switchMap(([defaultBrowserAutofillDisabled, account]) =>
|
||||
this.vaultNudgesService.showNudge$(VaultNudgeType.AutofillNudge, account.id).pipe(
|
||||
map((nudgeStatus) => {
|
||||
return !defaultBrowserAutofillDisabled && nudgeStatus.hasBadgeDismissed === false;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected isNudgeFeatureEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.PM8851_BrowserOnboardingNudge,
|
||||
);
|
||||
@@ -58,9 +83,17 @@ export class SettingsV2Component {
|
||||
constructor(
|
||||
private readonly vaultNudgesService: VaultNudgesService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.isBrowserAutofillSettingOverridden =
|
||||
await this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden(
|
||||
BrowserApi.getBrowserClientVendor(window),
|
||||
);
|
||||
}
|
||||
|
||||
async dismissBadge(type: VaultNudgeType) {
|
||||
if (!(await firstValueFrom(this.showVaultBadge$)).hasBadgeDismissed) {
|
||||
const account = await firstValueFrom(this.authenticatedAccount$);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
COPY_USERNAME_ID,
|
||||
COPY_VERIFICATION_CODE_ID,
|
||||
SHOW_AUTOFILL_BUTTON,
|
||||
UPDATE_PASSWORD,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { sendExtensionMessage } from "../../../../../autofill/utils/index";
|
||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
|
||||
@@ -72,7 +74,8 @@ type LoadAction =
|
||||
| typeof SHOW_AUTOFILL_BUTTON
|
||||
| typeof COPY_USERNAME_ID
|
||||
| typeof COPY_PASSWORD_ID
|
||||
| typeof COPY_VERIFICATION_CODE_ID;
|
||||
| typeof COPY_VERIFICATION_CODE_ID
|
||||
| typeof UPDATE_PASSWORD;
|
||||
|
||||
@Component({
|
||||
selector: "app-view-v2",
|
||||
@@ -294,7 +297,7 @@ export class ViewV2Component {
|
||||
// Both vaultPopupAutofillService and copyCipherFieldService will perform password re-prompting internally.
|
||||
|
||||
switch (loadAction) {
|
||||
case "show-autofill-button":
|
||||
case SHOW_AUTOFILL_BUTTON:
|
||||
// This action simply shows the cipher view, no need to do anything.
|
||||
if (
|
||||
this.cipher.reprompt !== CipherRepromptType.None &&
|
||||
@@ -303,30 +306,42 @@ export class ViewV2Component {
|
||||
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
|
||||
}
|
||||
return;
|
||||
case "autofill":
|
||||
case AUTOFILL_ID:
|
||||
actionSuccess = await this.vaultPopupAutofillService.doAutofill(this.cipher, false);
|
||||
break;
|
||||
case "copy-username":
|
||||
case COPY_USERNAME_ID:
|
||||
actionSuccess = await this.copyCipherFieldService.copy(
|
||||
this.cipher.login.username,
|
||||
"username",
|
||||
this.cipher,
|
||||
);
|
||||
break;
|
||||
case "copy-password":
|
||||
case COPY_PASSWORD_ID:
|
||||
actionSuccess = await this.copyCipherFieldService.copy(
|
||||
this.cipher.login.password,
|
||||
"password",
|
||||
this.cipher,
|
||||
);
|
||||
break;
|
||||
case "copy-totp":
|
||||
case COPY_VERIFICATION_CODE_ID:
|
||||
actionSuccess = await this.copyCipherFieldService.copy(
|
||||
this.cipher.login.totp,
|
||||
"totp",
|
||||
this.cipher,
|
||||
);
|
||||
break;
|
||||
case UPDATE_PASSWORD: {
|
||||
const repromptSuccess = await this.passwordRepromptService.showPasswordPrompt();
|
||||
|
||||
await sendExtensionMessage("bgHandleReprompt", {
|
||||
tab: await chrome.tabs.get(senderTabId),
|
||||
success: repromptSuccess,
|
||||
});
|
||||
|
||||
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (BrowserPopupUtils.inPopout(window)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormBuilder } from "@angular/forms";
|
||||
import { BehaviorSubject, skipWhile } from "rxjs";
|
||||
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -20,8 +21,6 @@ import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
|
||||
|
||||
import {
|
||||
CachedFilterState,
|
||||
MY_VAULT_ID,
|
||||
@@ -123,7 +122,7 @@ describe("VaultPopupListFiltersService", () => {
|
||||
useValue: accountService,
|
||||
},
|
||||
{
|
||||
provide: PopupViewCacheService,
|
||||
provide: ViewCacheService,
|
||||
useValue: viewCacheService,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
@@ -40,8 +41,6 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import { ChipSelectOption } from "@bitwarden/components";
|
||||
|
||||
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
|
||||
|
||||
const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", {
|
||||
deserializer: (obj) => obj,
|
||||
});
|
||||
@@ -178,7 +177,7 @@ export class VaultPopupListFiltersService {
|
||||
private policyService: PolicyService,
|
||||
private stateProvider: StateProvider,
|
||||
private accountService: AccountService,
|
||||
private viewCacheService: PopupViewCacheService,
|
||||
private viewCacheService: ViewCacheService,
|
||||
) {
|
||||
this.filterForm.controls.organization.valueChanges
|
||||
.pipe(takeUntilDestroyed())
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<popup-header slot="header" pageTitle="{{ 'downloadBitwarden' | i18n }}" showBackButton>
|
||||
<ng-container slot="end">
|
||||
<app-pop-out></app-pop-out>
|
||||
<app-current-account></app-current-account>
|
||||
</ng-container>
|
||||
</popup-header>
|
||||
<h2 bitTypography="h6">
|
||||
@@ -20,16 +19,30 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="tw-flex tw-justify-center tw-gap-4">
|
||||
<div class="tw-w-[43%]">
|
||||
<a href="https://apps.apple.com/app/bitwarden-password-manager/id1137397744">
|
||||
<img class="tw-w-full" src="../../../images/app-store.png" alt="" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="tw-w-[43%]">
|
||||
<a href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden">
|
||||
<img class="tw-w-full" src="../../../images/google-play.png" alt="" />
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
class="tw-w-[43%] !tw-py-0"
|
||||
target="_blank"
|
||||
href="https://apps.apple.com/app/bitwarden-password-manager/id1137397744"
|
||||
bitLink
|
||||
>
|
||||
<img
|
||||
class="tw-w-full"
|
||||
src="../../../images/app-store.png"
|
||||
alt="{{ 'downloadOnTheAppStore' | i18n }}"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="tw-w-[43%] !tw-py-0"
|
||||
target="_blank"
|
||||
href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden"
|
||||
bitLink
|
||||
>
|
||||
<img
|
||||
class="tw-w-full"
|
||||
src="../../../images/google-play.png"
|
||||
alt="{{ 'getItOnGooglePlay' | i18n }}"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</bit-card>
|
||||
|
||||
@@ -41,6 +54,7 @@
|
||||
<a
|
||||
class="tw-text-primary-600 tw-mt-4 tw-flex tw-no-underline tw-gap-2 tw-items-center"
|
||||
href="https://bitwarden.com/download/#downloads-desktop"
|
||||
bitLink
|
||||
target="_blank"
|
||||
>
|
||||
{{ "downloadFromBitwardenNow" | i18n }}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { CardComponent, TypographyModule } from "@bitwarden/components";
|
||||
import { CardComponent, LinkModule, TypographyModule } from "@bitwarden/components";
|
||||
import { VaultNudgesService, VaultNudgeType } from "@bitwarden/vault";
|
||||
|
||||
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
|
||||
@@ -27,6 +27,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
|
||||
CardComponent,
|
||||
TypographyModule,
|
||||
CurrentAccountComponent,
|
||||
LinkModule,
|
||||
],
|
||||
})
|
||||
export class DownloadBitwardenComponent implements OnInit {
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@koa/multer": "3.0.2",
|
||||
"@koa/multer": "3.1.0",
|
||||
"@koa/router": "13.1.0",
|
||||
"argon2": "0.41.1",
|
||||
"big-integer": "1.6.52",
|
||||
@@ -81,7 +81,7 @@
|
||||
"koa-json": "2.0.2",
|
||||
"lowdb": "1.0.0",
|
||||
"lunr": "2.3.9",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"multer": "1.4.5-lts.2",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-forge": "1.3.1",
|
||||
"open": "8.4.2",
|
||||
|
||||
@@ -39,6 +39,7 @@ export class NodeApiService extends ApiService {
|
||||
logService,
|
||||
logoutCallback,
|
||||
vaultTimeoutSettingsService,
|
||||
{ createRequest: (url, request) => new Request(url, request) },
|
||||
customUserAgent,
|
||||
);
|
||||
}
|
||||
|
||||
153
apps/desktop/desktop_native/Cargo.lock
generated
153
apps/desktop/desktop_native/Cargo.lock
generated
@@ -120,9 +120,9 @@ checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.4.1"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df099ccb16cd014ff054ac1bf392c67feeef57164b05c42f037cd40f5d4357f4"
|
||||
checksum = "c1df21f715862ede32a0c525ce2ca4d52626bb0007f8c18b87a384503ac33e70"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"log",
|
||||
@@ -130,6 +130,7 @@ dependencies = [
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -465,15 +466,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.1"
|
||||
@@ -565,12 +557,6 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
@@ -867,17 +853,6 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive-new"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d150dea618e920167e5973d70ae6ece4385b7164e0d799fe7c122dd0a5d912ad"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "desktop_core"
|
||||
version = "0.0.0"
|
||||
@@ -1007,6 +982,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -1409,7 +1394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"nix 0.29.0",
|
||||
"nix",
|
||||
"widestring",
|
||||
"windows 0.57.0",
|
||||
]
|
||||
@@ -1839,18 +1824,6 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.1.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -1859,7 +1832,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.2.1",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
@@ -1990,47 +1963,24 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-sys"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.5.2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
|
||||
checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551"
|
||||
dependencies = [
|
||||
"objc-sys",
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.2.2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-data",
|
||||
"objc2-core-image",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
@@ -2041,18 +1991,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.2.2"
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"bitflags",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-metal",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2063,14 +2016,13 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.2.2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||
checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2084,28 +2036,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-metal"
|
||||
version = "0.2.2"
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-metal",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3487,9 +3425,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.31.2"
|
||||
version = "0.32.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4"
|
||||
checksum = "0781cf46869b37e36928f7b432273c0995aa8aed9552c556fb18754420541efc"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wayland-backend",
|
||||
@@ -3499,9 +3437,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.2.0"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6"
|
||||
checksum = "248a02e6f595aad796561fa82d25601bd2c8c3b145b1c7453fc8f94c1a58f8b2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wayland-backend",
|
||||
@@ -3982,15 +3920,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.8.1"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12b41773911497b18ca8553c3daaf8ec9fe9819caf93d451d3055f69de028adb"
|
||||
checksum = "2a083daad7e8a4b8805ad73947ccadabe62afe37ce0e9787a56ff373d34762c7"
|
||||
dependencies = [
|
||||
"derive-new",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.28.0",
|
||||
"os_pipe",
|
||||
"rustix",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tree_magic_mini",
|
||||
@@ -4085,7 +4022,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nix 0.29.0",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
@@ -4115,7 +4052,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"nix 0.29.0",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
|
||||
@@ -11,7 +11,7 @@ publish = false
|
||||
[workspace.dependencies]
|
||||
aes = "=0.8.4"
|
||||
anyhow = "=1.0.94"
|
||||
arboard = { version = "=3.4.1", default-features = false }
|
||||
arboard = { version = "=3.5.0", default-features = false }
|
||||
argon2 = "=0.5.3"
|
||||
base64 = "=0.22.1"
|
||||
bindgen = "0.71.1"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.14.1",
|
||||
"@types/node": "22.15.3",
|
||||
"typescript": "5.4.2"
|
||||
}
|
||||
},
|
||||
@@ -101,9 +101,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"version": "22.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
|
||||
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.14.1",
|
||||
"@types/node": "22.15.3",
|
||||
"typescript": "5.4.2"
|
||||
},
|
||||
"_moduleAliases": {
|
||||
|
||||
@@ -220,7 +220,7 @@ export default class NativeMessageService {
|
||||
|
||||
const sharedKey = await this.getSharedKeyForKey(key);
|
||||
|
||||
return this.encryptService.encrypt(commandDataString, sharedKey);
|
||||
return this.encryptService.encryptString(commandDataString, sharedKey);
|
||||
}
|
||||
|
||||
private async decryptResponsePayload(
|
||||
@@ -228,11 +228,7 @@ export default class NativeMessageService {
|
||||
key: string,
|
||||
): Promise<DecryptedCommandData> {
|
||||
const sharedKey = await this.getSharedKeyForKey(key);
|
||||
const decrypted = await this.encryptService.decryptToUtf8(
|
||||
payload,
|
||||
sharedKey,
|
||||
"native-messaging-session",
|
||||
);
|
||||
const decrypted = await this.encryptService.decryptString(payload, sharedKey);
|
||||
|
||||
return JSON.parse(decrypted);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export class AvatarComponent implements OnChanges, OnInit {
|
||||
textTag.setAttribute("fill", Utils.pickTextColorBasedOnBgColor(color, 135, true));
|
||||
textTag.setAttribute(
|
||||
"font-family",
|
||||
'"DM Sans","Helvetica Neue",Helvetica,Arial,' +
|
||||
'Roboto,"Helvetica Neue",Helvetica,Arial,' +
|
||||
'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"',
|
||||
);
|
||||
textTag.textContent = character;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'DM Sans\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
font-size="18" text-anchor="middle">
|
||||
Loading...
|
||||
</text>
|
||||
|
||||
@@ -3616,6 +3616,27 @@
|
||||
"biometricsStatusHelptextUnavailableReasonUnknown": {
|
||||
"message": "Biometric unlock is currently unavailable for an unknown reason."
|
||||
},
|
||||
"itemDetails": {
|
||||
"message": "Item details"
|
||||
},
|
||||
"itemName": {
|
||||
"message": "Item name"
|
||||
},
|
||||
"loginCredentials": {
|
||||
"message": "Login credentials"
|
||||
},
|
||||
"additionalOptions": {
|
||||
"message": "Additional options"
|
||||
},
|
||||
"itemHistory": {
|
||||
"message": "Item history"
|
||||
},
|
||||
"lastEdited": {
|
||||
"message": "Last edited"
|
||||
},
|
||||
"upload": {
|
||||
"message": "Upload"
|
||||
},
|
||||
"authorize": {
|
||||
"message": "Authorize"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
$dark-icon-themes: "theme_dark";
|
||||
|
||||
$font-family-sans-serif: "DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
$font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
$font-size-base: 14px;
|
||||
$font-size-large: 18px;
|
||||
|
||||
@@ -347,7 +347,7 @@ describe("BiometricMessageHandlerService", () => {
|
||||
trusted: false,
|
||||
}),
|
||||
);
|
||||
encryptService.decryptToUtf8.mockResolvedValue(
|
||||
encryptService.decryptString.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
command: "biometricUnlock",
|
||||
messageId: 0,
|
||||
@@ -382,7 +382,7 @@ describe("BiometricMessageHandlerService", () => {
|
||||
ngZone.run.mockReturnValue({
|
||||
closed: of(true),
|
||||
});
|
||||
encryptService.decryptToUtf8.mockResolvedValue(
|
||||
encryptService.decryptString.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
messageId: 0,
|
||||
@@ -433,7 +433,7 @@ describe("BiometricMessageHandlerService", () => {
|
||||
ngZone.run.mockReturnValue({
|
||||
closed: of(false),
|
||||
});
|
||||
encryptService.decryptToUtf8.mockResolvedValue(
|
||||
encryptService.decryptString.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
messageId: 0,
|
||||
@@ -480,7 +480,7 @@ describe("BiometricMessageHandlerService", () => {
|
||||
trusted: true,
|
||||
}),
|
||||
);
|
||||
encryptService.decryptToUtf8.mockResolvedValue(
|
||||
encryptService.decryptString.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
messageId: 0,
|
||||
|
||||
@@ -175,7 +175,7 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
const message: LegacyMessage = JSON.parse(
|
||||
await this.encryptService.decryptToUtf8(
|
||||
await this.encryptService.decryptString(
|
||||
rawMessage as EncString,
|
||||
SymmetricCryptoKey.fromString(sessionSecret),
|
||||
),
|
||||
@@ -365,7 +365,7 @@ export class BiometricMessageHandlerService {
|
||||
throw new Error("Session secret is missing");
|
||||
}
|
||||
|
||||
const encrypted = await this.encryptService.encrypt(
|
||||
const encrypted = await this.encryptService.encryptString(
|
||||
JSON.stringify(message),
|
||||
SymmetricCryptoKey.fromString(sessionSecret),
|
||||
);
|
||||
|
||||
@@ -168,7 +168,7 @@ export class DuckDuckGoMessageHandlerService {
|
||||
payload: DecryptedCommandData,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
return await this.encryptService.encrypt(JSON.stringify(payload), key);
|
||||
return await this.encryptService.encryptString(JSON.stringify(payload), key);
|
||||
}
|
||||
|
||||
private async decryptPayload(message: EncryptedMessage): Promise<DecryptedCommandData> {
|
||||
@@ -188,10 +188,9 @@ export class DuckDuckGoMessageHandlerService {
|
||||
}
|
||||
|
||||
try {
|
||||
let decryptedResult = await this.encryptService.decryptToUtf8(
|
||||
let decryptedResult = await this.encryptService.decryptString(
|
||||
message.encryptedCommand as EncString,
|
||||
this.duckduckgoSharedSecret,
|
||||
"ddg-shared-key",
|
||||
);
|
||||
|
||||
decryptedResult = this.trimNullCharsFromMessage(decryptedResult);
|
||||
|
||||
@@ -514,6 +514,9 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
await this.buildFormConfig("edit");
|
||||
if (!cipher.edit && this.config) {
|
||||
this.config.mode = "partial-edit";
|
||||
}
|
||||
this.action = "edit";
|
||||
await this.go().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class StripeService {
|
||||
base: {
|
||||
color: null,
|
||||
fontFamily:
|
||||
'"DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
|
||||
'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
|
||||
'"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
|
||||
fontSize: "16px",
|
||||
fontSmoothing: "antialiased",
|
||||
|
||||
@@ -112,13 +112,15 @@ export class SponsoredFamiliesComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.anyOrgsAvailable$ = this.availableSponsorshipOrgs$.pipe(map((orgs) => orgs.length > 0));
|
||||
|
||||
this.activeSponsorshipOrgs$ = this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(map((orgs) => orgs.filter((o) => o.familySponsorshipFriendlyName !== null)));
|
||||
|
||||
.pipe(
|
||||
map((orgs) =>
|
||||
orgs.filter((o) => o.familySponsorshipFriendlyName !== null && !o.isAdminInitiated),
|
||||
),
|
||||
);
|
||||
this.anyActiveSponsorships$ = this.activeSponsorshipOrgs$.pipe(map((orgs) => orgs.length > 0));
|
||||
|
||||
this.loading = false;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DangerZoneComponent } from "../auth/settings/account/danger-zone.compon
|
||||
import { DeauthorizeSessionsComponent } from "../auth/settings/account/deauthorize-sessions.component";
|
||||
import { DeleteAccountDialogComponent } from "../auth/settings/account/delete-account-dialog.component";
|
||||
import { ProfileComponent } from "../auth/settings/account/profile.component";
|
||||
import { SelectableAvatarComponent } from "../auth/settings/account/selectable-avatar.component";
|
||||
import { EmergencyAccessConfirmComponent } from "../auth/settings/emergency-access/confirm/emergency-access-confirm.component";
|
||||
import { EmergencyAccessAddEditComponent } from "../auth/settings/emergency-access/emergency-access-add-edit.component";
|
||||
import { EmergencyAccessComponent } from "../auth/settings/emergency-access/emergency-access.component";
|
||||
@@ -39,7 +40,6 @@ import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.comp
|
||||
import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component";
|
||||
import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component";
|
||||
import { DynamicAvatarComponent } from "../components/dynamic-avatar.component";
|
||||
import { SelectableAvatarComponent } from "../components/selectable-avatar.component";
|
||||
import { ExposedPasswordsReportComponent as OrgExposedPasswordsReportComponent } from "../dirt/reports/pages/organizations/exposed-passwords-report.component";
|
||||
import { InactiveTwoFactorReportComponent as OrgInactiveTwoFactorReportComponent } from "../dirt/reports/pages/organizations/inactive-two-factor-report.component";
|
||||
import { ReusedPasswordsReportComponent as OrgReusedPasswordsReportComponent } from "../dirt/reports/pages/organizations/reused-passwords-report.component";
|
||||
|
||||
@@ -10,13 +10,13 @@ describe("duo-redirect", () => {
|
||||
});
|
||||
|
||||
it("should redirect to a valid Duo URL", () => {
|
||||
const validUrl = "https://api-123.duosecurity.com/auth";
|
||||
const validUrl = "https://api-123.duosecurity.com/oauth/v1/authorize";
|
||||
redirectToDuoFrameless(validUrl);
|
||||
expect(window.location.href).toBe(validUrl);
|
||||
});
|
||||
|
||||
it("should redirect to a valid Duo Federal URL", () => {
|
||||
const validUrl = "https://api-123.duofederal.com/auth";
|
||||
const validUrl = "https://api-123.duofederal.com/oauth/v1/authorize";
|
||||
redirectToDuoFrameless(validUrl);
|
||||
expect(window.location.href).toBe(validUrl);
|
||||
});
|
||||
@@ -27,15 +27,55 @@ describe("duo-redirect", () => {
|
||||
});
|
||||
|
||||
it("should throw an error for an malicious URL with valid redirect embedded", () => {
|
||||
const invalidUrl = "https://malicious-site.com\\@api-123.duosecurity.com/auth";
|
||||
const invalidUrl = "https://malicious-site.com\\@api-123.duosecurity.com/oauth/v1/authorize";
|
||||
expect(() => redirectToDuoFrameless(invalidUrl)).toThrow("Invalid redirect URL");
|
||||
});
|
||||
|
||||
it("should throw an error for a URL with a malicious subdomain", () => {
|
||||
const maliciousSubdomainUrl =
|
||||
"https://api-a86d5bde.duosecurity.com.evil.com/oauth/v1/authorize";
|
||||
expect(() => redirectToDuoFrameless(maliciousSubdomainUrl)).toThrow("Invalid redirect URL");
|
||||
});
|
||||
|
||||
it("should throw an error for a URL using HTTP protocol", () => {
|
||||
const maliciousSubdomainUrl = "http://api-a86d5bde.duosecurity.com/oauth/v1/authorize";
|
||||
expect(() => redirectToDuoFrameless(maliciousSubdomainUrl)).toThrow(
|
||||
"Invalid redirect URL: invalid protocol",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error for a URL with javascript code", () => {
|
||||
const maliciousSubdomainUrl = "javascript://https://api-a86d5bde.duosecurity.com%0Aalert(1)";
|
||||
expect(() => redirectToDuoFrameless(maliciousSubdomainUrl)).toThrow(
|
||||
"Invalid redirect URL: invalid protocol",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error for a non-HTTPS URL", () => {
|
||||
const nonHttpsUrl = "http://api-123.duosecurity.com/auth";
|
||||
expect(() => redirectToDuoFrameless(nonHttpsUrl)).toThrow("Invalid redirect URL");
|
||||
});
|
||||
|
||||
it("should throw an error for a URL with invalid port specified", () => {
|
||||
const urlWithPort = "https://api-123.duyosecurity.com:8080/auth";
|
||||
expect(() => redirectToDuoFrameless(urlWithPort)).toThrow(
|
||||
"Invalid redirect URL: port not allowed",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect to a valid Duo Federal URL with valid port", () => {
|
||||
const validUrl = "https://api-123.duofederal.com:443/oauth/v1/authorize";
|
||||
redirectToDuoFrameless(validUrl);
|
||||
expect(window.location.href).toBe(validUrl);
|
||||
});
|
||||
|
||||
it("should throw an error for a URL with an invalid pathname", () => {
|
||||
const urlWithPort = "https://api-123.duyosecurity.com/../evil/path/here/";
|
||||
expect(() => redirectToDuoFrameless(urlWithPort)).toThrow(
|
||||
"Invalid redirect URL: invalid pathname",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error for a URL with an invalid hostname", () => {
|
||||
const invalidHostnameUrl = "https://api-123.invalid.com";
|
||||
expect(() => redirectToDuoFrameless(invalidHostnameUrl)).toThrow("Invalid redirect URL");
|
||||
|
||||
@@ -57,29 +57,46 @@ window.addEventListener("load", async () => {
|
||||
* @param redirectUrl the duo auth url
|
||||
*/
|
||||
export function redirectToDuoFrameless(redirectUrl: string) {
|
||||
// Regex to match a valid duo redirect URL
|
||||
// Validation for Duo redirect URL to prevent open redirect or XSS vulnerabilities
|
||||
// Only used for Duo 2FA redirects in the extension
|
||||
/**
|
||||
* This regex checks for the following:
|
||||
* The string must start with "https://api-"
|
||||
* Followed by a subdomain that can contain letters, numbers
|
||||
* The hostname must start with a subdomain that begins with "api-" followed by a
|
||||
* string that can contain letters or numbers of indeterminate length
|
||||
* Followed by either "duosecurity.com" or "duofederal.com"
|
||||
* This ensures that the redirect does not contain any malicious content
|
||||
* and is a valid Duo URL.
|
||||
* */
|
||||
const duoRedirectUrlRegex = /^https:\/\/api-[a-zA-Z0-9]+\.(duosecurity|duofederal)\.com/;
|
||||
// Check if the redirect URL matches the regex
|
||||
if (!duoRedirectUrlRegex.test(redirectUrl)) {
|
||||
throw new Error("Invalid redirect URL");
|
||||
}
|
||||
// At this point we know the URL to be valid, but we need to check for embedded credentials
|
||||
const duoRedirectUrlRegex = /^api-[a-zA-Z0-9]+\.(duosecurity|duofederal)\.com$/;
|
||||
const validateUrl = new URL(redirectUrl);
|
||||
// URLs should not contain
|
||||
|
||||
// Check that no embedded credentials are present
|
||||
if (validateUrl.username || validateUrl.password) {
|
||||
throw new Error("Invalid redirect URL: embedded credentials not allowed");
|
||||
}
|
||||
|
||||
window.location.href = decodeURIComponent(redirectUrl);
|
||||
// Check that the protocol is HTTPS
|
||||
if (validateUrl.protocol !== "https:") {
|
||||
throw new Error("Invalid redirect URL: invalid protocol");
|
||||
}
|
||||
|
||||
// Check that the port is not specified
|
||||
if (validateUrl.port && validateUrl.port !== "443") {
|
||||
throw new Error("Invalid redirect URL: port not allowed");
|
||||
}
|
||||
|
||||
if (validateUrl.pathname !== "/oauth/v1/authorize") {
|
||||
throw new Error("Invalid redirect URL: invalid pathname");
|
||||
}
|
||||
|
||||
// Check if the redirect hostname matches the regex
|
||||
// Only check the hostname part of the URL to avoid over-zealous Regex expressions from matching
|
||||
// and causing an Open Redirect vulnerability. Always use hostname instead of host, because host includes port if specified.
|
||||
if (!duoRedirectUrlRegex.test(validateUrl.hostname)) {
|
||||
throw new Error("Invalid redirect URL");
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
|
||||
<text fill="%23FBFBFB" x="50%" y="50%" font-family="\'DM Sans\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
<text fill="%23FBFBFB" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
font-size="18" text-anchor="middle">
|
||||
Loading...
|
||||
</text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100% 100%">
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'DM Sans\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
<text fill="%23333333" x="50%" y="50%" font-family="\'Roboto\', \'Helvetica Neue\', Helvetica, Arial, sans-serif"
|
||||
font-size="18" text-anchor="middle">
|
||||
Loading...
|
||||
</text>
|
||||
|
||||
@@ -21,7 +21,7 @@ $body-bg: $white;
|
||||
$body-color: #333333;
|
||||
|
||||
$font-family-sans-serif:
|
||||
"DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
|
||||
$h1-font-size: 1.7rem;
|
||||
|
||||
1
libs/angular/src/platform/view-cache/index.ts
Normal file
1
libs/angular/src/platform/view-cache/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ViewCacheService, FormCacheOptions, SignalCacheOptions } from "./view-cache.service";
|
||||
1
libs/angular/src/platform/view-cache/internal.ts
Normal file
1
libs/angular/src/platform/view-cache/internal.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NoopViewCacheService } from "./noop-view-cache.service";
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Injectable, signal, WritableSignal } from "@angular/core";
|
||||
import type { FormGroup } from "@angular/forms";
|
||||
|
||||
import {
|
||||
FormCacheOptions,
|
||||
SignalCacheOptions,
|
||||
ViewCacheService,
|
||||
} from "../abstractions/view-cache.service";
|
||||
import { FormCacheOptions, SignalCacheOptions, ViewCacheService } from "./view-cache.service";
|
||||
|
||||
/**
|
||||
* The functionality of the {@link ViewCacheService} is only needed in the browser extension popup,
|
||||
130
libs/angular/src/platform/view-cache/view-cache.md
Normal file
130
libs/angular/src/platform/view-cache/view-cache.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Extension Persistence
|
||||
|
||||
By default, when the browser extension popup closes, the user's current view and any data entered
|
||||
without saving is lost. This introduces friction in several workflows within our client, such as:
|
||||
|
||||
- Performing actions that require email OTP entry, since the user must navigate from the popup to
|
||||
get to their email inbox
|
||||
- Entering information to create a new vault item from a browser tab
|
||||
- And many more
|
||||
|
||||
Previously, we have recommended that users "pop out" the extension into its own window to persist
|
||||
the extension context, but this introduces additional user actions and may leave the extension open
|
||||
(and unlocked) for longer than a user intends.
|
||||
|
||||
In order to provide a better user experience, we have introduced two levels of persistence to the
|
||||
Bitwarden extension client:
|
||||
|
||||
- We persist the route history, allowing us to re-open the last route when the popup re-opens, and
|
||||
- We offer a service for teams to use to persist component-specific form data or state to survive a
|
||||
popup close/re-open cycle
|
||||
|
||||
## Persistence lifetime
|
||||
|
||||
Since we are persisting data, it is important that the lifetime of that data be well-understood and
|
||||
well-constrained. The cache of route history and form data is cleared when any of the following
|
||||
events occur:
|
||||
|
||||
- The account is locked
|
||||
- The account is logged out
|
||||
- Account switching is used to switch the active account
|
||||
- The extension popup has been closed for 2 minutes
|
||||
|
||||
In addition, cached form data is cleared when a browser extension navigation event occurs (e.g.
|
||||
switching between tabs in the extension).
|
||||
|
||||
## Types of persistence
|
||||
|
||||
### Route history persistence
|
||||
|
||||
Route history is persisted on the extension automatically, with no specific implementation required
|
||||
on any component.
|
||||
|
||||
The persistence layer ensures that the popup will open at the same route as was active when it
|
||||
closed, provided that none of the lifetime expiration events have occurred.
|
||||
|
||||
:::tip Excluding a route
|
||||
|
||||
If a particular route should be excluded from the history and not persisted, add
|
||||
`doNotSaveUrl: true` to the `data` property on the route.
|
||||
|
||||
:::
|
||||
|
||||
### View data persistence
|
||||
|
||||
Route persistence ensures that the user will land back on the route that they were on when the popup
|
||||
closed, but it does not persist any state or form data that the user may have modified. In order to
|
||||
persist that data, the component is responsible for registering that data with the
|
||||
[`ViewCacheService`](./view-cache.service.ts).
|
||||
This is done prescriptively to ensure that only necessary data is cached and that it is done with
|
||||
intention by the component.
|
||||
|
||||
The `ViewCacheService` provides an interface for caching both individual state and `FormGroup`s.
|
||||
|
||||
#### Caching individual data elements
|
||||
|
||||
For individual pieces of state, use the `signal()` method on the `ViewCacheService` to create a
|
||||
writeable [signal](https://angular.dev/guide/signals) wrapper around the desired state.
|
||||
|
||||
```typescript
|
||||
const mySignal = this.viewCacheService.signal({
|
||||
key: "my-state-key"
|
||||
initialValue: null
|
||||
});
|
||||
```
|
||||
|
||||
If a cached value exists, the returned signal will contain the cached data.
|
||||
|
||||
Setting the value should be done through the signal's `set()` method:
|
||||
|
||||
```typescript
|
||||
const mySignal = this.viewCacheService.signal({
|
||||
key: "my-state-key"
|
||||
initialValue: null
|
||||
});
|
||||
mySignal.set("value")
|
||||
```
|
||||
|
||||
:::note Equality comparison
|
||||
|
||||
By default, signals use `Object.is` to determine equality, and `set()` will only trigger updates if
|
||||
the updated value is not equal to the current signal state. See documentation
|
||||
[here](https://angular.dev/guide/signals#signal-equality-functions).
|
||||
|
||||
:::
|
||||
|
||||
Putting this together, the most common implementation pattern would be:
|
||||
|
||||
1. **Register the signal** using `ViewCacheService.signal()` on initialization of the component or
|
||||
service responsible for the state being persisted.
|
||||
2. **Restore state from the signal:** If cached data exists, the signal will contain that data. The
|
||||
component or service should use this data to re-create the state from prior to the popup closing.
|
||||
3. **Set new state** in the cache when it changes. Ensure that any updates to the data are persisted
|
||||
to the cache with `set()`, so that the cache reflects the latest state.
|
||||
|
||||
#### Caching form data
|
||||
|
||||
For persisting form data, the `ViewCacheService` supplies a `formGroup()` method, which manages the
|
||||
persistence of any entered form data to the cache and the initialization of the form from the cached
|
||||
data. You can supply the `FormGroup` in the `control` parameter of the method, and the
|
||||
`ViewCacheService` will:
|
||||
|
||||
- Initialize the form the a cached value, if it exists
|
||||
- Save form value to cache when it changes
|
||||
- Mark the form dirty if the restored value is not `undefined`.
|
||||
|
||||
```typescript
|
||||
this.loginDetailsForm = this.viewCacheService.formGroup({
|
||||
key: "my-form",
|
||||
control: this.formBuilder.group({
|
||||
username: [""],
|
||||
email: [""],
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## What about other clients?
|
||||
|
||||
The `ViewCacheService` is designed to be injected into shared, client-agnostic components. A
|
||||
`NoopViewCacheService` is provided and injected for non-extension clients, preserving a single
|
||||
interface for your components.
|
||||
@@ -42,6 +42,8 @@ export type FormCacheOptions<TFormGroup extends FormGroup> = BaseCacheOptions<
|
||||
/**
|
||||
* Cache for temporary component state
|
||||
*
|
||||
* [Read more](./view-cache.md)
|
||||
*
|
||||
* #### Implementations
|
||||
* - browser extension popup: used to persist UI between popup open and close
|
||||
* - all other clients: noop
|
||||
@@ -1,8 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
font-family: Roboto;
|
||||
src:
|
||||
url("webfonts/dm-sans.woff2") format("woff2 supports variations"),
|
||||
url("webfonts/dm-sans.woff2") format("woff2-variations");
|
||||
url("webfonts/roboto.woff2") format("woff2 supports variations"),
|
||||
url("webfonts/roboto.woff2") format("woff2-variations");
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
Binary file not shown.
BIN
libs/angular/src/scss/webfonts/roboto.woff2
Normal file
BIN
libs/angular/src/scss/webfonts/roboto.woff2
Normal file
Binary file not shown.
@@ -13,6 +13,7 @@ import {
|
||||
import { Theme } from "@bitwarden/common/platform/enums";
|
||||
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
||||
import { Message } from "@bitwarden/common/platform/messaging";
|
||||
import { HttpOperations } from "@bitwarden/common/services/api.service";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
// Re-export the SafeInjectionToken from ui-common
|
||||
export { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
@@ -61,3 +62,5 @@ export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() =>
|
||||
export const ENV_ADDITIONAL_REGIONS = new SafeInjectionToken<RegionConfig[]>(
|
||||
"ENV_ADDITIONAL_REGIONS",
|
||||
);
|
||||
|
||||
export const HTTP_OPERATIONS = new SafeInjectionToken<HttpOperations>("HTTP_OPERATIONS");
|
||||
|
||||
@@ -325,18 +325,20 @@ import {
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
|
||||
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
||||
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
||||
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
|
||||
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
|
||||
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
|
||||
import { NoopViewCacheService } from "../platform/services/noop-view-cache.service";
|
||||
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
|
||||
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
|
||||
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
|
||||
import { ViewCacheService } from "../platform/view-cache";
|
||||
// eslint-disable-next-line no-restricted-imports -- Needed for DI
|
||||
import { NoopViewCacheService } from "../platform/view-cache/internal";
|
||||
|
||||
import {
|
||||
CLIENT_TYPE,
|
||||
DEFAULT_VAULT_TIMEOUT,
|
||||
ENV_ADDITIONAL_REGIONS,
|
||||
HTTP_OPERATIONS,
|
||||
INTRAPROCESS_MESSAGING_SUBJECT,
|
||||
LOCALES_DIRECTORY,
|
||||
LOCKED_CALLBACK,
|
||||
@@ -700,6 +702,10 @@ const safeProviders: SafeProvider[] = [
|
||||
},
|
||||
deps: [ToastService, I18nServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: HTTP_OPERATIONS,
|
||||
useValue: { createRequest: (url, request) => new Request(url, request) },
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ApiServiceAbstraction,
|
||||
useClass: ApiService,
|
||||
@@ -712,6 +718,7 @@ const safeProviders: SafeProvider[] = [
|
||||
LogService,
|
||||
LOGOUT_CALLBACK,
|
||||
VaultTimeoutSettingsService,
|
||||
HTTP_OPERATIONS,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -23,12 +23,10 @@ import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request"
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -101,7 +99,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
private validationService: ValidationService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private loginViaAuthRequestCacheService: LoginViaAuthRequestCacheService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
|
||||
@@ -132,7 +129,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
async ngOnInit(): Promise<void> {
|
||||
// Get the authStatus early because we use it in both flows
|
||||
this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
await this.loginViaAuthRequestCacheService.init();
|
||||
|
||||
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
||||
|
||||
@@ -410,24 +406,22 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
const authRequestResponse: AuthRequestResponse =
|
||||
await this.authRequestApiService.postAuthRequest(authRequest);
|
||||
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
|
||||
if (!this.authRequestKeyPair.privateKey) {
|
||||
this.logService.error("No private key when trying to cache the login view.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.accessCode) {
|
||||
this.logService.error("No access code when trying to cache the login view.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginViaAuthRequestCacheService.cacheLoginView(
|
||||
authRequestResponse.id,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
this.accessCode,
|
||||
);
|
||||
if (!this.authRequestKeyPair.privateKey) {
|
||||
this.logService.error("No private key when trying to cache the login view.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.accessCode) {
|
||||
this.logService.error("No access code when trying to cache the login view.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginViaAuthRequestCacheService.cacheLoginView(
|
||||
authRequestResponse.id,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
this.accessCode,
|
||||
);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { inject, Injectable, WritableSignal } from "@angular/core";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { inject, Injectable, WritableSignal } from "@angular/core";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { signal } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
|
||||
@@ -14,74 +13,40 @@ describe("LoginViaAuthRequestCache", () => {
|
||||
|
||||
const cacheSignal = signal<LoginViaAuthRequestView | null>(null);
|
||||
const getCacheSignal = jest.fn().mockReturnValue(cacheSignal);
|
||||
const getFeatureFlag = jest.fn().mockResolvedValue(false);
|
||||
const cacheSetMock = jest.spyOn(cacheSignal, "set");
|
||||
|
||||
beforeEach(() => {
|
||||
getCacheSignal.mockClear();
|
||||
getFeatureFlag.mockClear();
|
||||
cacheSetMock.mockClear();
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ViewCacheService, useValue: { signal: getCacheSignal } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||
LoginViaAuthRequestCacheService,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature enabled", () => {
|
||||
beforeEach(() => {
|
||||
getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
|
||||
cacheSignal.set({ ...buildMockState() });
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
|
||||
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
|
||||
cacheSignal.set({ ...buildMockState() });
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
await service.init();
|
||||
|
||||
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
|
||||
...buildMockState(),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the signal value", async () => {
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
await service.init();
|
||||
|
||||
const parameters = buildAuthenticMockAuthView();
|
||||
|
||||
service.cacheLoginView(parameters.id, parameters.privateKey, parameters.accessCode);
|
||||
|
||||
expect(cacheSignal.set).toHaveBeenCalledWith({
|
||||
id: parameters.id,
|
||||
privateKey: Utils.fromBufferToB64(parameters.privateKey),
|
||||
accessCode: parameters.accessCode,
|
||||
});
|
||||
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
|
||||
...buildMockState(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature disabled", () => {
|
||||
beforeEach(async () => {
|
||||
cacheSignal.set({ ...buildMockState() } as LoginViaAuthRequestView);
|
||||
getFeatureFlag.mockResolvedValue(false);
|
||||
cacheSetMock.mockClear();
|
||||
it("updates the signal value", async () => {
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
await service.init();
|
||||
});
|
||||
const parameters = buildAuthenticMockAuthView();
|
||||
|
||||
it("`getCachedCipherView` returns null", () => {
|
||||
expect(service.getCachedLoginViaAuthRequestView()).toBeNull();
|
||||
});
|
||||
service.cacheLoginView(parameters.id, parameters.privateKey, parameters.accessCode);
|
||||
|
||||
it("does not update the signal value", () => {
|
||||
const params = buildAuthenticMockAuthView();
|
||||
|
||||
service.cacheLoginView(params.id, params.privateKey, params.accessCode);
|
||||
|
||||
expect(cacheSignal.set).not.toHaveBeenCalled();
|
||||
expect(cacheSignal.set).toHaveBeenCalledWith({
|
||||
id: parameters.id,
|
||||
privateKey: Utils.fromBufferToB64(parameters.privateKey),
|
||||
accessCode: parameters.accessCode,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { inject, Injectable, WritableSignal } from "@angular/core";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
const LOGIN_VIA_AUTH_CACHE_KEY = "login-via-auth-request-form-cache";
|
||||
@@ -17,10 +15,6 @@ const LOGIN_VIA_AUTH_CACHE_KEY = "login-via-auth-request-form-cache";
|
||||
@Injectable()
|
||||
export class LoginViaAuthRequestCacheService {
|
||||
private viewCacheService: ViewCacheService = inject(ViewCacheService);
|
||||
private configService: ConfigService = inject(ConfigService);
|
||||
|
||||
/** True when the `PM9112_DeviceApproval` flag is enabled */
|
||||
private featureEnabled: boolean = false;
|
||||
|
||||
private defaultLoginViaAuthRequestCache: WritableSignal<LoginViaAuthRequestView | null> =
|
||||
this.viewCacheService.signal<LoginViaAuthRequestView | null>({
|
||||
@@ -31,23 +25,10 @@ export class LoginViaAuthRequestCacheService {
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Must be called once before interacting with the cached data, otherwise methods will be noop.
|
||||
*/
|
||||
async init() {
|
||||
this.featureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM9112_DeviceApprovalPersistence,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cache with the new LoginView.
|
||||
*/
|
||||
cacheLoginView(id: string, privateKey: Uint8Array, accessCode: string): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When the keys get stored they should be converted to a B64 string to ensure
|
||||
// data can be properly formed when json-ified. If not done, they are not stored properly and
|
||||
// will not be parsable by the cryptography library after coming out of storage.
|
||||
@@ -59,10 +40,6 @@ export class LoginViaAuthRequestCacheService {
|
||||
}
|
||||
|
||||
clearCacheLoginView(): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultLoginViaAuthRequestCache.set(null);
|
||||
}
|
||||
|
||||
@@ -70,10 +47,6 @@ export class LoginViaAuthRequestCacheService {
|
||||
* Returns the cached LoginViaAuthRequestView when available.
|
||||
*/
|
||||
getCachedLoginViaAuthRequestView(): LoginViaAuthRequestView | null {
|
||||
if (!this.featureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.defaultLoginViaAuthRequestCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
isAdminInitiated: false,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -61,6 +61,7 @@ export class OrganizationData {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
|
||||
constructor(
|
||||
response?: ProfileOrganizationResponse,
|
||||
@@ -124,6 +125,7 @@ export class OrganizationData {
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useRiskInsights = response.useRiskInsights;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
this.isAdminInitiated = response.isAdminInitiated;
|
||||
|
||||
this.isMember = options.isMember;
|
||||
this.isProviderUser = options.isProviderUser;
|
||||
|
||||
@@ -91,6 +91,7 @@ export class Organization {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
|
||||
constructor(obj?: OrganizationData) {
|
||||
if (obj == null) {
|
||||
@@ -150,6 +151,7 @@ export class Organization {
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useRiskInsights = obj.useRiskInsights;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
this.isAdminInitiated = obj.isAdminInitiated;
|
||||
}
|
||||
|
||||
get canAccess() {
|
||||
|
||||
@@ -56,6 +56,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -123,5 +124,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
|
||||
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ClearClipboardDelay = {
|
||||
FiveMinutes: 300,
|
||||
} as const;
|
||||
|
||||
/* Context Menu item Ids */
|
||||
/* Ids for context menu items and messaging events */
|
||||
export const AUTOFILL_CARD_ID = "autofill-card";
|
||||
export const AUTOFILL_ID = "autofill";
|
||||
export const SHOW_AUTOFILL_BUTTON = "show-autofill-button";
|
||||
@@ -54,6 +54,7 @@ export const GENERATE_PASSWORD_ID = "generate-password";
|
||||
export const NOOP_COMMAND_SUFFIX = "noop";
|
||||
export const ROOT_ID = "root";
|
||||
export const SEPARATOR_ID = "separator";
|
||||
export const UPDATE_PASSWORD = "update-password";
|
||||
|
||||
export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ export enum FeatureFlag {
|
||||
SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions",
|
||||
|
||||
/* Auth */
|
||||
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
|
||||
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
|
||||
|
||||
/* Autofill */
|
||||
@@ -56,7 +55,6 @@ export enum FeatureFlag {
|
||||
/* Vault */
|
||||
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
SecurityTasks = "security-tasks",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
|
||||
@@ -108,14 +106,12 @@ export const DefaultFeatureFlagValue = {
|
||||
/* Vault */
|
||||
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
|
||||
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
|
||||
[FeatureFlag.EndUserNotifications]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
|
||||
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
|
||||
@@ -49,6 +49,7 @@ export abstract class EncryptService {
|
||||
key: SymmetricCryptoKey,
|
||||
decryptTrace?: string,
|
||||
): Promise<Uint8Array | null>;
|
||||
|
||||
/**
|
||||
* @deprecated Replaced by BulkEncryptService, remove once the feature is tested and the featureflag PM-4154-multi-worker-encryption-service is removed
|
||||
* @param items The items to decrypt
|
||||
|
||||
@@ -209,7 +209,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
devices.data
|
||||
.filter((device) => device.isTrusted)
|
||||
.map(async (device) => {
|
||||
const publicKey = await this.encryptService.decryptToBytes(
|
||||
const publicKey = await this.encryptService.unwrapEncapsulationKey(
|
||||
new EncString(device.encryptedPublicKey),
|
||||
oldUserKey,
|
||||
);
|
||||
@@ -220,7 +220,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newEncryptedPublicKey = await this.encryptService.encrypt(publicKey, newUserKey);
|
||||
const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
publicKey,
|
||||
newUserKey,
|
||||
);
|
||||
const newEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
newUserKey,
|
||||
publicKey,
|
||||
@@ -278,7 +281,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(deviceIdentifier);
|
||||
|
||||
// Decrypt the existing device public key with the old user key
|
||||
const decryptedDevicePublicKey = await this.encryptService.decryptToBytes(
|
||||
const decryptedDevicePublicKey = await this.encryptService.unwrapEncapsulationKey(
|
||||
currentDeviceKeys.encryptedPublicKey,
|
||||
oldUserKey,
|
||||
);
|
||||
@@ -394,7 +397,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
|
||||
try {
|
||||
// attempt to decrypt encryptedDevicePrivateKey with device key
|
||||
const devicePrivateKey = await this.encryptService.decryptToBytes(
|
||||
const devicePrivateKey = await this.encryptService.unwrapDecapsulationKey(
|
||||
encryptedDevicePrivateKey,
|
||||
deviceKey,
|
||||
);
|
||||
|
||||
@@ -623,9 +623,9 @@ describe("deviceTrustService", () => {
|
||||
});
|
||||
|
||||
it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => {
|
||||
const decryptToBytesSpy = jest
|
||||
.spyOn(encryptService, "decryptToBytes")
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
const unwrapDecapsulationKeySpy = jest
|
||||
.spyOn(encryptService, "unwrapDecapsulationKey")
|
||||
.mockResolvedValue(new Uint8Array(2048));
|
||||
const rsaDecryptSpy = jest
|
||||
.spyOn(encryptService, "decapsulateKeyUnsigned")
|
||||
.mockResolvedValue(new SymmetricCryptoKey(new Uint8Array(userKeyBytesLength)));
|
||||
@@ -638,13 +638,13 @@ describe("deviceTrustService", () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockUserKey);
|
||||
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unwrapDecapsulationKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(rsaDecryptSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null and removes device key when the decryption fails", async () => {
|
||||
const decryptToBytesSpy = jest
|
||||
.spyOn(encryptService, "decryptToBytes")
|
||||
const unwrapDecapsulationKeySpy = jest
|
||||
.spyOn(encryptService, "unwrapDecapsulationKey")
|
||||
.mockRejectedValue(new Error("Decryption error"));
|
||||
const setDeviceKeySpy = jest.spyOn(deviceTrustService as any, "setDeviceKey");
|
||||
|
||||
@@ -656,7 +656,7 @@ describe("deviceTrustService", () => {
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unwrapDecapsulationKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(setDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(setDeviceKeySpy).toHaveBeenCalledWith(mockUserId, null);
|
||||
});
|
||||
@@ -704,8 +704,8 @@ describe("deviceTrustService", () => {
|
||||
DeviceResponse,
|
||||
),
|
||||
);
|
||||
encryptService.decryptToBytes.mockResolvedValue(null);
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.decryptBytes.mockResolvedValue(null);
|
||||
encryptService.encryptString.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(
|
||||
new EncString("test_encrypted_data"),
|
||||
);
|
||||
@@ -752,9 +752,11 @@ describe("deviceTrustService", () => {
|
||||
DeviceResponse,
|
||||
),
|
||||
);
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(64));
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.rsaEncrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.unwrapEncapsulationKey.mockResolvedValue(new Uint8Array(64));
|
||||
encryptService.wrapEncapsulationKey.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(
|
||||
new EncString("test_encrypted_data"),
|
||||
);
|
||||
|
||||
const protectedDeviceResponse = new ProtectedDeviceResponse({
|
||||
id: "",
|
||||
@@ -862,13 +864,15 @@ describe("deviceTrustService", () => {
|
||||
});
|
||||
|
||||
// Mock the decryption of the public key with the old user key
|
||||
encryptService.decryptToBytes.mockImplementationOnce((_encValue, privateKeyValue) => {
|
||||
expect(privateKeyValue.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(new Uint8Array(privateKeyValue.toEncoded())[0]).toBe(FakeOldUserKeyMarker);
|
||||
const data = new Uint8Array(250);
|
||||
data.fill(FakeDecryptedPublicKeyMarker, 0, 1);
|
||||
return Promise.resolve(data);
|
||||
});
|
||||
encryptService.unwrapEncapsulationKey.mockImplementationOnce(
|
||||
(_encValue, privateKeyValue) => {
|
||||
expect(privateKeyValue.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(new Uint8Array(privateKeyValue.toEncoded())[0]).toBe(FakeOldUserKeyMarker);
|
||||
const data = new Uint8Array(250);
|
||||
data.fill(FakeDecryptedPublicKeyMarker, 0, 1);
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock the encryption of the new user key with the decrypted public key
|
||||
encryptService.encapsulateKeyUnsigned.mockImplementationOnce((data, publicKey) => {
|
||||
|
||||
@@ -2,12 +2,16 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import * as rxjs from "rxjs";
|
||||
|
||||
import { makeSymmetricCryptoKey } from "../../../../spec";
|
||||
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { MasterKey } from "../../../types/key";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
|
||||
import { MasterPasswordService } from "./master-password.service";
|
||||
@@ -27,6 +31,14 @@ describe("MasterPasswordService", () => {
|
||||
update: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const testUserKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 1);
|
||||
const testMasterKey: MasterKey = makeSymmetricCryptoKey(32, 2);
|
||||
const testStretchedMasterKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 3);
|
||||
const testMasterKeyEncryptedKey =
|
||||
"0.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=";
|
||||
const testStretchedMasterKeyEncryptedKey =
|
||||
"2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY=";
|
||||
|
||||
beforeEach(() => {
|
||||
stateProvider = mock<StateProvider>();
|
||||
stateService = mock<StateService>();
|
||||
@@ -45,6 +57,9 @@ describe("MasterPasswordService", () => {
|
||||
encryptService,
|
||||
logService,
|
||||
);
|
||||
|
||||
encryptService.unwrapSymmetricKey.mockResolvedValue(makeSymmetricCryptoKey(64, 1));
|
||||
keyGenerationService.stretchKey.mockResolvedValue(makeSymmetricCryptoKey(64, 3));
|
||||
});
|
||||
|
||||
describe("setForceSetPasswordReason", () => {
|
||||
@@ -101,4 +116,41 @@ describe("MasterPasswordService", () => {
|
||||
expect(mockUserState.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("decryptUserKeyWithMasterKey", () => {
|
||||
it("decrypts a userkey wrapped in AES256-CBC", async () => {
|
||||
encryptService.unwrapSymmetricKey.mockResolvedValue(testUserKey);
|
||||
await sut.decryptUserKeyWithMasterKey(
|
||||
testMasterKey,
|
||||
userId,
|
||||
new EncString(testMasterKeyEncryptedKey),
|
||||
);
|
||||
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(
|
||||
new EncString(testMasterKeyEncryptedKey),
|
||||
testMasterKey,
|
||||
);
|
||||
});
|
||||
it("decrypts a userkey wrapped in AES256-CBC-HMAC", async () => {
|
||||
encryptService.unwrapSymmetricKey.mockResolvedValue(testUserKey);
|
||||
keyGenerationService.stretchKey.mockResolvedValue(testStretchedMasterKey);
|
||||
await sut.decryptUserKeyWithMasterKey(
|
||||
testMasterKey,
|
||||
userId,
|
||||
new EncString(testStretchedMasterKeyEncryptedKey),
|
||||
);
|
||||
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(
|
||||
new EncString(testStretchedMasterKeyEncryptedKey),
|
||||
testStretchedMasterKey,
|
||||
);
|
||||
expect(keyGenerationService.stretchKey).toHaveBeenCalledWith(testMasterKey);
|
||||
});
|
||||
it("returns null if failed to decrypt", async () => {
|
||||
encryptService.unwrapSymmetricKey.mockResolvedValue(null);
|
||||
const result = await sut.decryptUserKeyWithMasterKey(
|
||||
testMasterKey,
|
||||
userId,
|
||||
new EncString(testStretchedMasterKeyEncryptedKey),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,21 +174,13 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
throw new Error("No master key found.");
|
||||
}
|
||||
|
||||
let decUserKey: Uint8Array;
|
||||
let decUserKey: SymmetricCryptoKey;
|
||||
|
||||
if (userKey.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
decUserKey = await this.encryptService.decryptToBytes(
|
||||
userKey,
|
||||
masterKey,
|
||||
"Content: User Key; Encrypting Key: Master Key",
|
||||
);
|
||||
decUserKey = await this.encryptService.unwrapSymmetricKey(userKey, masterKey);
|
||||
} else if (userKey.encryptionType === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const newKey = await this.keyGenerationService.stretchKey(masterKey);
|
||||
decUserKey = await this.encryptService.decryptToBytes(
|
||||
userKey,
|
||||
newKey,
|
||||
"Content: User Key; Encrypting Key: Stretched Master Key",
|
||||
);
|
||||
decUserKey = await this.encryptService.unwrapSymmetricKey(userKey, newKey);
|
||||
} else {
|
||||
throw new Error("Unsupported encryption type.");
|
||||
}
|
||||
@@ -198,6 +190,6 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SymmetricCryptoKey(decUserKey) as UserKey;
|
||||
return decUserKey as UserKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { EncryptionType } from "../enums";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
import { KeyGenerationService } from "./key-generation.service";
|
||||
|
||||
@@ -98,4 +99,23 @@ describe("KeyGenerationService", () => {
|
||||
expect(key.inner().type).toEqual(EncryptionType.AesCbc256_B64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stretchKey", () => {
|
||||
it("should stretch a key", async () => {
|
||||
const key = new SymmetricCryptoKey(new Uint8Array(32));
|
||||
|
||||
cryptoFunctionService.hkdf.mockResolvedValue(new Uint8Array(64));
|
||||
|
||||
const stretchedKey = await sut.stretchKey(key);
|
||||
|
||||
expect(stretchedKey.inner().type).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
});
|
||||
it("should throw if key is not 32 bytes", async () => {
|
||||
const key = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
|
||||
await expect(sut.stretchKey(key)).rejects.toThrow(
|
||||
"Key passed into stretchKey is not a 256-bit key.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { MasterKey, PinKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfig, PBKDF2KdfConfig, Argon2KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "../abstractions/key-generation.service";
|
||||
import { EncryptionType } from "../enums";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
@@ -79,7 +79,13 @@ export class KeyGenerationService implements KeyGenerationServiceAbstraction {
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
|
||||
async stretchKey(key: MasterKey | PinKey): Promise<SymmetricCryptoKey> {
|
||||
async stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey> {
|
||||
// The key to be stretched is actually usually the output of a KDF, and not actually meant for AesCbc256_B64 encryption,
|
||||
// but has the same key length. Only 256-bit key materials should be stretched.
|
||||
if (key.inner().type != EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Key passed into stretchKey is not a 256-bit key.");
|
||||
}
|
||||
|
||||
const newKey = new Uint8Array(64);
|
||||
// Master key and pin key are always 32 bytes
|
||||
const encKey = await this.cryptoFunctionService.hkdfExpand(
|
||||
|
||||
203
libs/common/src/services/api.service.spec.ts
Normal file
203
libs/common/src/services/api.service.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
import { TokenService } from "../auth/abstractions/token.service";
|
||||
import { DeviceType } from "../enums";
|
||||
import { VaultTimeoutSettingsService } from "../key-management/vault-timeout";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
import { AppIdService } from "../platform/abstractions/app-id.service";
|
||||
import { Environment, EnvironmentService } from "../platform/abstractions/environment.service";
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
||||
|
||||
import { ApiService, HttpOperations } from "./api.service";
|
||||
|
||||
describe("ApiService", () => {
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let appIdService: MockProxy<AppIdService>;
|
||||
let refreshAccessTokenErrorCallback: jest.Mock<void, []>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let logoutCallback: jest.Mock<Promise<void>, [reason: LogoutReason]>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let httpOperations: MockProxy<HttpOperations>;
|
||||
|
||||
let sut: ApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenService = mock();
|
||||
platformUtilsService = mock();
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.ChromeExtension);
|
||||
|
||||
environmentService = mock();
|
||||
appIdService = mock();
|
||||
refreshAccessTokenErrorCallback = jest.fn();
|
||||
logService = mock();
|
||||
logoutCallback = jest.fn();
|
||||
vaultTimeoutSettingsService = mock();
|
||||
httpOperations = mock();
|
||||
|
||||
sut = new ApiService(
|
||||
tokenService,
|
||||
platformUtilsService,
|
||||
environmentService,
|
||||
appIdService,
|
||||
refreshAccessTokenErrorCallback,
|
||||
logService,
|
||||
logoutCallback,
|
||||
vaultTimeoutSettingsService,
|
||||
httpOperations,
|
||||
"custom-user-agent",
|
||||
);
|
||||
});
|
||||
|
||||
describe("send", () => {
|
||||
it("handles ok GET", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.mockResolvedValue("access_token");
|
||||
tokenService.tokenNeedsRefresh.mockResolvedValue(false);
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ hello: "world" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
const response = await sut.send("GET", "/something", null, true, true, null, null);
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
const request = nativeFetch.mock.calls[0][0];
|
||||
// This should get set for users of send
|
||||
expect(request.cache).toBe("no-store");
|
||||
// TODO: Could expect on the credentials parameter
|
||||
expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension
|
||||
// Custom user agent should get set
|
||||
expect(request.headers.get("User-Agent")).toBe("custom-user-agent");
|
||||
// This should be set when the caller has indicated there is a response
|
||||
expect(request.headers.get("Accept")).toBe("application/json");
|
||||
// If they have indicated that it's authed, then the authorization header should get set.
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer access_token");
|
||||
// The response body
|
||||
expect(response).toEqual({ hello: "world" });
|
||||
});
|
||||
});
|
||||
|
||||
const errorData: {
|
||||
name: string;
|
||||
input: Partial<Response>;
|
||||
error: Partial<ErrorResponse>;
|
||||
}[] = [
|
||||
{
|
||||
name: "json response in camel case",
|
||||
input: {
|
||||
json: () => Promise.resolve({ message: "Something bad happened." }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
},
|
||||
error: {
|
||||
message: "Something bad happened.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "json response in pascal case",
|
||||
input: {
|
||||
json: () => Promise.resolve({ Message: "Something bad happened." }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
},
|
||||
error: {
|
||||
message: "Something bad happened.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "json response with charset in content type",
|
||||
input: {
|
||||
json: () => Promise.resolve({ message: "Something bad happened." }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
}),
|
||||
},
|
||||
error: {
|
||||
message: "Something bad happened.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "text/plain response",
|
||||
input: {
|
||||
text: () => Promise.resolve("Something bad happened."),
|
||||
headers: new Headers({
|
||||
"content-type": "text/plain",
|
||||
}),
|
||||
},
|
||||
error: {
|
||||
message: "Something bad happened.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(errorData)(
|
||||
"throws error-like response when not ok response with $name",
|
||||
async ({ input, error }) => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
...input,
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null, null),
|
||||
).rejects.toMatchObject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -139,6 +139,10 @@ import { AttachmentResponse } from "../vault/models/response/attachment.response
|
||||
import { CipherResponse } from "../vault/models/response/cipher.response";
|
||||
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
|
||||
|
||||
export type HttpOperations = {
|
||||
createRequest: (url: string, request: RequestInit) => Request;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated The `ApiService` class is deprecated and calls should be extracted into individual
|
||||
* api services. The `send` method is still allowed to be used within api services. For background
|
||||
@@ -166,6 +170,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
private logService: LogService,
|
||||
private logoutCallback: (logoutReason: LogoutReason) => Promise<void>,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private readonly httpOperations: HttpOperations,
|
||||
private customUserAgent: string = null,
|
||||
) {
|
||||
this.device = platformUtilsService.getDevice();
|
||||
@@ -217,7 +222,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
|
||||
const response = await this.fetch(
|
||||
new Request(env.getIdentityUrl() + "/connect/token", {
|
||||
this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", {
|
||||
body: this.qsStringify(identityToken),
|
||||
credentials: await this.getCredentials(),
|
||||
cache: "no-store",
|
||||
@@ -1409,7 +1414,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const response = await this.fetch(
|
||||
new Request(env.getEventsUrl() + "/collect", {
|
||||
this.httpOperations.createRequest(env.getEventsUrl() + "/collect", {
|
||||
cache: "no-store",
|
||||
credentials: await this.getCredentials(),
|
||||
method: "POST",
|
||||
@@ -1456,7 +1461,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const authHeader = await this.getActiveBearerToken();
|
||||
|
||||
const response = await this.fetch(
|
||||
new Request(keyConnectorUrl + "/user-keys", {
|
||||
this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", {
|
||||
cache: "no-store",
|
||||
method: "GET",
|
||||
headers: new Headers({
|
||||
@@ -1481,7 +1486,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const authHeader = await this.getActiveBearerToken();
|
||||
|
||||
const response = await this.fetch(
|
||||
new Request(keyConnectorUrl + "/user-keys", {
|
||||
this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", {
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
@@ -1501,7 +1506,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
async getKeyConnectorAlive(keyConnectorUrl: string) {
|
||||
const response = await this.fetch(
|
||||
new Request(keyConnectorUrl + "/alive", {
|
||||
this.httpOperations.createRequest(keyConnectorUrl + "/alive", {
|
||||
cache: "no-store",
|
||||
method: "GET",
|
||||
headers: new Headers({
|
||||
@@ -1570,7 +1575,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const path = `/sso/prevalidate?domainHint=${encodeURIComponent(identifier)}`;
|
||||
const response = await this.fetch(
|
||||
new Request(env.getIdentityUrl() + path, {
|
||||
this.httpOperations.createRequest(env.getIdentityUrl() + path, {
|
||||
cache: "no-store",
|
||||
credentials: await this.getCredentials(),
|
||||
headers: headers,
|
||||
@@ -1711,7 +1716,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const decodedToken = await this.tokenService.decodeAccessToken();
|
||||
const response = await this.fetch(
|
||||
new Request(env.getIdentityUrl() + "/connect/token", {
|
||||
this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", {
|
||||
body: this.qsStringify({
|
||||
grant_type: "refresh_token",
|
||||
client_id: decodedToken.client_id,
|
||||
@@ -1820,7 +1825,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
};
|
||||
requestInit.headers = requestHeaders;
|
||||
requestInit.body = requestBody;
|
||||
const response = await this.fetch(new Request(requestUrl, requestInit));
|
||||
const response = await this.fetch(this.httpOperations.createRequest(requestUrl, requestInit));
|
||||
|
||||
const responseType = response.headers.get("content-type");
|
||||
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
|
||||
@@ -1889,7 +1894,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
let responseJson: any = null;
|
||||
if (this.isJsonResponse(response)) {
|
||||
responseJson = await response.json();
|
||||
} else if (this.isTextResponse(response)) {
|
||||
} else if (this.isTextPlainResponse(response)) {
|
||||
responseJson = { Message: await response.text() };
|
||||
}
|
||||
|
||||
@@ -1945,8 +1950,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return typeHeader != null && typeHeader.indexOf("application/json") > -1;
|
||||
}
|
||||
|
||||
private isTextResponse(response: Response): boolean {
|
||||
private isTextPlainResponse(response: Response): boolean {
|
||||
const typeHeader = response.headers.get("content-type");
|
||||
return typeHeader != null && typeHeader.indexOf("text") > -1;
|
||||
return typeHeader != null && typeHeader.indexOf("text/plain") > -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,8 +519,15 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch: UriMatchStrategySetting = null,
|
||||
): Promise<CipherView[]> {
|
||||
const ciphers = await this.getAllDecrypted(userId);
|
||||
return await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch);
|
||||
return await firstValueFrom(
|
||||
this.cipherViews$(userId).pipe(
|
||||
filter((c) => c != null),
|
||||
switchMap(
|
||||
async (ciphers) =>
|
||||
await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async filterCiphersForUrl(
|
||||
|
||||
@@ -123,7 +123,7 @@ export class AvatarComponent implements OnChanges {
|
||||
textTag.setAttribute("fill", Utils.pickTextColorBasedOnBgColor(color, 135, true));
|
||||
textTag.setAttribute(
|
||||
"font-family",
|
||||
'"DM Sans","Helvetica Neue",Helvetica,Arial,' +
|
||||
'Roboto,"Helvetica Neue",Helvetica,Arial,' +
|
||||
'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"',
|
||||
);
|
||||
// Warning do not use innerHTML here, characters are user provided
|
||||
|
||||
@@ -21,7 +21,7 @@ $body-bg: $white;
|
||||
$body-color: #333333;
|
||||
|
||||
$font-family-sans-serif:
|
||||
"DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
|
||||
$h1-font-size: 1.7rem;
|
||||
|
||||
@@ -330,7 +330,7 @@ describe("keyService", () => {
|
||||
everHadUserKeyState.nextState(null);
|
||||
|
||||
// Mock private key decryption
|
||||
encryptService.decryptToBytes.mockResolvedValue(mockRandomBytes);
|
||||
encryptService.unwrapDecapsulationKey.mockResolvedValue(mockRandomBytes);
|
||||
});
|
||||
|
||||
it("throws if userKey is null", async () => {
|
||||
@@ -352,7 +352,7 @@ describe("keyService", () => {
|
||||
});
|
||||
|
||||
it("throws if encPrivateKey cannot be decrypted with the userKey", async () => {
|
||||
encryptService.decryptToBytes.mockResolvedValue(null);
|
||||
encryptService.unwrapDecapsulationKey.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
keyService.setUserKeys(mockUserKey, mockEncPrivateKey, mockUserId),
|
||||
@@ -452,17 +452,16 @@ describe("keyService", () => {
|
||||
|
||||
// Decryption of the user private key
|
||||
const fakeDecryptedUserPrivateKey = makeStaticByteArray(10, 1);
|
||||
encryptService.decryptToBytes.mockResolvedValue(fakeDecryptedUserPrivateKey);
|
||||
encryptService.unwrapDecapsulationKey.mockResolvedValue(fakeDecryptedUserPrivateKey);
|
||||
|
||||
const fakeUserPublicKey = makeStaticByteArray(10, 2);
|
||||
cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(fakeUserPublicKey);
|
||||
|
||||
const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(
|
||||
expect(encryptService.unwrapDecapsulationKey).toHaveBeenCalledWith(
|
||||
fakeEncryptedUserPrivateKey,
|
||||
userKey,
|
||||
"Content: Encrypted Private Key",
|
||||
);
|
||||
|
||||
expect(userPrivateKey).toBe(fakeDecryptedUserPrivateKey);
|
||||
@@ -473,7 +472,7 @@ describe("keyService", () => {
|
||||
|
||||
const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
expect(encryptService.decryptToBytes).not.toHaveBeenCalled();
|
||||
expect(encryptService.unwrapDecapsulationKey).not.toHaveBeenCalled();
|
||||
|
||||
expect(userPrivateKey).toBeFalsy();
|
||||
});
|
||||
@@ -552,10 +551,12 @@ describe("keyService", () => {
|
||||
providerKeysState.nextState(keys.providerKeys!);
|
||||
}
|
||||
|
||||
encryptService.decryptToBytes.mockImplementation((encryptedPrivateKey, userKey) => {
|
||||
// TOOD: Branch between provider and private key?
|
||||
encryptService.unwrapDecapsulationKey.mockImplementation((encryptedPrivateKey, userKey) => {
|
||||
return Promise.resolve(fakePrivateKeyDecryption(encryptedPrivateKey, userKey));
|
||||
});
|
||||
encryptService.unwrapSymmetricKey.mockImplementation((encryptedPrivateKey, userKey) => {
|
||||
return Promise.resolve(new SymmetricCryptoKey(new Uint8Array(64)));
|
||||
});
|
||||
|
||||
encryptService.decapsulateKeyUnsigned.mockImplementation((data, privateKey) => {
|
||||
return Promise.resolve(new SymmetricCryptoKey(fakeOrgKeyDecryption(data, privateKey)));
|
||||
@@ -617,6 +618,7 @@ describe("keyService", () => {
|
||||
});
|
||||
|
||||
it("returns decryption keys when some of the org keys are providers", async () => {
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(64));
|
||||
const org2Id = "org2Id" as OrganizationId;
|
||||
updateKeys({
|
||||
userKey: makeSymmetricCryptoKey<UserKey>(64),
|
||||
@@ -647,7 +649,7 @@ describe("keyService", () => {
|
||||
|
||||
const org2Key = decryptionKeys!.orgKeys![org2Id];
|
||||
expect(org2Key).not.toBeNull();
|
||||
expect(org2Key.keyB64).toContain("provider1Key");
|
||||
expect(org2Key.toEncoded()).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("returns a stream that pays attention to updates of all data", async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user