1
0
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:
Bernd Schoolmann
2025-05-13 15:04:43 +02:00
committed by GitHub
121 changed files with 3735 additions and 1115 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,7 @@ const buttonRowStyles = css`
> button {
max-width: min-content;
flex: 1 1 50%;
flex: 1 1 25%;
}
> div {

View File

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

View File

@@ -15,6 +15,7 @@ export type NotificationsExtensionMessage = {
typeData?: NotificationTypeData;
height?: number;
error?: string;
closedByUser?: boolean;
fadeOutNotification?: boolean;
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ export class NodeApiService extends ApiService {
logService,
logoutCallback,
vaultTimeoutSettingsService,
{ createRequest: (url, request) => new Request(url, request) },
customUserAgent,
);
}

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "22.14.1",
"@types/node": "22.15.3",
"typescript": "5.4.2"
},
"_moduleAliases": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { ViewCacheService, FormCacheOptions, SignalCacheOptions } from "./view-cache.service";

View File

@@ -0,0 +1 @@
export { NoopViewCacheService } from "./noop-view-cache.service";

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,7 @@ describe("ORGANIZATIONS state", () => {
userIsManagedByOrganization: false,
useRiskInsights: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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