1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 13:10:17 +00:00

Merge branch 'main' into clps-remove-angular-dep

This commit is contained in:
Miles Blackwood
2025-06-12 15:21:20 -04:00
80 changed files with 1098 additions and 478 deletions

View File

@@ -41,6 +41,8 @@ defaults:
run:
shell: bash
permissions: {}
jobs:
setup:
name: Setup

View File

@@ -46,6 +46,9 @@ defaults:
run:
working-directory: apps/cli
permissions:
contents: read
jobs:
setup:
name: Setup
@@ -168,13 +171,6 @@ jobs:
exit 1
fi
- name: Create checksums Unix
run: |
cd ./dist
shasum -a 256 bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip \
| awk '{split($0, a); print a[1]}' > bw${{
matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-sha256-${{ env._PACKAGE_VERSION }}.txt
- name: Upload unix zip asset
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
@@ -182,13 +178,6 @@ jobs:
path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip
if-no-files-found: error
- name: Upload unix checksum asset
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-sha256-${{ env._PACKAGE_VERSION }}.txt
path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
# We want to confirm the CLI is runnable using the dependencies defined in `apps/cli/package.json`.
- name: Remove node_modules (root)
run: rm -rf node_modules
@@ -379,11 +368,6 @@ jobs:
Throw "Version test failed."
}
- name: Create checksums Windows
run: |
checksum -f="./dist/bw${{ matrix.license_type.artifact_prefix }}-windows-${env:_PACKAGE_VERSION}.zip" `
-t sha256 | Out-File -Encoding ASCII ./dist/bw${{ matrix.license_type.artifact_prefix }}-windows-sha256-${env:_PACKAGE_VERSION}.txt
- name: Upload windows zip asset
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
@@ -391,13 +375,6 @@ jobs:
path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip
if-no-files-found: error
- name: Upload windows checksum asset
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bw${{ matrix.license_type.artifact_prefix }}-windows-sha256-${{ env._PACKAGE_VERSION }}.txt
path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-windows-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
- name: Upload Chocolatey asset
if: matrix.license_type.build_prefix == 'bit'
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
@@ -457,13 +434,6 @@ jobs:
with:
path: apps/cli/dist/snap
- name: Create checksum
run: |
cd dist/snap
ls -alth
sha256sum bw_${{ env._PACKAGE_VERSION }}_amd64.snap \
| awk '{split($0, a); print a[1]}' > bw-snap-sha256-${{ env._PACKAGE_VERSION }}.txt
- name: Install Snap
run: sudo snap install dist/snap/bw*.snap --dangerous
@@ -488,13 +458,6 @@ jobs:
path: apps/cli/dist/snap/bw_${{ env._PACKAGE_VERSION }}_amd64.snap
if-no-files-found: error
- name: Upload snap checksum asset
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bw-snap-sha256-${{ env._PACKAGE_VERSION }}.txt
path: apps/cli/dist/snap/bw-snap-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
check-failures:
name: Check for failures

View File

@@ -46,6 +46,9 @@ defaults:
run:
shell: bash
permissions:
contents: read
jobs:
electron-verify:
name: Verify Electron Version
@@ -425,7 +428,7 @@ jobs:
- name: Install AST
run: dotnet tool install --global AzureSignTool --version 4.0.1
- name: Set up environmentF
- name: Set up environment
run: choco install checksum --no-progress
- name: Print environment

View File

@@ -51,6 +51,8 @@ env:
_AZ_REGISTRY: bitwardenprod.azurecr.io
_GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }}
permissions: {}
jobs:
setup:
name: Setup

View File

@@ -18,6 +18,9 @@ defaults:
run:
working-directory: apps/cli
permissions:
contents: read
jobs:
setup:
name: Setup
@@ -78,24 +81,15 @@ jobs:
PKG_VERSION: ${{ needs.setup.outputs.release_version }}
with:
artifacts: "apps/cli/bw-oss-windows-${{ env.PKG_VERSION }}.zip,
apps/cli/bw-oss-windows-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bw-windows-${{ env.PKG_VERSION }}.zip,
apps/cli/bw-windows-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bw-oss-macos-${{ env.PKG_VERSION }}.zip,
apps/cli/bw-oss-macos-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bw-oss-macos-arm64-${{ env.PKG_VERSION }}.zip,
apps/cli/bw-oss-macos-arm64-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bw-macos-${{ env.PKG_VERSION }}.zip,
apps/cli/bw-macos-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bw-macos-arm64-${{ env.PKG_VERSION }}.zip,
apps/cli/bw-macos-arm64-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bw-oss-linux-${{ env.PKG_VERSION }}.zip,
apps/cli/bw-oss-linux-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bw-linux-${{ env.PKG_VERSION }}.zip,
apps/cli/bw-linux-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bitwarden-cli.${{ env.PKG_VERSION }}.nupkg,
apps/cli/bw_${{ env.PKG_VERSION }}_amd64.snap,
apps/cli/bw-snap-sha256-${{ env.PKG_VERSION }}.txt,
apps/cli/bitwarden-cli-${{ env.PKG_VERSION }}-npm-build.zip"
commit: ${{ github.sha }}
tag: cli-v${{ env.PKG_VERSION }}

View File

@@ -17,6 +17,9 @@ defaults:
run:
shell: bash
permissions:
contents: read
jobs:
setup:
name: Setup
@@ -89,12 +92,6 @@ jobs:
working-directory: apps/desktop/artifacts
run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive
- name: Get checksum files
uses: bitwarden/gh-actions/get-checksum@main
with:
packages_dir: "apps/desktop/artifacts"
file_path: "apps/desktop/artifacts/sha256-checksums.txt"
- name: Create Release
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
if: ${{ steps.release_channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }}
@@ -125,8 +122,7 @@ jobs:
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive,
apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}.yml,
apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-linux.yml,
apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-mac.yml,
apps/desktop/artifacts/sha256-checksums.txt"
apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-mac.yml"
commit: ${{ github.sha }}
tag: desktop-v${{ env.PKG_VERSION }}
name: Desktop v${{ env.PKG_VERSION }}

View File

@@ -10,6 +10,8 @@ on:
pull_request:
types: [ opened, synchronize ]
permissions: {}
jobs:
testing:
@@ -134,7 +136,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install rust
uses: dtolnay/rust-toolchain@c5a29ddb4d9d194e7c84ec8c3fba61b1c31fee8c # stable
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
components: llvm-tools

View File

@@ -474,12 +474,14 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
this.form.controls.pin.setValue(userHasPinSet, { emitEvent: false });
const requireReprompt = (await this.pinService.getPinLockType(userId)) == "EPHEMERAL";
this.form.controls.pinLockWithMasterPassword.setValue(requireReprompt, { emitEvent: false });
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("unlockPinSet"),
});
await this.vaultNudgesService.dismissNudge(NudgeType.AccountSecurity, userId);
if (userHasPinSet) {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("unlockPinSet"),
});
await this.vaultNudgesService.dismissNudge(NudgeType.AccountSecurity, userId);
}
} else {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.vaultTimeoutSettingsService.clear(userId);

View File

@@ -4,10 +4,12 @@ import { html, TemplateResult } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { border, themes, typography, spacing } from "../constants/styles";
import { Spinner } from "../icons";
export type ActionButtonProps = {
buttonText: string | TemplateResult;
disabled?: boolean;
isLoading?: boolean;
theme: Theme;
handleClick: (e: Event) => void;
fullWidth?: boolean;
@@ -16,40 +18,46 @@ export type ActionButtonProps = {
export function ActionButton({
buttonText,
disabled = false,
isLoading = false,
theme,
handleClick,
fullWidth = true,
}: ActionButtonProps) {
const handleButtonClick = (event: Event) => {
if (!disabled) {
if (!disabled && !isLoading) {
handleClick(event);
}
};
return html`
<button
class=${actionButtonStyles({ disabled, theme, fullWidth })}
class=${actionButtonStyles({ disabled, fullWidth, isLoading, theme })}
title=${buttonText}
type="button"
@click=${handleButtonClick}
>
${buttonText}
${isLoading ? Spinner({ theme, color: themes[theme].text.muted }) : buttonText}
</button>
`;
}
const actionButtonStyles = ({
disabled,
theme,
fullWidth,
isLoading,
theme,
}: {
disabled: boolean;
theme: Theme;
fullWidth: boolean;
isLoading: boolean;
theme: Theme;
}) => css`
${typography.body2}
user-select: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: ${border.radius.full};
padding: ${spacing["1"]} ${spacing["3"]};
@@ -59,7 +67,7 @@ const actionButtonStyles = ({
text-overflow: ellipsis;
font-weight: 700;
${disabled
${disabled || isLoading
? `
background-color: ${themes[theme].secondary["300"]};
color: ${themes[theme].text.muted};
@@ -81,7 +89,8 @@ const actionButtonStyles = ({
`}
svg {
width: fit-content;
padding: 2px 0; /* Match line-height of button body2 typography */
width: auto;
height: 16px;
}
`;

View File

@@ -174,6 +174,17 @@ export const buildIconColorRule = (color: string, rule: RuleName = ruleNames.fil
${rule}: ${color};
`;
export const animations = {
spin: `
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
`,
};
export function scrollbarStyles(theme: Theme, color?: { thumb?: string; track?: string }) {
const thumbColor = color?.thumb || themes[theme].secondary["500"];
const trackColor = color?.track || themes[theme].background.alt;

View File

@@ -11,4 +11,5 @@ export { Folder } from "./folder";
export { Globe } from "./globe";
export { PencilSquare } from "./pencil-square";
export { Shield } from "./shield";
export { Spinner } from "./spinner";
export { User } from "./user";

View File

@@ -0,0 +1,34 @@
import { css, keyframes } from "@emotion/css";
import { html } from "lit";
import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes, animations } from "../constants/styles";
export function Spinner({
ariaHidden = true,
color,
disabled,
theme,
disableSpin = false,
}: IconProps & { disableSpin?: boolean }) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg
class=${disableSpin ? "" : animation}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
aria-hidden="${ariaHidden}"
>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M9.5 1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM14.5 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3ZM11.536 11.536a1.5 1.5 0 1 1 2.12 2.12 1.5 1.5 0 0 1-2.12-2.12ZM9.5 14.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM0 8a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0ZM4.464 13.657a1.5 1.5 0 1 1-2.12-2.121 1.5 1.5 0 0 1 2.12 2.12ZM2.343 2.343a1.5 1.5 0 1 1 2.121 2.121 1.5 1.5 0 0 1-2.12-2.12Z"
/>
</svg>
`;
}
const animation = css`
animation: ${keyframes(animations.spin)} 2s infinite linear;
`;

View File

@@ -1,9 +1,12 @@
import { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
import { ActionButton, ActionButtonProps } from "../../buttons/action-button";
type ComponentAndControls = ActionButtonProps & { width: number };
export default {
title: "Components/Buttons/Action Button",
argTypes: {
@@ -11,12 +14,15 @@ export default {
disabled: { control: "boolean" },
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
handleClick: { control: false },
width: { control: "number", min: 10, max: 100, step: 1 },
},
args: {
buttonText: "Click Me",
disabled: false,
isLoading: false,
theme: ThemeTypes.Light,
handleClick: () => alert("Clicked"),
width: 150,
},
parameters: {
design: {
@@ -24,10 +30,18 @@ export default {
url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=487-14755&t=2O7uCAkwRZCcjumm-4",
},
},
} as Meta<ActionButtonProps>;
} as Meta<ComponentAndControls>;
const Template = (args: ActionButtonProps) => ActionButton({ ...args });
const Template = (args: ComponentAndControls) => {
const { width, ...componentProps } = args;
return html`<div style="width: ${width}px;">${ActionButton({ ...componentProps })}</div>`;
};
export const Default: StoryObj<ComponentAndControls> = {
args: {
isLoading: true,
theme: "dark",
},
export const Default: StoryObj<ActionButtonProps> = {
render: Template,
};

View File

@@ -6,9 +6,10 @@ import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
import { IconProps } from "../../common-types";
import * as Icons from "../../icons";
const { Spinner, ...StaticIcons } = Icons;
type Args = IconProps & {
size: number;
iconLink: URL;
};
export default {
@@ -26,7 +27,10 @@ export default {
},
} as Meta<Args>;
const Template = (args: Args, IconComponent: (props: IconProps) => ReturnType<typeof html>) => html`
const Template = (
args: Args,
IconComponent: (props: IconProps & { disableSpin?: boolean }) => ReturnType<typeof html>,
) => html`
<div
style="width: ${args.size}px; height: ${args.size}px; display: flex; align-items: center; justify-content: center;"
>
@@ -34,18 +38,26 @@ const Template = (args: Args, IconComponent: (props: IconProps) => ReturnType<ty
</div>
`;
const createIconStory = (iconName: keyof typeof Icons): StoryObj<Args> => {
const createIconStory = (
iconName: keyof typeof StaticIcons,
): StoryObj<Args & { disableSpin?: boolean }> => {
const story = {
render: (args) => Template(args, Icons[iconName]),
render: (args) => Template(args, StaticIcons[iconName]),
} as StoryObj<Args>;
story.argTypes = {
iconLink: { table: { disable: true } },
};
return story;
};
const SpinnerIconStory: StoryObj<Args & { disableSpin: boolean }> = {
render: (args) => Template(args, Spinner),
argTypes: {
disableSpin: { control: "boolean" },
},
args: {
disableSpin: false,
},
};
export const AngleDownIcon = createIconStory("AngleDown");
export const AngleUpIcon = createIconStory("AngleUp");
export const BusinessIcon = createIconStory("Business");
@@ -58,4 +70,5 @@ export const FolderIcon = createIconStory("Folder");
export const GlobeIcon = createIconStory("Globe");
export const PencilSquareIcon = createIconStory("PencilSquare");
export const ShieldIcon = createIconStory("Shield");
export const SpinnerIcon = SpinnerIconStory;
export const UserIcon = createIconStory("User");

View File

@@ -34,6 +34,7 @@ export type NotificationButtonRowProps = {
organizations?: OrgView[];
primaryButton: {
text: string;
isLoading?: boolean;
handlePrimaryButtonClick: (args: any) => void;
};
personalVaultIsAllowed: boolean;

View File

@@ -29,6 +29,7 @@ export type NotificationContainerProps = NotificationBarIframeInitData & {
folders?: FolderView[];
headerMessage?: string;
i18n: I18n;
isLoading?: boolean;
organizations?: OrgView[];
personalVaultIsAllowed?: boolean;
notificationTestId: string;
@@ -44,6 +45,7 @@ export function NotificationContainer({
folders,
headerMessage,
i18n,
isLoading,
organizations,
personalVaultIsAllowed = true,
notificationTestId,
@@ -74,6 +76,7 @@ export function NotificationContainer({
collections,
folders,
i18n,
isLoading,
notificationType: type,
organizations,
personalVaultIsAllowed,

View File

@@ -16,6 +16,7 @@ export type NotificationFooterProps = {
collections?: CollectionView[];
folders?: FolderView[];
i18n: I18n;
isLoading?: boolean;
notificationType?: NotificationType;
organizations?: OrgView[];
personalVaultIsAllowed: boolean;
@@ -27,6 +28,7 @@ export function NotificationFooter({
collections,
folders,
i18n,
isLoading,
notificationType,
organizations,
personalVaultIsAllowed,
@@ -52,6 +54,7 @@ export function NotificationFooter({
i18n,
primaryButton: {
handlePrimaryButtonClick: handleSaveAction,
isLoading,
text: primaryButtonText,
},
personalVaultIsAllowed,

View File

@@ -12,6 +12,7 @@ export type ButtonRowProps = {
theme: Theme;
primaryButton: {
text: string;
isLoading?: boolean;
handlePrimaryButtonClick: (args: any) => void;
};
selectButtons?: {
@@ -29,6 +30,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp
${ActionButton({
handleClick: primaryButton.handlePrimaryButtonClick,
buttonText: primaryButton.text,
isLoading: primaryButton.isLoading,
theme,
})}
<div class=${optionSelectionsStyles}>

View File

@@ -249,25 +249,34 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
if (isVaultLocked) {
return render(
NotificationContainer({
...notificationBarIframeInitData,
headerMessage,
type: resolvedType,
notificationTestId,
theme: resolvedTheme,
personalVaultIsAllowed: !personalVaultDisallowed,
handleCloseNotification,
handleSaveAction: (e) => {
sendSaveCipherMessage(true);
const notificationConfig = {
...notificationBarIframeInitData,
headerMessage,
type: resolvedType,
notificationTestId,
theme: resolvedTheme,
personalVaultIsAllowed: !personalVaultDisallowed,
handleCloseNotification,
handleEditOrUpdateAction,
i18n,
};
// @TODO can't close before vault has finished decrypting, but can't leave open during long decrypt because it looks like the experience has failed
},
handleEditOrUpdateAction,
i18n,
}),
document.body,
);
const handleSaveAction = () => {
sendSaveCipherMessage(true);
render(
NotificationContainer({
...notificationConfig,
handleSaveAction: () => {},
isLoading: true,
}),
document.body,
);
};
const UnlockNotification = NotificationContainer({ ...notificationConfig, handleSaveAction });
return render(UnlockNotification, document.body);
}
// Handle AtRiskPasswordNotification render

View File

@@ -23,14 +23,14 @@ export class IpcBackgroundService extends IpcService {
await SdkLoadService.Ready;
this.communicationBackend = new IpcCommunicationBackend({
async send(message: OutgoingMessage): Promise<void> {
if (typeof message.destination === "object") {
if (typeof message.destination === "object" && message.destination.Web != undefined) {
await BrowserApi.tabSendMessage(
{ id: message.destination.Web.id } as chrome.tabs.Tab,
{
type: "bitwarden-ipc-message",
message: {
destination: message.destination,
payload: message.payload,
payload: [...message.payload],
topic: message.topic,
},
} satisfies IpcMessage,
@@ -44,7 +44,7 @@ export class IpcBackgroundService extends IpcService {
});
BrowserApi.messageListener("platform.ipc", (message, sender) => {
if (!isIpcMessage(message)) {
if (!isIpcMessage(message) || message.message.destination !== "BrowserBackground") {
return;
}
@@ -53,10 +53,14 @@ export class IpcBackgroundService extends IpcService {
return;
}
this.communicationBackend?.deliver_message(
new IncomingMessage(message.message.payload, message.message.destination, {
Web: { id: sender.tab.id },
}),
this.communicationBackend?.receive(
new IncomingMessage(
new Uint8Array(message.message.payload),
message.message.destination,
{
Web: { id: sender.tab.id },
},
),
);
});

View File

@@ -62,6 +62,6 @@ windows = "=0.61.1"
windows-core = "=0.61.0"
windows-future = "=0.2.0"
windows-registry = "=0.5.1"
zbus = "=4.4.0"
zbus_polkit = "=4.0.0"
zbus = "=5.5.0"
zbus_polkit = "=5.0.0"
zeroizing-alloc = "=0.1.0"

View File

@@ -1,6 +1,8 @@
use std::borrow::Cow;
use zbus::{export::futures_util::TryStreamExt, Connection, MatchRule};
use futures::TryStreamExt;
use zbus::{Connection, MatchRule};
struct ScreenLock {
interface: Cow<'static, str>,
path: Cow<'static, str>,
@@ -23,7 +25,7 @@ pub async fn on_lock(tx: tokio::sync::mpsc::Sender<()>) -> Result<(), Box<dyn st
let proxy = zbus::fdo::DBusProxy::new(&connection).await?;
for monitor in SCREEN_LOCK_MONITORS.iter() {
let match_rule = MatchRule::builder()
.msg_type(zbus::MessageType::Signal)
.msg_type(zbus::message::Type::Signal)
.interface(monitor.interface.clone())?
.member("ActiveChanged")?
.build();

View File

@@ -12,6 +12,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { DialogService, ToastService } from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component";
import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
@@ -51,6 +52,7 @@ export class VaultFilterComponent
protected dialogService: DialogService,
protected configService: ConfigService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
) {
super(
vaultFilterService,
@@ -62,6 +64,7 @@ export class VaultFilterComponent
dialogService,
configService,
accountService,
restrictedItemTypesService,
);
}

View File

@@ -7,7 +7,7 @@ import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class RestrictedItemTypesPolicy extends BasePolicy {
name = "restrictedItemTypesPolicy";
description = "restrictedItemTypesPolicyDesc";
type = PolicyType.RestrictedItemTypesPolicy;
type = PolicyType.RestrictedItemTypes;
component = RestrictedItemTypesPolicyComponent;
}

View File

@@ -56,20 +56,21 @@
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #appListDropdown>
<button
type="button"
bitMenuItem
[attr.aria-label]="'resendEmailLabel' | i18n"
*ngIf="!isSelfHosted && !sponsoredFamily.validUntil"
(click)="resendEmail(sponsoredFamily)"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
@if (!isSelfHosted && !sponsoredFamily.validUntil) {
<button
type="button"
bitMenuItem
[attr.aria-label]="'resendEmailLabel' | i18n"
(click)="resendEmail(sponsoredFamily)"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
}
<ng-container *ngIf="!isSelfHosted && !sponsoredFamily.validUntil">
@if (!isSelfHosted && !sponsoredFamily.validUntil) {
<hr class="tw-m-0" />
</ng-container>
}
<button
type="button"
@@ -78,7 +79,7 @@
(click)="removeSponsorship(sponsoredFamily)"
>
<i aria-hidden="true" class="bwi bwi-close tw-text-danger"></i>
<span class="tw-text-danger pl-1">{{ "remove" | i18n }}</span>
<span class="tw-text-danger tw-pl-1">{{ "remove" | i18n }}</span>
</button>
</bit-menu>
</td>
@@ -87,7 +88,7 @@
}
</ng-template>
</bit-table>
<hr class="mt-0" />
<hr class="tw-mt-0" />
</ng-container>
} @else if (!loading()) {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">

View File

@@ -27,7 +27,7 @@ export class WebIpcService extends IpcService {
type: "bitwarden-ipc-message",
message: {
destination: message.destination,
payload: message.payload,
payload: [...message.payload],
topic: message.topic,
},
} satisfies IpcMessage,
@@ -50,9 +50,16 @@ export class WebIpcService extends IpcService {
return;
}
this.communicationBackend?.deliver_message(
if (
typeof message.message.destination !== "object" ||
message.message.destination.Web == undefined
) {
return;
}
this.communicationBackend?.receive(
new IncomingMessage(
message.message.payload,
new Uint8Array(message.message.payload),
message.message.destination,
"BrowserBackground",
message.message.topic,

View File

@@ -26,6 +26,7 @@ import { UpdatePasswordComponent } from "../auth/update-password.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component";
import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component";
import { FreeBitwardenFamiliesComponent } from "../billing/members/free-bitwarden-families.component";
import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component";
import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component";
// eslint-disable-next-line no-restricted-imports -- Temporarily disabled until DIRT refactors these out of this module
@@ -46,7 +47,6 @@ import { OrganizationBadgeModule } from "../vault/individual-vault/organization-
import { PipesModule } from "../vault/individual-vault/pipes/pipes.module";
import { PurgeVaultComponent } from "../vault/settings/purge-vault.component";
import { FreeBitwardenFamiliesComponent } from "./../billing/members/free-bitwarden-families.component";
import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component";
import { SharedModule } from "./shared.module";

View File

@@ -342,8 +342,6 @@ export class VaultItemsComponent {
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
const items: VaultItem[] = [].concat(collections).concat(ciphers);
this.selection.clear();
// All ciphers are selectable, collections only if they can be edited or deleted
this.editableItems = items.filter(
(item) =>

View File

@@ -29,6 +29,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { GroupView } from "../../../admin-console/organizations/core";
import { PreloadedEnglishI18nModule } from "../../../core/tests";
@@ -125,6 +126,12 @@ export default {
},
},
},
{
provide: RestrictedItemTypesService,
useValue: {
restricted$: of([]), // No restricted item types for this story
},
},
],
}),
applicationConfig({

View File

@@ -1,8 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs";
import {
distinctUntilChanged,
firstValueFrom,
map,
merge,
shareReplay,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -16,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { DialogService, ToastService } from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { TrialFlowService } from "../../../../billing/services/trial-flow.service";
import { VaultFilterService } from "../services/abstractions/vault-filter.service";
@@ -56,6 +64,45 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
return this.filters ? Object.values(this.filters) : [];
}
allTypeFilters: CipherTypeFilter[] = [
{
id: "favorites",
name: this.i18nService.t("favorites"),
type: "favorites",
icon: "bwi-star",
},
{
id: "login",
name: this.i18nService.t("typeLogin"),
type: CipherType.Login,
icon: "bwi-globe",
},
{
id: "card",
name: this.i18nService.t("typeCard"),
type: CipherType.Card,
icon: "bwi-credit-card",
},
{
id: "identity",
name: this.i18nService.t("typeIdentity"),
type: CipherType.Identity,
icon: "bwi-id-card",
},
{
id: "note",
name: this.i18nService.t("note"),
type: CipherType.SecureNote,
icon: "bwi-sticky-note",
},
{
id: "sshKey",
name: this.i18nService.t("typeSshKey"),
type: CipherType.SshKey,
icon: "bwi-key",
},
];
get searchPlaceholder() {
if (this.activeFilter.isFavorites) {
return "searchFavorites";
@@ -107,12 +154,17 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected dialogService: DialogService,
protected configService: ConfigService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
) {}
async ngOnInit(): Promise<void> {
this.filters = await this.buildAllFilters();
this.activeFilter.selectedCipherTypeNode =
(await this.getDefaultFilter()) as TreeNode<CipherTypeFilter>;
if (this.filters?.typeFilter?.data$) {
this.activeFilter.selectedCipherTypeNode = (await firstValueFrom(
this.filters?.typeFilter.data$,
)) as TreeNode<CipherTypeFilter>;
}
this.isLoaded = true;
// Without refactoring the entire component, we need to manually update the organization filter whenever the policies update
@@ -133,6 +185,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
takeUntil(this.destroy$),
)
.subscribe((orgFilters) => {
if (!this.filters) {
return;
}
this.filters.organizationFilter = orgFilters;
});
}
@@ -151,7 +206,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
if (!orgNode?.node.enabled) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("disabledOrganizationFilterError"),
});
const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id);
@@ -190,10 +244,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
this.onEditFolder.emit(folder);
};
async getDefaultFilter(): Promise<TreeNode<VaultFilterType>> {
return await firstValueFrom(this.filters?.typeFilter.data$);
}
async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList;
builderFilter.organizationFilter = await this.addOrganizationFilter();
@@ -225,7 +275,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
const addAction = !singleOrgPolicy
? { text: "newOrganization", route: "/create-organization" }
: null;
: undefined;
const orgFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.organizationTree$,
@@ -233,7 +283,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
showHeader: !(singleOrgPolicy && personalVaultPolicy),
isSelectable: true,
},
action: this.applyOrganizationFilter,
action: this.applyOrganizationFilter as (orgNode: TreeNode<VaultFilterType>) => Promise<void>,
options: { component: OrganizationOptionsComponent },
add: addAction,
divider: true,
@@ -243,55 +293,31 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}
protected async addTypeFilter(excludeTypes: CipherStatus[] = []): Promise<VaultFilterSection> {
const allTypeFilters: CipherTypeFilter[] = [
{
id: "favorites",
name: this.i18nService.t("favorites"),
type: "favorites",
icon: "bwi-star",
},
{
id: "login",
name: this.i18nService.t("typeLogin"),
type: CipherType.Login,
icon: "bwi-globe",
},
{
id: "card",
name: this.i18nService.t("typeCard"),
type: CipherType.Card,
icon: "bwi-credit-card",
},
{
id: "identity",
name: this.i18nService.t("typeIdentity"),
type: CipherType.Identity,
icon: "bwi-id-card",
},
{
id: "note",
name: this.i18nService.t("note"),
type: CipherType.SecureNote,
icon: "bwi-sticky-note",
},
{
id: "sshKey",
name: this.i18nService.t("typeSshKey"),
type: CipherType.SshKey,
icon: "bwi-key",
},
];
const allFilter: CipherTypeFilter = { id: "AllItems", name: "allItems", type: "all", icon: "" };
const data$ = this.restrictedItemTypesService.restricted$.pipe(
map((restricted) => {
// List of types restricted by all orgs
const restrictedByAll = restricted
.filter((r) => r.allowViewOrgIds.length === 0)
.map((r) => r.cipherType);
const toExclude = [...excludeTypes, ...restrictedByAll];
return this.allTypeFilters.filter(
(f) => typeof f.type === "string" || !toExclude.includes(f.type),
);
}),
switchMap((allowed) => this.vaultFilterService.buildTypeTree(allFilter, allowed)),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
const typeFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.buildTypeTree(
{ id: "AllItems", name: "allItems", type: "all", icon: "" },
allTypeFilters.filter((f) => !excludeTypes.includes(f.type)),
),
data$,
header: {
showHeader: true,
isSelectable: true,
},
action: this.applyTypeFilter,
action: this.applyTypeFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
};
return typeFilterSection;
}
@@ -303,10 +329,10 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
showHeader: true,
isSelectable: false,
},
action: this.applyFolderFilter,
action: this.applyFolderFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
edit: {
filterName: this.i18nService.t("folder"),
action: this.editFolder,
action: this.editFolder as (filter: VaultFilterType) => void,
},
};
return folderFilterSection;
@@ -319,7 +345,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
showHeader: true,
isSelectable: true,
},
action: this.applyCollectionFilter,
action: this.applyCollectionFilter as (
filterNode: TreeNode<VaultFilterType>,
) => Promise<void>,
};
return collectionFilterSection;
}
@@ -346,7 +374,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
showHeader: false,
isSelectable: true,
},
action: this.applyTypeFilter,
action: this.applyTypeFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
};
return trashFilterSection;
}

View File

@@ -3,6 +3,7 @@
import { Unassigned } from "@bitwarden/admin-console/common";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { RestrictedCipherType } from "@bitwarden/vault";
import { createFilterFunction } from "./filter-function";
import { All } from "./routed-vault-filter.model";
@@ -214,6 +215,46 @@ describe("createFilter", () => {
expect(result).toBe(true);
});
});
describe("given restricted types", () => {
const restrictedTypes: RestrictedCipherType[] = [
{ cipherType: CipherType.Login, allowViewOrgIds: [] },
];
it("should filter out a cipher whose type is fully restricted", () => {
const cipher = createCipher({ type: CipherType.Login });
const filterFunction = createFilterFunction({}, restrictedTypes);
expect(filterFunction(cipher)).toBe(false);
});
it("should allow a cipher when the cipher's organization allows it", () => {
const cipher = createCipher({ type: CipherType.Login, organizationId: "org1" });
const restricted: RestrictedCipherType[] = [
{ cipherType: CipherType.Login, allowViewOrgIds: ["org1"] },
];
const filterFunction2 = createFilterFunction({}, restricted);
expect(filterFunction2(cipher)).toBe(true);
});
it("should filter out a personal vault cipher when the owning orgs does not allow it", () => {
const cipher = createCipher({ type: CipherType.Card, organizationId: "org1" });
const restricted2: RestrictedCipherType[] = [
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
];
const filterFunction3 = createFilterFunction({}, restricted2);
expect(filterFunction3(cipher)).toBe(false);
});
it("should not filter a cipher if there are no restricted types", () => {
const cipher = createCipher({ type: CipherType.Login });
const filterFunction = createFilterFunction({}, []);
expect(filterFunction(cipher)).toBe(true);
});
});
});
function createCipher(options: Partial<CipherView> = {}) {

View File

@@ -1,12 +1,16 @@
import { Unassigned } from "@bitwarden/admin-console/common";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { RestrictedCipherType } from "@bitwarden/vault";
import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model";
export type FilterFunction = (cipher: CipherView) => boolean;
export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction {
export function createFilterFunction(
filter: RoutedVaultFilterModel,
restrictedTypes?: RestrictedCipherType[],
): FilterFunction {
return (cipher) => {
if (filter.type === "favorites" && !cipher.favorite) {
return false;
@@ -80,6 +84,24 @@ export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunc
return false;
}
// Restricted types
if (restrictedTypes && restrictedTypes.length > 0) {
// Filter the cipher if that type is restricted unless
// - The cipher belongs to an organization and that organization allows viewing the cipher type
// OR
// - The cipher belongs to the user's personal vault and at least one other organization does not restrict that type
if (
restrictedTypes.some(
(restrictedType) =>
restrictedType.cipherType === cipher.type &&
(cipher.organizationId
? !restrictedType.allowViewOrgIds.includes(cipher.organizationId)
: restrictedType.allowViewOrgIds.length === 0),
)
) {
return false;
}
}
return true;
};
}

View File

@@ -81,26 +81,12 @@
{{ "new" | i18n }}<i class="bwi tw-ml-2" aria-hidden="true"></i>
</button>
<bit-menu #addOptions aria-labelledby="newItemDropdown">
<button type="button" bitMenuItem (click)="addCipher(CipherType.Login)">
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
{{ "typeLogin" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Card)">
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
{{ "typeCard" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Identity)">
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.SecureNote)">
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.SshKey)">
<i class="bwi bwi-key" slot="start" aria-hidden="true"></i>
{{ "typeSshKey" | i18n }}
</button>
@for (item of cipherMenuItems$ | async; track item.type) {
<button type="button" bitMenuItem (click)="addCipher(item.type)">
<i class="bwi {{ item.icon }}" slot="start" aria-hidden="true"></i>
{{ item.labelKey | i18n }}
</button>
}
<bit-menu-divider />
<button type="button" bitMenuItem (click)="addFolder()">
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>

View File

@@ -1,16 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
} from "@angular/core";
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, shareReplay } from "rxjs";
import {
Unassigned,
@@ -31,6 +24,7 @@ import {
MenuModule,
SimpleDialogOptions,
} from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog";
import { HeaderModule } from "../../../layouts/header/header.module";
@@ -55,11 +49,26 @@ import {
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VaultHeaderComponent implements OnInit {
export class VaultHeaderComponent {
protected Unassigned = Unassigned;
protected All = All;
protected CollectionDialogTabType = CollectionDialogTabType;
protected CipherType = CipherType;
protected allCipherMenuItems = [
{ type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" },
{ type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" },
{ type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" },
{ type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "note" },
{ type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" },
];
protected cipherMenuItems$ = this.restrictedItemTypesService.restricted$.pipe(
map((restrictedTypes) => {
return this.allCipherMenuItems.filter((item) => {
return !restrictedTypes.some((restrictedType) => restrictedType.cipherType === item.type);
});
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
/**
* Boolean to determine the loading state of the header.
@@ -100,10 +109,9 @@ export class VaultHeaderComponent implements OnInit {
private dialogService: DialogService,
private router: Router,
private configService: ConfigService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {}
async ngOnInit() {}
/**
* The id of the organization that is currently being filtered on.
* This can come from a collection filter or organization filter, if applied.

View File

@@ -79,6 +79,7 @@ import {
DecryptionFailureDialogComponent,
DefaultCipherFormConfigService,
PasswordRepromptService,
RestrictedItemTypesService,
} from "@bitwarden/vault";
import {
@@ -273,6 +274,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private organizationBillingService: OrganizationBillingServiceAbstraction,
private billingNotificationService: BillingNotificationService,
private configService: ConfigService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {}
async ngOnInit() {
@@ -356,12 +358,13 @@ export class VaultComponent implements OnInit, OnDestroy {
this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)),
filter$,
this.currentSearchText$,
this.restrictedItemTypesService.restricted$,
]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText]) => {
concatMap(async ([ciphers, filter, searchText, restrictedTypes]) => {
const failedCiphers =
(await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? [];
const filterFunction = createFilterFunction(filter);
const filterFunction = createFilterFunction(filter, restrictedTypes);
// Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers];

View File

@@ -9,4 +9,4 @@
<path d="M95.5038 77.5474L79 61.9604L77 63.9604L92.587 80.4643C93.3607 81.2836 94.6583 81.3021 95.4552 80.5053L95.5448 80.4156C96.3417 79.6188 96.3231 78.3212 95.5038 77.5474Z" fill="#0E3781"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.5 5.46045C29.0523 5.46045 29.5 5.90816 29.5 6.46045V21.4604H14.5C13.9477 21.4604 13.5 21.0127 13.5 20.4604C13.5 19.9082 13.9477 19.4604 14.5 19.4604H27.5V6.46045C27.5 5.90816 27.9477 5.46045 28.5 5.46045Z" fill="#0E3781"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5 28.4604C19.9477 28.4604 19.5 28.9082 19.5 29.4604C19.5 30.0127 19.9477 30.4604 20.5 30.4604H30.5C31.0523 30.4604 31.5 30.0127 31.5 29.4604C31.5 28.9082 31.0523 28.4604 30.5 28.4604H20.5ZM34.5 28.4604C33.9477 28.4604 33.5 28.9082 33.5 29.4604C33.5 30.0127 33.9477 30.4604 34.5 30.4604H44.5C45.0523 30.4604 45.5 30.0127 45.5 29.4604C45.5 28.9082 45.0523 28.4604 44.5 28.4604H34.5ZM51.483 33.2759C51.3964 32.8118 50.9892 32.4604 50.5 32.4604H40.5C39.9477 32.4604 39.5 32.9082 39.5 33.4604C39.5 34.0127 39.9477 34.4604 40.5 34.4604H50.2171C50.6218 34.0477 51.0441 33.6524 51.483 33.2759ZM44.5246 49.4604C44.5579 50.1365 44.6247 50.8037 44.7235 51.4604H40.5C39.9477 51.4604 39.5 51.0127 39.5 50.4604C39.5 49.9082 39.9477 49.4604 40.5 49.4604H44.5246ZM44.7235 45.4605C44.6247 46.1172 44.5579 46.7844 44.5246 47.4605L37.5 47.4604C36.9477 47.4604 36.5 47.0127 36.5 46.4604C36.5 45.9082 36.9477 45.4604 37.5 45.4604L44.7235 45.4605ZM48.4985 36.4604C48.0192 37.0986 47.5772 37.7663 47.1756 38.4604L38.5 38.4604C37.9477 38.4604 37.5 38.0127 37.5 37.4604C37.5 36.9082 37.9477 36.4604 38.5 36.4604L48.4985 36.4604ZM64.5 28.4604C61.3707 28.4604 58.4093 29.1791 55.7717 30.4604L54.5 30.4604C53.9477 30.4604 53.5 30.0127 53.5 29.4604C53.5 28.9082 53.9477 28.4604 54.5 28.4604H64.5ZM48.5 28.4604C47.9477 28.4604 47.5 28.9082 47.5 29.4604C47.5 30.0127 47.9477 30.4604 48.5 30.4604H50.5C51.0523 30.4604 51.5 30.0127 51.5 29.4604C51.5 28.9082 51.0523 28.4604 50.5 28.4604H48.5ZM37.5 33.4604C37.5 32.9082 37.0523 32.4604 36.5 32.4604H34.5C33.9477 32.4604 33.5 32.9082 33.5 33.4604C33.5 34.0127 33.9477 34.4604 34.5 34.4604H36.5C37.0523 34.4604 37.5 34.0127 37.5 33.4604ZM34.5 38.4604C35.0523 38.4604 35.5 38.0127 35.5 37.4604C35.5 36.9082 35.0523 36.4604 34.5 36.4604H30.5C29.9477 36.4604 29.5 36.9082 29.5 37.4604C29.5 38.0127 29.9477 38.4604 30.5 38.4604H34.5ZM34.5 46.4604C34.5 47.0127 34.0523 47.4604 33.5 47.4604H27.5C26.9477 47.4604 26.5 47.0127 26.5 46.4604C26.5 45.9082 26.9477 45.4604 27.5 45.4604H33.5C34.0523 45.4604 34.5 45.9082 34.5 46.4604ZM36.5 51.4604C37.0523 51.4604 37.5 51.0127 37.5 50.4604C37.5 49.9082 37.0523 49.4604 36.5 49.4604H20.5C19.9477 49.4604 19.5 49.9082 19.5 50.4604C19.5 51.0127 19.9477 51.4604 20.5 51.4604H36.5ZM31.5 33.4604C31.5 32.9082 31.0523 32.4604 30.5 32.4604L20.5 32.4605C19.9477 32.4605 19.5 32.9082 19.5 33.4605C19.5 34.0127 19.9477 34.4605 20.5 34.4605L30.5 34.4604C31.0523 34.4604 31.5 34.0127 31.5 33.4604ZM26.5 38.4604C27.0523 38.4604 27.5 38.0127 27.5 37.4604C27.5 36.9082 27.0523 36.4604 26.5 36.4604H20.5C19.9477 36.4604 19.5 36.9082 19.5 37.4604C19.5 38.0127 19.9477 38.4604 20.5 38.4604H26.5ZM24.5 46.4604C24.5 47.0127 24.0523 47.4604 23.5 47.4604H20.5C19.9477 47.4604 19.5 47.0127 19.5 46.4604C19.5 45.9082 19.9477 45.4604 20.5 45.4604H23.5C24.0523 45.4604 24.5 45.9082 24.5 46.4604Z" fill="#FFBF00"/>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType } from "./collection";
import { CollectionDetailsResponse } from "./collection.response";
export class CollectionData {
@@ -12,6 +13,7 @@ export class CollectionData {
readOnly: boolean;
manage: boolean;
hidePasswords: boolean;
type: CollectionType;
constructor(response: CollectionDetailsResponse) {
this.id = response.id;
@@ -21,6 +23,7 @@ export class CollectionData {
this.readOnly = response.readOnly;
this.manage = response.manage;
this.hidePasswords = response.hidePasswords;
this.type = response.type;
}
static fromJSON(obj: Jsonify<CollectionData>) {

View File

@@ -2,11 +2,14 @@ import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/model
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType } from "./collection";
export class CollectionResponse extends BaseResponse {
id: CollectionId;
organizationId: OrganizationId;
name: string;
externalId: string;
type: CollectionType;
constructor(response: any) {
super(response);
@@ -14,6 +17,7 @@ export class CollectionResponse extends BaseResponse {
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
this.externalId = this.getResponseProperty("ExternalId");
this.type = this.getResponseProperty("Type");
}
}

View File

@@ -2,7 +2,7 @@ import { makeSymmetricCryptoKey, mockEnc } from "@bitwarden/common/spec";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { Collection } from "./collection";
import { Collection, CollectionTypes } from "./collection";
import { CollectionData } from "./collection.data";
describe("Collection", () => {
@@ -17,6 +17,7 @@ describe("Collection", () => {
readOnly: true,
manage: true,
hidePasswords: true,
type: CollectionTypes.DefaultUserCollection,
};
});
@@ -32,6 +33,7 @@ describe("Collection", () => {
organizationId: null,
readOnly: null,
manage: null,
type: null,
});
});
@@ -46,6 +48,7 @@ describe("Collection", () => {
readOnly: true,
manage: true,
hidePasswords: true,
type: CollectionTypes.DefaultUserCollection,
});
});
@@ -58,6 +61,7 @@ describe("Collection", () => {
collection.readOnly = false;
collection.hidePasswords = false;
collection.manage = true;
collection.type = CollectionTypes.DefaultUserCollection;
const key = makeSymmetricCryptoKey<OrgKey>();
@@ -72,6 +76,7 @@ describe("Collection", () => {
readOnly: false,
manage: true,
assigned: true,
type: CollectionTypes.DefaultUserCollection,
});
});
});

View File

@@ -7,6 +7,13 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
import { CollectionView } from "./collection.view";
export const CollectionTypes = {
SharedCollection: 0,
DefaultUserCollection: 1,
} as const;
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
export class Collection extends Domain {
id: string;
organizationId: string;
@@ -15,6 +22,7 @@ export class Collection extends Domain {
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
type: CollectionType;
constructor(obj?: CollectionData) {
super();
@@ -33,8 +41,9 @@ export class Collection extends Domain {
readOnly: null,
hidePasswords: null,
manage: null,
type: null,
},
["id", "organizationId", "readOnly", "hidePasswords", "manage"],
["id", "organizationId", "readOnly", "hidePasswords", "manage", "type"],
);
}

View File

@@ -6,7 +6,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { View } from "@bitwarden/common/models/view/view";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { Collection } from "./collection";
import { Collection, CollectionType } from "./collection";
import { CollectionAccessDetailsResponse } from "./collection.response";
export const NestingDelimiter = "/";
@@ -21,6 +21,7 @@ export class CollectionView implements View, ITreeNodeObject {
hidePasswords: boolean = null;
manage: boolean = null;
assigned: boolean = null;
type: CollectionType = null;
constructor(c?: Collection | CollectionAccessDetailsResponse) {
if (!c) {
@@ -39,6 +40,7 @@ export class CollectionView implements View, ITreeNodeObject {
if (c instanceof CollectionAccessDetailsResponse) {
this.assigned = c.assigned;
}
this.type = c.type;
}
canEditItems(org: Organization): boolean {

View File

@@ -166,7 +166,7 @@ describe("AuthRequestLoginStrategy", () => {
decMasterKeyHash,
mockUserId,
);
expect(keyService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
tokenResponse.key,
mockUserId,
);
@@ -194,7 +194,7 @@ describe("AuthRequestLoginStrategy", () => {
expect(masterPasswordService.mock.setMasterKeyHash).not.toHaveBeenCalled();
// setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called
expect(keyService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
tokenResponse.key,
mockUserId,
);

View File

@@ -95,7 +95,9 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
const authRequestCredentials = this.cache.value.authRequestCredentials;
// User now may or may not have a master password
// but set the master key encrypted user key if it exists regardless
await this.keyService.setMasterKeyEncryptedUserKey(response.key, userId);
if (response.key) {
await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, userId);
}
if (authRequestCredentials.decryptedUserKey) {
await this.keyService.setUserKey(authRequestCredentials.decryptedUserKey, userId);

View File

@@ -202,7 +202,10 @@ describe("PasswordLoginStrategy", () => {
localHashedPassword,
userId,
);
expect(keyService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key, userId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
tokenResponse.key,
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId);
});

View File

@@ -126,7 +126,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
if (this.encryptionKeyMigrationRequired(response)) {
return;
}
await this.keyService.setMasterKeyEncryptedUserKey(response.key, userId);
if (response.key) {
await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, userId);
}
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {

View File

@@ -196,8 +196,11 @@ describe("SsoLoginStrategy", () => {
await ssoLoginStrategy.logIn(credentials);
// Assert
expect(keyService.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1);
expect(keyService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key, userId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
tokenResponse.key,
userId,
);
});
describe("Trusted Device Decryption", () => {

View File

@@ -185,7 +185,10 @@ export class SsoLoginStrategy extends LoginStrategy {
if (masterKeyEncryptedUserKey) {
// set the master key encrypted user key if it exists
await this.keyService.setMasterKeyEncryptedUserKey(masterKeyEncryptedUserKey, userId);
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
masterKeyEncryptedUserKey,
userId,
);
}
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;

View File

@@ -176,7 +176,10 @@ describe("UserApiLoginStrategy", () => {
await apiLogInStrategy.logIn(credentials);
expect(keyService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key, userId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
tokenResponse.key,
userId,
);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId);
});

View File

@@ -63,7 +63,9 @@ export class UserApiLoginStrategy extends LoginStrategy {
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setMasterKeyEncryptedUserKey(response.key, userId);
if (response.key) {
await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, userId);
}
if (response.apiUseKeyConnector) {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));

View File

@@ -237,8 +237,8 @@ describe("WebAuthnLoginStrategy", () => {
// Assert
// Master key encrypted user key should be set
expect(keyService.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1);
expect(keyService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
idTokenResponse.key,
userId,
);

View File

@@ -66,7 +66,10 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
if (masterKeyEncryptedUserKey) {
// set the master key encrypted user key if it exists
await this.keyService.setMasterKeyEncryptedUserKey(masterKeyEncryptedUserKey, userId);
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
masterKeyEncryptedUserKey,
userId,
);
}
const userDecryptionOptions = idTokenResponse?.userDecryptionOptions;

View File

@@ -1,8 +1,17 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BreachAccountResponse } from "../models/response/breach-account.response";
export abstract class AuditService {
passwordLeaked: (password: string) => Promise<number>;
breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
/**
* Checks how many times a password has been leaked.
* @param password The password to check.
* @returns A promise that resolves to the number of times the password has been leaked.
*/
abstract passwordLeaked: (password: string) => Promise<number>;
/**
* Retrieves accounts that have been breached for a given username.
* @param username The username to check for breaches.
* @returns A promise that resolves to an array of BreachAccountResponse objects.
*/
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
}

View File

@@ -16,5 +16,5 @@ export enum PolicyType {
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
RestrictedItemTypesPolicy = 15, // Restricts item types that can be created within an organization
RestrictedItemTypes = 15, // Restricts item types that can be created within an organization
}

View File

@@ -228,15 +228,19 @@ export class DefaultPolicyService implements PolicyService {
case PolicyType.MaximumVaultTimeout:
// Max Vault Timeout applies to everyone except owners
return organization.isOwner;
// the following policies apply to everyone
case PolicyType.PasswordGenerator:
// password generation policy applies to everyone
// password generation policy
return false;
case PolicyType.FreeFamiliesSponsorshipPolicy:
// free Bitwarden families policy
return false;
case PolicyType.RestrictedItemTypes:
// restricted item types policy
return false;
case PolicyType.PersonalOwnership:
// individual vault policy applies to everyone except admins and owners
return organization.isAdmin;
case PolicyType.FreeFamiliesSponsorshipPolicy:
// free Bitwarden families policy applies to everyone
return false;
default:
return organization.canManagePolicies;
}

View File

@@ -5,6 +5,7 @@
import { KdfType } from "@bitwarden/key-management";
import { BaseResponse } from "../../../models/response/base.response";
import { EncString } from "../../../platform/models/domain/enc-string";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response";
@@ -17,7 +18,7 @@ export class IdentityTokenResponse extends BaseResponse {
resetMasterPassword: boolean;
privateKey: string;
key: string;
key?: EncString;
twoFactorToken: string;
kdf: KdfType;
kdfIterations: number;
@@ -39,7 +40,10 @@ export class IdentityTokenResponse extends BaseResponse {
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
this.privateKey = this.getResponseProperty("PrivateKey");
this.key = this.getResponseProperty("Key");
const key = this.getResponseProperty("Key");
if (key) {
this.key = new EncString(key);
}
this.twoFactorToken = this.getResponseProperty("TwoFactorToken");
this.kdf = this.getResponseProperty("Kdf");
this.kdfIterations = this.getResponseProperty("KdfIterations");

View File

@@ -26,6 +26,7 @@ import {
getFeatureFlagValue,
} from "../../../enums/feature-flag.enum";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
import { EncryptService } from "../abstractions/encrypt.service";
export class EncryptServiceImplementation implements EncryptService {
@@ -242,6 +243,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (encString == null || encString.encryptedString == null) {
throw new Error("encString is null or undefined");
}
await SdkLoadService.Ready;
return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded());
}
this.logService.debug("decrypting with javascript");
@@ -324,6 +326,7 @@ export class EncryptServiceImplementation implements EncryptService {
encThing.dataBytes,
encThing.macBytes,
).buffer;
await SdkLoadService.Ready;
return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded());
}
this.logService.debug("[EncryptService] Decrypting bytes with javascript");

View File

@@ -11,10 +11,12 @@ import {
SymmetricCryptoKey,
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { PureCrypto } from "@bitwarden/sdk-internal";
import { makeStaticByteArray } from "../../../../spec";
import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
@@ -343,6 +345,24 @@ describe("EncryptService", () => {
);
});
it("calls PureCrypto when useSDKForDecryption is true", async () => {
(encryptService as any).useSDKForDecryption = true;
const decryptedBytes = makeStaticByteArray(10, 200);
Object.defineProperty(SdkLoadService, "Ready", {
value: Promise.resolve(),
configurable: true,
});
jest.spyOn(PureCrypto, "symmetric_decrypt_array_buffer").mockReturnValue(decryptedBytes);
const actual = await encryptService.decryptToBytes(encBuffer, key);
expect(PureCrypto.symmetric_decrypt_array_buffer).toHaveBeenCalledWith(
encBuffer.buffer,
key.toEncoded(),
);
expect(actual).toEqualBuffer(decryptedBytes);
});
it("decrypts data with provided key for Aes256CbcHmac", async () => {
const decryptedBytes = makeStaticByteArray(10, 200);
@@ -450,6 +470,25 @@ describe("EncryptService", () => {
);
});
it("calls PureCrypto when useSDKForDecryption is true", async () => {
(encryptService as any).useSDKForDecryption = true;
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
Object.defineProperty(SdkLoadService, "Ready", {
value: Promise.resolve(),
configurable: true,
});
jest.spyOn(PureCrypto, "symmetric_decrypt").mockReturnValue("data");
const actual = await encryptService.decryptToUtf8(encString, key);
expect(actual).toEqual("data");
expect(PureCrypto.symmetric_decrypt).toHaveBeenCalledWith(
encString.encryptedString,
key.toEncoded(),
);
});
it("decrypts data with provided key for AesCbc256_HmacSha256", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");

View File

@@ -5,21 +5,23 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { KdfType, KeyService } from "@bitwarden/key-management";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { ApiService } from "../../../abstractions/api.service";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { Organization } from "../../../admin-console/models/domain/organization";
import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response";
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response";
import { TokenService } from "../../../auth/services/token.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { KeyGenerationService } from "../../../platform/services/key-generation.service";
import { OrganizationId, UserId } from "../../../types/guid";
import { MasterKey } from "../../../types/key";
import { MasterKey, UserKey } from "../../../types/key";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
@@ -50,7 +52,7 @@ describe("KeyConnectorService", () => {
const keyConnectorUrl = "https://key-connector-url.com";
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
masterPasswordService = new FakeMasterPasswordService();
accountService = mockAccountServiceWith(mockUserId);
@@ -403,6 +405,106 @@ describe("KeyConnectorService", () => {
});
});
describe("convertNewSsoUserToKeyConnector", () => {
const tokenResponse = mock<IdentityTokenResponse>();
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockEmail = "test@example.com";
const mockMasterKey = getMockMasterKey();
let mockMakeUserKeyResult: [UserKey, EncString];
beforeEach(() => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
string,
EncString,
];
const encString = new EncString("mockEncryptedString");
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
tokenResponse.kdf = KdfType.PBKDF2_SHA256;
tokenResponse.kdfIterations = 100000;
tokenResponse.kdfMemory = 16;
tokenResponse.kdfParallelism = 4;
tokenResponse.keyConnectorUrl = keyConnectorUrl;
keyGenerationService.createKey.mockResolvedValue(passwordKey);
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
tokenService.getEmail.mockResolvedValue(mockEmail);
});
it("sets up a new SSO user with key connector", async () => {
await keyConnectorService.convertNewSsoUserToKeyConnector(
tokenResponse,
mockOrgId,
mockUserId,
);
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
expect.any(Object),
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockUserId,
);
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
mockMakeUserKeyResult[1],
mockUserId,
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
tokenResponse.keyConnectorUrl,
expect.any(KeyConnectorUserKeyRequest),
);
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalled();
});
it("handles api error", async () => {
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
try {
await keyConnectorService.convertNewSsoUserToKeyConnector(
tokenResponse,
mockOrgId,
mockUserId,
);
} catch (error: any) {
expect(error).toBeInstanceOf(Error);
expect(error?.message).toBe("Key Connector error");
}
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
expect.any(Object),
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockUserId,
);
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
mockMakeUserKeyResult[1],
mockUserId,
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
tokenResponse.keyConnectorUrl,
expect.any(KeyConnectorUserKeyRequest),
);
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
});
});
function organizationData(
usesKeyConnector: boolean,
keyConnectorEnabled: boolean,

View File

@@ -160,7 +160,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
const userKey = await this.keyService.makeUserKey(masterKey);
await this.keyService.setUserKey(userKey[0], userId);
await this.keyService.setMasterKeyEncryptedUserKey(userKey[1].encryptedString, userId);
await this.masterPasswordService.setMasterKeyEncryptedUserKey(userKey[1], userId);
const [pubKey, privKey] = await this.keyService.makeKeyPair(userKey[0]);

View File

@@ -153,4 +153,41 @@ describe("MasterPasswordService", () => {
expect(result).toBeNull();
});
});
describe("setMasterKeyEncryptedUserKey", () => {
test.each([null as unknown as EncString, undefined as unknown as EncString])(
"throws when the provided encryptedKey is %s",
async (encryptedKey) => {
await expect(sut.setMasterKeyEncryptedUserKey(encryptedKey, userId)).rejects.toThrow(
"Encrypted Key is required.",
);
},
);
it("throws an error if encryptedKey is malformed null", async () => {
await expect(
sut.setMasterKeyEncryptedUserKey(new EncString(null as unknown as string), userId),
).rejects.toThrow("Encrypted Key is required.");
});
test.each([null as unknown as UserId, undefined as unknown as UserId])(
"throws when the provided userId is %s",
async (userId) => {
await expect(
sut.setMasterKeyEncryptedUserKey(new EncString(testMasterKeyEncryptedKey), userId),
).rejects.toThrow("User ID is required.");
},
);
it("calls stateProvider with the provided encryptedKey and user ID", async () => {
const encryptedKey = new EncString(testMasterKeyEncryptedKey);
await sut.setMasterKeyEncryptedUserKey(encryptedKey, userId);
expect(stateProvider.getUser).toHaveBeenCalled();
expect(mockUserState.update).toHaveBeenCalled();
const updateFn = mockUserState.update.mock.calls[0][0];
expect(updateFn(null)).toEqual(encryptedKey.toJSON());
});
});
});

View File

@@ -130,7 +130,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
}
async setMasterKeyEncryptedUserKey(encryptedKey: EncString, userId: UserId): Promise<void> {
if (encryptedKey == null) {
if (encryptedKey == null || encryptedKey.encryptedString == null) {
throw new Error("Encrypted Key is required.");
}
if (userId == null) {

View File

@@ -1,6 +1,7 @@
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
import { EncString } from "../../platform/models/domain/enc-string";
import { UserId } from "../../types/guid";
import { BaseResponse } from "./base.response";
@@ -14,7 +15,7 @@ export class ProfileResponse extends BaseResponse {
premiumFromOrganization: boolean;
culture: string;
twoFactorEnabled: boolean;
key: string;
key?: EncString;
avatarColor: string;
creationDate: string;
privateKey: string;
@@ -36,7 +37,10 @@ export class ProfileResponse extends BaseResponse {
this.premiumFromOrganization = this.getResponseProperty("PremiumFromOrganization");
this.culture = this.getResponseProperty("Culture");
this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled");
this.key = this.getResponseProperty("Key");
const key = this.getResponseProperty("Key");
if (key) {
this.key = new EncString(key);
}
this.avatarColor = this.getResponseProperty("AvatarColor");
this.creationDate = this.getResponseProperty("CreationDate");
this.privateKey = this.getResponseProperty("PrivateKey");

View File

@@ -2,7 +2,11 @@ import type { OutgoingMessage } from "@bitwarden/sdk-internal";
export interface IpcMessage {
type: "bitwarden-ipc-message";
message: Omit<OutgoingMessage, "free">;
message: SerializedOutgoingMessage;
}
export interface SerializedOutgoingMessage extends Omit<OutgoingMessage, "free" | "payload"> {
payload: number[];
}
export function isIpcMessage(message: any): message is IpcMessage {

View File

@@ -23,6 +23,8 @@ export abstract class IpcService {
protected async initWithClient(client: IpcClient): Promise<void> {
this._client = client;
await this._client.start();
this._messages$ = new Observable<IncomingMessage>((subscriber) => {
let isSubscribed = true;
const receiveLoop = async () => {

View File

@@ -1,97 +0,0 @@
import { throttle } from "./throttle";
describe("throttle decorator", () => {
it("should call the function once at a time", async () => {
const foo = new Foo();
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(foo.bar(1));
}
await Promise.all(promises);
expect(foo.calls).toBe(10);
});
it("should call the function once at a time for each object", async () => {
const foo = new Foo();
const foo2 = new Foo();
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(foo.bar(1));
promises.push(foo2.bar(1));
}
await Promise.all(promises);
expect(foo.calls).toBe(10);
expect(foo2.calls).toBe(10);
});
it("should call the function limit at a time", async () => {
const foo = new Foo();
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(foo.baz(1));
}
await Promise.all(promises);
expect(foo.calls).toBe(10);
});
it("should call the function limit at a time for each object", async () => {
const foo = new Foo();
const foo2 = new Foo();
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(foo.baz(1));
promises.push(foo2.baz(1));
}
await Promise.all(promises);
expect(foo.calls).toBe(10);
expect(foo2.calls).toBe(10);
});
});
class Foo {
calls = 0;
inflight = 0;
@throttle(1, () => "bar")
bar(a: number) {
this.calls++;
this.inflight++;
return new Promise((res) => {
setTimeout(() => {
expect(this.inflight).toBe(1);
this.inflight--;
res(a * 2);
}, Math.random() * 10);
});
}
@throttle(5, () => "baz")
baz(a: number) {
this.calls++;
this.inflight++;
return new Promise((res) => {
setTimeout(() => {
expect(this.inflight).toBeLessThanOrEqual(5);
this.inflight--;
res(a * 3);
}, Math.random() * 10);
});
}
@throttle(1, () => "qux")
qux(a: number) {
this.calls++;
this.inflight++;
return new Promise((res) => {
setTimeout(() => {
expect(this.inflight).toBe(1);
this.inflight--;
res(a * 3);
}, Math.random() * 10);
});
}
}

View File

@@ -1,71 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
/**
* Use as a Decorator on async functions, it will limit how many times the function can be
* in-flight at a time.
*
* Calls beyond the limit will be queued, and run when one of the active calls finishes
*/
export function throttle(limit: number, throttleKey: (args: any[]) => string) {
return <T>(
target: any,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<(...args: any[]) => Promise<T>>,
) => {
const originalMethod: () => Promise<T> = descriptor.value;
const allThrottles = new Map<any, Map<string, (() => void)[]>>();
const getThrottles = (obj: any) => {
let throttles = allThrottles.get(obj);
if (throttles != null) {
return throttles;
}
throttles = new Map<string, (() => void)[]>();
allThrottles.set(obj, throttles);
return throttles;
};
return {
value: function (...args: any[]) {
const throttles = getThrottles(this);
const argsThrottleKey = throttleKey(args);
let queue = throttles.get(argsThrottleKey);
if (queue == null) {
queue = [];
throttles.set(argsThrottleKey, queue);
}
return new Promise<T>((resolve, reject) => {
const exec = () => {
const onFinally = () => {
queue.splice(queue.indexOf(exec), 1);
if (queue.length >= limit) {
queue[limit - 1]();
} else if (queue.length === 0) {
throttles.delete(argsThrottleKey);
if (throttles.size === 0) {
allThrottles.delete(this);
}
}
};
originalMethod
.apply(this, args)
.then((val: any) => {
onFinally();
return val;
})
.catch((err: any) => {
onFinally();
throw err;
})
.then(resolve, reject);
};
queue.push(exec);
if (queue.length <= limit) {
exec();
}
});
},
};
};
}

View File

@@ -5,11 +5,12 @@ import { BehaviorSubject, of } from "rxjs";
import { mockAccountServiceWith } from "../../../../spec";
import { Account } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { CipherId, UserId } from "../../../types/guid";
import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
import { CipherType } from "../../../vault/enums/cipher-type";
import { CipherData } from "../../../vault/models/data/cipher.data";
import { Cipher } from "../../../vault/models/domain/cipher";
import { CipherView } from "../../../vault/models/view/cipher.view";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
@@ -218,9 +219,11 @@ describe("FidoAuthenticatorService", () => {
beforeEach(async () => {
existingCipher = createCipherView({ type: CipherType.Login });
params = await createParams({ requireResidentKey: false });
cipherService.get.mockImplementation(async (id) =>
id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined,
cipherService.ciphers$.mockImplementation((userId: UserId) =>
of({ [existingCipher.id as CipherId]: {} as CipherData }),
);
cipherService.getAllDecrypted.mockResolvedValue([existingCipher]);
cipherService.decrypt.mockResolvedValue(existingCipher);
});
@@ -351,9 +354,10 @@ describe("FidoAuthenticatorService", () => {
cipherId,
userVerified: false,
});
cipherService.get.mockImplementation(async (cipherId) =>
cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined,
cipherService.ciphers$.mockImplementation((userId: UserId) =>
of({ [cipher.id as CipherId]: {} as CipherData }),
);
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
cipherService.decrypt.mockResolvedValue(cipher);
cipherService.encrypt.mockImplementation(async (cipher) => {

View File

@@ -1,13 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { filter, firstValueFrom, map, timeout } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service";
import { getUserId } from "../../../auth/services/account.service";
import { CipherId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
import { CipherType } from "../../../vault/enums/cipher-type";
import { Cipher } from "../../../vault/models/domain/cipher";
import { CipherView } from "../../../vault/models/view/cipher.view";
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
import {
@@ -149,7 +151,23 @@ export class Fido2AuthenticatorService<ParentWindowReference>
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const encrypted = await this.cipherService.get(cipherId, activeUserId);
const encrypted = await firstValueFrom(
this.cipherService.ciphers$(activeUserId).pipe(
map((ciphers) => ciphers[cipherId as CipherId]),
filter((c) => c !== undefined),
timeout({
first: 5000,
with: () => {
this.logService?.error(
`[Fido2Authenticator] Aborting because cipher with ID ${cipherId} could not be found within timeout.`,
);
throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
},
}),
map((c) => new Cipher(c, null)),
),
);
cipher = await this.cipherService.decrypt(encrypted, activeUserId);

View File

@@ -225,7 +225,10 @@ export class DefaultSyncService extends CoreSyncService {
throw new Error("Stamp has changed");
}
await this.keyService.setMasterKeyEncryptedUserKey(response.key, response.id);
// Users with no master password will not have a key.
if (response?.key) {
await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, response.id);
}
await this.keyService.setPrivateKey(response.privateKey, response.id);
await this.keyService.setProviderKeys(response.providers, response.id);
await this.keyService.setOrgKeys(

View File

@@ -0,0 +1,81 @@
import { ApiService } from "../abstractions/api.service";
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "../models/response/error.response";
import { AuditService } from "./audit.service";
jest.useFakeTimers();
// Polyfill global Request for Jest environment if not present
if (typeof global.Request === "undefined") {
global.Request = jest.fn((input: string | URL, init?: RequestInit) => {
return { url: typeof input === "string" ? input : input.toString(), ...init };
}) as any;
}
describe("AuditService", () => {
let auditService: AuditService;
let mockCrypto: jest.Mocked<CryptoFunctionService>;
let mockApi: jest.Mocked<ApiService>;
beforeEach(() => {
mockCrypto = {
hash: jest.fn().mockResolvedValue(Buffer.from("AABBCCDDEEFF", "hex")),
} as unknown as jest.Mocked<CryptoFunctionService>;
mockApi = {
nativeFetch: jest.fn().mockResolvedValue({
text: jest.fn().mockResolvedValue(`CDDEEFF:4\nDDEEFF:2\n123456:1`),
}),
getHibpBreach: jest.fn(),
} as unknown as jest.Mocked<ApiService>;
auditService = new AuditService(mockCrypto, mockApi, 2);
});
it("should not exceed max concurrent passwordLeaked requests", async () => {
const inFlight: string[] = [];
const maxInFlight: number[] = [];
// Patch fetchLeakedPasswordCount to track concurrency
const origFetch = (auditService as any).fetchLeakedPasswordCount.bind(auditService);
jest
.spyOn(auditService as any, "fetchLeakedPasswordCount")
.mockImplementation(async (password: string) => {
inFlight.push(password);
maxInFlight.push(inFlight.length);
// Simulate async work to allow concurrency limiter to take effect
await new Promise((resolve) => setTimeout(resolve, 100));
inFlight.splice(inFlight.indexOf(password), 1);
return origFetch(password);
});
const p1 = auditService.passwordLeaked("password1");
const p2 = auditService.passwordLeaked("password2");
const p3 = auditService.passwordLeaked("password3");
const p4 = auditService.passwordLeaked("password4");
jest.advanceTimersByTime(250);
// Flush all pending timers and microtasks
await jest.runAllTimersAsync();
await Promise.all([p1, p2, p3, p4]);
// The max value in maxInFlight should not exceed 2 (the concurrency limit)
expect(Math.max(...maxInFlight)).toBeLessThanOrEqual(2);
expect((auditService as any).fetchLeakedPasswordCount).toHaveBeenCalledTimes(4);
expect(mockCrypto.hash).toHaveBeenCalledTimes(4);
expect(mockApi.nativeFetch).toHaveBeenCalledTimes(4);
});
it("should return empty array for breachedAccounts on 404", async () => {
mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse);
const result = await auditService.breachedAccounts("user@example.com");
expect(result).toEqual([]);
});
it("should throw error for breachedAccounts on non-404 error", async () => {
mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse);
await expect(auditService.breachedAccounts("user@example.com")).rejects.toThrow();
});
});

View File

@@ -1,21 +1,58 @@
import { Subject } from "rxjs";
import { mergeMap } from "rxjs/operators";
import { ApiService } from "../abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.service";
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { ErrorResponse } from "../models/response/error.response";
import { throttle } from "../platform/misc/throttle";
import { Utils } from "../platform/misc/utils";
const PwnedPasswordsApi = "https://api.pwnedpasswords.com/range/";
export class AuditService implements AuditServiceAbstraction {
private passwordLeakedSubject = new Subject<{
password: string;
resolve: (count: number) => void;
reject: (err: any) => void;
}>();
constructor(
private cryptoFunctionService: CryptoFunctionService,
private apiService: ApiService,
) {}
private readonly maxConcurrent: number = 100, // default to 100, can be overridden
) {
this.maxConcurrent = maxConcurrent;
this.passwordLeakedSubject
.pipe(
mergeMap(
// Handle each password leak request, resolving or rejecting the associated promise.
async (req) => {
try {
const count = await this.fetchLeakedPasswordCount(req.password);
req.resolve(count);
} catch (err) {
req.reject(err);
}
},
this.maxConcurrent, // Limit concurrent API calls
),
)
.subscribe();
}
@throttle(100, () => "passwordLeaked")
async passwordLeaked(password: string): Promise<number> {
return new Promise<number>((resolve, reject) => {
this.passwordLeakedSubject.next({ password, resolve, reject });
});
}
/**
* Fetches the count of leaked passwords from the Pwned Passwords API.
* @param password The password to check.
* @returns A promise that resolves to the number of times the password has been leaked.
*/
protected async fetchLeakedPasswordCount(password: string): Promise<number> {
const hashBytes = await this.cryptoFunctionService.hash(password, "sha1");
const hash = Utils.fromBufferToHex(hashBytes).toUpperCase();
const hashStart = hash.substr(0, 5);

View File

@@ -29,19 +29,19 @@
--color-info-100: 219 229 246;
--color-info-600: 121 161 233;
--color-info-700: 26 65 172;
--color-info-700: 13 36 123;
--color-warning-100: 255 248 228;
--color-warning-100: 255 244 212;
--color-warning-600: 255 191 0;
--color-warning-700: 172 88 0;
--color-warning-700: 142 64 0;
--color-danger-100: 255 236 239;
--color-danger-600: 203 38 58;
--color-danger-700: 149 27 42;
--color-success-100: 191 236 195;
--color-success-100: 213 243 216;
--color-success-600: 12 128 24;
--color-success-700: 11 111 21;
--color-success-700: 8 81 15;
--color-notification-100: 255 225 247;
--color-notification-600: 192 17 118;
@@ -85,19 +85,19 @@
--color-secondary-600: 143 152 166;
--color-secondary-700: 158 167 181;
--color-success-100: 11 111 21;
--color-success-100: 8 81 15;
--color-success-600: 107 241 120;
--color-success-700: 191 236 195;
--color-success-700: 213 243 216;
--color-danger-100: 149 27 42;
--color-danger-600: 255 78 99;
--color-danger-700: 255 236 239;
--color-warning-100: 172 88 0;
--color-warning-100: 142 64 0;
--color-warning-600: 255 191 0;
--color-warning-700: 255 248 228;
--color-warning-700: 255 244 212;
--color-info-100: 26 65 172;
--color-info-100: 13 36 123;
--color-info-600: 121 161 233;
--color-info-700: 219 229 246;

View File

@@ -170,13 +170,6 @@ export abstract class KeyService {
* @throws Error when userId is null or undefined.
*/
abstract clearStoredUserKey(keySuffix: KeySuffixOptions, userId: string): Promise<void>;
/**
* Stores the master key encrypted user key
* @throws Error when userId is null and there is no active user.
* @param userKeyMasterKey The master key encrypted user key to set
* @param userId The desired user
*/
abstract setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: UserId): Promise<void>;
/**
* @throws Error when userId is null and no active user
* @param password The user's master password that will be used to derive a master key if one isn't found

View File

@@ -263,18 +263,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
}
}
async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
if (userId == null) {
throw new Error("No active user id found.");
}
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
new EncString(userKeyMasterKey),
userId,
);
}
// TODO: Move to MasterPasswordService
async getOrDeriveMasterKey(password: string, userId?: UserId) {
const [resolvedUserId, email] = await firstValueFrom(

View File

@@ -7,7 +7,7 @@ import { BehaviorSubject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -35,6 +35,7 @@ const createMockCollection = (
hidePasswords: false,
manage: true,
assigned: true,
type: CollectionTypes.DefaultUserCollection,
canEditItems: jest.fn().mockReturnValue(canEdit),
canEdit: jest.fn(),
canDelete: jest.fn(),

View File

@@ -24,6 +24,10 @@ export * as VaultIcons from "./icons";
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
export {
RestrictedItemTypesService,
RestrictedCipherType,
} from "./services/restricted-item-types.service";
export * from "./abstractions/change-login-password.service";
export * from "./services/default-change-login-password.service";

View File

@@ -0,0 +1,137 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
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";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { RestrictedItemTypesService, RestrictedCipherType } from "./restricted-item-types.service";
describe("RestrictedItemTypesService", () => {
let service: RestrictedItemTypesService;
let policyService: MockProxy<PolicyService>;
let organizationService: MockProxy<OrganizationService>;
let accountService: MockProxy<AccountService>;
let configService: MockProxy<ConfigService>;
let fakeAccount: Account | null;
const org1: Organization = { id: "org1" } as any;
const org2: Organization = { id: "org2" } as any;
const policyOrg1 = {
organizationId: "org1",
type: PolicyType.RestrictedItemTypes,
enabled: true,
data: [CipherType.Card],
} as Policy;
const policyOrg2 = {
organizationId: "org2",
type: PolicyType.RestrictedItemTypes,
enabled: true,
data: [CipherType.Card],
} as Policy;
beforeEach(() => {
policyService = mock<PolicyService>();
organizationService = mock<OrganizationService>();
accountService = mock<AccountService>();
configService = mock<ConfigService>();
fakeAccount = { id: Utils.newGuid() as UserId } as Account;
accountService.activeAccount$ = of(fakeAccount);
TestBed.configureTestingModule({
providers: [
{ provide: PolicyService, useValue: policyService },
{ provide: OrganizationService, useValue: organizationService },
{ provide: AccountService, useValue: accountService },
{ provide: ConfigService, useValue: configService },
],
});
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.organizations$.mockReturnValue(of([org1, org2]));
policyService.policiesByType$.mockReturnValue(of([]));
service = TestBed.inject(RestrictedItemTypesService);
});
it("emits empty array when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual([]);
});
it("emits empty array if no organizations exist", async () => {
organizationService.organizations$.mockReturnValue(of([]));
policyService.policiesByType$.mockReturnValue(of([]));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual([]);
});
it("defaults undefined data to [Card] and returns empty allowViewOrgIds", async () => {
organizationService.organizations$.mockReturnValue(of([org1]));
const policyForOrg1 = {
organizationId: "org1",
type: PolicyType.RestrictedItemTypes,
enabled: true,
data: undefined,
} as Policy;
policyService.policiesByType$.mockReturnValue(of([policyForOrg1]));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual<RestrictedCipherType[]>([
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
]);
});
it("if one org restricts Card and another has no policy, allowViewOrgIds contains the unrestricted org", async () => {
policyService.policiesByType$.mockReturnValue(of([policyOrg1]));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual<RestrictedCipherType[]>([
{ cipherType: CipherType.Card, allowViewOrgIds: ["org2"] },
]);
});
it("returns empty allowViewOrgIds when all orgs restrict the same type", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.organizations$.mockReturnValue(of([org1, org2]));
policyService.policiesByType$.mockReturnValue(of([policyOrg1, policyOrg2]));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual<RestrictedCipherType[]>([
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
]);
});
it("aggregates multiple types and computes allowViewOrgIds correctly", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.organizations$.mockReturnValue(of([org1, org2]));
policyService.policiesByType$.mockReturnValue(
of([
{ ...policyOrg1, data: [CipherType.Card, CipherType.Login] } as Policy,
{ ...policyOrg2, data: [CipherType.Card, CipherType.Identity] } as Policy,
]),
);
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual<RestrictedCipherType[]>([
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
{ cipherType: CipherType.Login, allowViewOrgIds: ["org2"] },
{ cipherType: CipherType.Identity, allowViewOrgIds: ["org1"] },
]);
});
});

View File

@@ -0,0 +1,80 @@
import { Injectable } from "@angular/core";
import { combineLatest, map, of, Observable } from "rxjs";
import { switchMap, distinctUntilChanged, shareReplay } from "rxjs/operators";
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";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherType } from "@bitwarden/common/vault/enums";
export type RestrictedCipherType = {
cipherType: CipherType;
allowViewOrgIds: string[];
};
@Injectable({ providedIn: "root" })
export class RestrictedItemTypesService {
/**
* Emits an array of RestrictedCipherType objects:
* - cipherType: each type restricted by at least one org-level policy
* - allowViewOrgIds: org IDs that allow viewing that type
*/
readonly restricted$: Observable<RestrictedCipherType[]> = this.configService
.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy)
.pipe(
switchMap((flagOn) => {
if (!flagOn) {
return of([]);
}
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
combineLatest([
this.organizationService.organizations$(userId),
this.policyService.policiesByType$(PolicyType.RestrictedItemTypes, userId),
]),
),
map(([orgs, enabledPolicies]) => {
// Helper to extract restricted types, defaulting to [Card]
const restrictedTypes = (p: (typeof enabledPolicies)[number]) =>
(p.data as CipherType[]) ?? [CipherType.Card];
// Union across all enabled policies
const allRestrictedTypes = Array.from(
new Set(enabledPolicies.flatMap(restrictedTypes)),
);
return allRestrictedTypes.map((cipherType) => {
// Determine which orgs allow viewing this type
const allowViewOrgIds = orgs
.filter((org) => {
const orgPolicy = enabledPolicies.find((p) => p.organizationId === org.id);
// no policy for this org => allows everything
if (!orgPolicy) {
return true;
}
// if this type not in their restricted list => they allow it
return !restrictedTypes(orgPolicy).includes(cipherType);
})
.map((org) => org.id);
return { cipherType, allowViewOrgIds };
});
}),
);
}),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
constructor(
private configService: ConfigService,
private accountService: AccountService,
private organizationService: OrganizationService,
private policyService: PolicyService,
) {}
}

25
package-lock.json generated
View File

@@ -24,7 +24,7 @@
"@angular/platform-browser": "19.2.14",
"@angular/platform-browser-dynamic": "19.2.14",
"@angular/router": "19.2.14",
"@bitwarden/sdk-internal": "0.2.0-main.177",
"@bitwarden/sdk-internal": "0.2.0-main.198",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "3.1.0",
@@ -4378,10 +4378,25 @@
"link": true
},
"node_modules/@bitwarden/sdk-internal": {
"version": "0.2.0-main.177",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.177.tgz",
"integrity": "sha512-2fp/g0WJDPPrIqrU88QrwoJsZTzoi7S7eCf+Qq0/8x3ImqQyoYJEdHdz06YHjUdS0CzucPrwTo5zJ/ZvcLNOmQ==",
"license": "GPL-3.0"
"version": "0.2.0-main.198",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.198.tgz",
"integrity": "sha512-/MRdlcBqGxFEK/p6bU4hu5ZRoa+PqU88S+xnQaFrCXsWCTXrC8Nvm46iiz6gAqdbfFQWFNLCtmoNx6LFUdRuNg==",
"license": "GPL-3.0",
"dependencies": {
"type-fest": "^4.41.0"
}
},
"node_modules/@bitwarden/sdk-internal/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@bitwarden/send-ui": {
"resolved": "libs/tools/send/send-ui",

View File

@@ -160,7 +160,7 @@
"@angular/platform-browser": "19.2.14",
"@angular/platform-browser-dynamic": "19.2.14",
"@angular/router": "19.2.14",
"@bitwarden/sdk-internal": "0.2.0-main.177",
"@bitwarden/sdk-internal": "0.2.0-main.198",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "3.1.0",