1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 11:24:07 +00:00

Merge branch 'main' into beeep/dev-container

This commit is contained in:
Conner Turnbull
2026-02-10 15:14:58 -05:00
committed by GitHub
49 changed files with 366 additions and 836 deletions

View File

@@ -565,7 +565,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Upload Sources
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -1007,7 +1007,7 @@ jobs:
node-version: ${{ env._NODE_VERSION }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.14.2'
@@ -1247,7 +1247,7 @@ jobs:
node-version: ${{ env._NODE_VERSION }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.14.2'
@@ -1522,7 +1522,7 @@ jobs:
node-version: ${{ env._NODE_VERSION }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.14.2'
@@ -1873,7 +1873,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Upload Sources
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -352,7 +352,7 @@ jobs:
- name: Scan Docker image
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
id: container-scan
uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3
uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
with:
image: ${{ steps.image-name.outputs.name }}
fail-build: false
@@ -408,7 +408,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Upload Sources
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -45,7 +45,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Lint ${{ matrix.app.name }} config
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ matrix.app.project_id }}

View File

@@ -142,7 +142,7 @@ jobs:
run: cargo +nightly udeps --workspace --all-features --all-targets
- name: Install cargo-deny
uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7
uses: taiki-e/install-action@887bc4e03483810873d617344dd5189cd82e7b8b # v2.67.11
with:
tool: cargo-deny@0.18.6

View File

@@ -331,7 +331,7 @@ jobs:
run: wget "https://github.com/bitwarden/clients/releases/download/${_RELEASE_TAG}/macos-build-number.json"
- name: Setup Ruby and Install Fastlane
uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0
uses: ruby/setup-ruby@90be1154f987f4dc0fe0dd0feedac9e473aa4ba8 # v1.286.0
with:
ruby-version: '3.4.7'
bundler-cache: false

View File

@@ -716,7 +716,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
stroke-dasharray="78.5"
stroke-dashoffset="78.5"
stroke-width="3"
style="stroke-dashoffset: NaN;"
style="stroke-dashoffset: 34.033920413889426;"
transform="rotate(-90 14.5 14.5)"
/>
@@ -737,7 +737,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
bittypography="helper"
class="totp-sec-span"
>
NaN
17
</span>
</div>
</span>
@@ -2115,7 +2115,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
stroke-dasharray="78.5"
stroke-dashoffset="78.5"
stroke-width="3"
style="stroke-dashoffset: NaN;"
style="stroke-dashoffset: 34.033920413889426;"
transform="rotate(-90 14.5 14.5)"
/>
@@ -2136,7 +2136,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
bittypography="helper"
class="totp-sec-span"
>
NaN
17
</span>
</div>
</span>
@@ -2227,7 +2227,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
stroke-dasharray="78.5"
stroke-dashoffset="78.5"
stroke-width="3"
style="stroke-dashoffset: NaN;"
style="stroke-dashoffset: 34.033920413889426;"
transform="rotate(-90 14.5 14.5)"
/>
@@ -2248,7 +2248,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
bittypography="helper"
class="totp-sec-span"
>
NaN
17
</span>
</div>
</span>

View File

@@ -157,6 +157,8 @@ describe("AutofillInlineMenuList", () => {
});
it("creates the view for a totp field", async () => {
jest.spyOn(Date, "now").mockReturnValue(13000);
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
inlineMenuFillType: CipherType.Login,
@@ -184,6 +186,8 @@ describe("AutofillInlineMenuList", () => {
});
it("renders correctly when there are multiple TOTP elements with username displayed", async () => {
jest.spyOn(Date, "now").mockReturnValue(13000);
const totpCipher1 = createAutofillOverlayCipherDataMock(1, {
type: CipherType.Login,
login: {

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
@@ -33,27 +31,36 @@ import {
import { AutofillInlineMenuPageElement } from "../shared/autofill-inline-menu-page-element";
export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
private inlineMenuListContainer: HTMLDivElement;
private passwordGeneratorContainer: HTMLDivElement;
/** Non-null asserted. Set in initAutofillInlineMenuList before any read. */
private inlineMenuListContainer!: HTMLDivElement;
/** Non-null asserted. Set in initAutofillInlineMenuList before any read. */
private passwordGeneratorContainer!: HTMLDivElement;
private resizeObserver: ResizeObserver;
private eventHandlersMemo: { [key: string]: EventListener } = {};
private ciphers: InlineMenuCipherData[] = [];
private ciphersList: HTMLUListElement;
/** Non-null asserted. Set in buildInlineMenuList before any read. */
private ciphersList!: HTMLUListElement;
private cipherListScrollIsDebounced = false;
private cipherListScrollDebounceTimeout: number | NodeJS.Timeout;
private cipherListScrollDebounceTimeout: number | ReturnType<typeof setTimeout> = 0;
private currentCipherIndex = 0;
private inlineMenuFillType: InlineMenuFillType;
private showInlineMenuAccountCreation: boolean;
private showPasskeysLabels: boolean;
private newItemButtonElement: HTMLButtonElement;
private passkeysHeadingElement: HTMLLIElement;
private loginHeadingElement: HTMLLIElement;
private lastPasskeysListItem: HTMLLIElement;
private passkeysHeadingHeight: number;
private lastPasskeysListItemHeight: number;
private ciphersListHeight: number;
/** Non-null asserted. Set in initAutofillInlineMenuList from message. */
private inlineMenuFillType!: InlineMenuFillType;
private showInlineMenuAccountCreation = false;
private showPasskeysLabels = false;
/** Non-null asserted. Set in buildNewItemButton before any read. */
private newItemButtonElement!: HTMLButtonElement;
/** Conditionally set in buildPasskeysHeadingElements, may be undefined when no passkeys. */
private passkeysHeadingElement?: HTMLLIElement;
/** Conditionally set in buildPasskeysHeadingElements, may be undefined when no login heading. */
private loginHeadingElement?: HTMLLIElement;
/** Conditionally set in buildInlineMenuListActionsItem when showPasskeysLabels and passkey cipher. */
private lastPasskeysListItem?: HTMLLIElement;
private passkeysHeadingHeight = 0;
private lastPasskeysListItemHeight = 0;
private ciphersListHeight = 0;
private isPasskeyAuthInProgress = false;
private authStatus: AuthenticationStatus;
private authStatus: AuthenticationStatus = AuthenticationStatus.Locked;
private isInitialized = false;
private readonly showCiphersPerPage = 6;
private readonly headingBorderClass = "inline-menu-list-heading--bordered";
private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers =
@@ -70,6 +77,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
constructor() {
super();
this.resizeObserver = new ResizeObserver(this.handleResizeObserver);
this.setupInlineMenuListGlobalListeners();
}
@@ -85,11 +93,11 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
styleSheetUrl,
theme,
authStatus,
ciphers,
ciphers = [],
portKey,
inlineMenuFillType,
showInlineMenuAccountCreation,
showPasskeysLabels,
inlineMenuFillType = CipherType.Login,
showInlineMenuAccountCreation = false,
showPasskeysLabels = false,
generatedPassword,
showSaveLoginMenu,
} = message;
@@ -112,6 +120,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
this.resizeObserver.observe(this.inlineMenuListContainer);
this.shadowDom.append(linkElement, this.inlineMenuListContainer);
this.isInitialized = true;
if (authStatus !== AuthenticationStatus.Unlocked) {
this.buildLockedInlineMenu();
@@ -368,7 +377,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
event.target.nextElementSibling
) {
(event.target.nextElementSibling as HTMLElement).focus();
event.target.parentElement.classList.add("remove-outline");
event.target.parentElement?.classList.add("remove-outline");
return;
}
};
@@ -409,7 +418,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
event.target.previousElementSibling
) {
(event.target.previousElementSibling as HTMLElement).focus();
event.target.parentElement.classList.remove("remove-outline");
event.target.parentElement?.classList.remove("remove-outline");
return;
}
};
@@ -473,8 +482,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param showInlineMenuAccountCreation - Whether identity ciphers are shown on login fields.
*/
private updateListItems({
ciphers,
showInlineMenuAccountCreation,
ciphers = [],
showInlineMenuAccountCreation = false,
}: UpdateAutofillInlineMenuListCiphersParams) {
if (this.isPasskeyAuthInProgress) {
return;
@@ -655,7 +664,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* scroll listeners that reposition the passkeys and login headings when the user scrolls.
*/
private setupCipherListScrollListeners() {
const options = { passive: true };
const options: AddEventListenerOptions = { passive: true };
this.ciphersList.addEventListener(EVENTS.SCROLL, this.updateCiphersListOnScroll, options);
if (this.showPasskeysLabels) {
this.ciphersList.addEventListener(
@@ -673,8 +682,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Handles updating the list of ciphers when the
* user scrolls to the bottom of the list.
*/
private updateCiphersListOnScroll = (event: MouseEvent) => {
event.preventDefault();
private updateCiphersListOnScroll = (event: Event) => {
event.stopPropagation();
if (this.cipherListScrollIsDebounced) {
@@ -721,8 +729,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
*
* @param event - The scroll event.
*/
private handleThrottledOnScrollEvent = (event: MouseEvent) => {
event.preventDefault();
private handleThrottledOnScrollEvent = (event: Event) => {
event.stopPropagation();
this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop);
@@ -754,6 +761,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param cipherListScrollTop - The current scroll top position of the ciphers list.
*/
private togglePasskeysHeadingAnchored(cipherListScrollTop: number) {
if (!this.passkeysHeadingElement || !this.lastPasskeysListItem) {
return;
}
if (!this.passkeysHeadingHeight) {
this.passkeysHeadingHeight = this.passkeysHeadingElement.offsetHeight;
}
@@ -776,6 +786,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param cipherListScrollTop - The current scroll top position of the ciphers list.
*/
private togglePasskeysHeadingBorder(cipherListScrollTop: number) {
if (!this.passkeysHeadingElement) {
return;
}
if (cipherListScrollTop < 1) {
this.passkeysHeadingElement.classList.remove(this.headingBorderClass);
return;
@@ -791,6 +804,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param cipherListScrollTop - The current scroll top position of the ciphers list.
*/
private toggleLoginHeadingBorder(cipherListScrollTop: number) {
if (!this.loginHeadingElement || !this.lastPasskeysListItem) {
return;
}
if (!this.lastPasskeysListItemHeight) {
this.lastPasskeysListItemHeight = this.lastPasskeysListItem.offsetHeight;
}
@@ -884,7 +900,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
);
this.addFillCipherElementAriaDescription(fillCipherElement, cipher);
fillCipherElement.append(cipherIcon, cipherDetailsElement);
fillCipherElement.append(cipherIcon, ...(cipherDetailsElement ? [cipherDetailsElement] : []));
fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher));
fillCipherElement.addEventListener(EVENTS.KEYUP, this.handleFillCipherKeyUpEvent);
@@ -1126,7 +1142,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
cipherIcon.appendChild(totpContainer);
const intervalSeconds = cipher.login.totpCodeTimeInterval;
const intervalSeconds = cipher.login.totpCodeTimeInterval ?? 30;
const updateCountdown = () => {
const epoch = Math.round(Date.now() / 1000);
@@ -1266,7 +1282,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
if (this.multipleTotpElements() && username) {
const usernameSubtitle = this.buildCipherSubtitleElement(username);
containerElement.appendChild(usernameSubtitle);
if (usernameSubtitle) {
containerElement.appendChild(usernameSubtitle);
}
}
const totpCodeSpan = document.createElement("span");
@@ -1326,19 +1344,25 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
cipher: InlineMenuCipherData,
cipherDetailsElement: HTMLSpanElement,
): HTMLSpanElement {
let rpNameSubtitle: HTMLSpanElement;
const login = cipher.login;
const passkey = login?.passkey;
if (!login || !passkey) {
return cipherDetailsElement;
}
if (cipher.name !== cipher.login.passkey.rpName) {
rpNameSubtitle = this.buildCipherSubtitleElement(cipher.login.passkey.rpName);
if (rpNameSubtitle) {
let rpNameSubtitle: HTMLSpanElement | undefined;
if (cipher.name !== passkey.rpName) {
const element = this.buildCipherSubtitleElement(passkey.rpName);
if (element) {
rpNameSubtitle = element;
rpNameSubtitle.prepend(buildSvgDomElement(passkeyIcon));
rpNameSubtitle.classList.add("cipher-subtitle--passkey");
cipherDetailsElement.appendChild(rpNameSubtitle);
}
}
if (cipher.login.username) {
const usernameSubtitle = this.buildCipherSubtitleElement(cipher.login.username);
if (login.username) {
const usernameSubtitle = this.buildCipherSubtitleElement(login.username);
if (usernameSubtitle) {
if (!rpNameSubtitle) {
usernameSubtitle.prepend(buildSvgDomElement(passkeyIcon));
@@ -1350,7 +1374,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
return cipherDetailsElement;
}
const passkeySubtitle = this.buildCipherSubtitleElement(cipher.login.passkey.userName);
const passkeySubtitle = this.buildCipherSubtitleElement(passkey.userName);
if (passkeySubtitle) {
if (!rpNameSubtitle) {
passkeySubtitle.prepend(buildSvgDomElement(passkeyIcon));
@@ -1412,6 +1436,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* If not focused, will check if the button element is focused.
*/
private checkInlineMenuListFocused() {
if (!this.isInitialized) {
return;
}
if (globalThis.document.hasFocus()) {
return;
}
@@ -1450,6 +1477,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* the first cipher button.
*/
private focusInlineMenuList() {
if (!this.isInitialized) {
return;
}
this.inlineMenuListContainer.setAttribute("role", "dialog");
this.inlineMenuListContainer.setAttribute("aria-modal", "true");
@@ -1472,8 +1502,6 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
*/
private setupInlineMenuListGlobalListeners() {
this.setupGlobalListeners(this.inlineMenuListWindowMessageHandlers);
this.resizeObserver = new ResizeObserver(this.handleResizeObserver);
}
/**

View File

@@ -368,20 +368,21 @@ export function getPropertyOrAttribute(element: HTMLElement, attributeName: stri
/**
* Throttles a callback function to run at most once every `limit` milliseconds.
*
* @param callback - The callback function to throttle.
* @param callback - The callback function to throttle (must return void).
* @param limit - The time in milliseconds to throttle the callback.
*/
export function throttle<FunctionType extends (...args: unknown[]) => unknown>(
callback: FunctionType,
export function throttle<TypeContext, Args extends unknown[]>(
callback: (this: TypeContext, ...args: Args) => void,
limit: number,
): (this: ThisParameterType<FunctionType>, ...args: Parameters<FunctionType>) => void {
): (this: TypeContext, ...args: Args) => void {
let waitingDelay = false;
return function (this: ThisParameterType<FunctionType>, ...args: Parameters<FunctionType>) {
if (!waitingDelay) {
callback.apply(this, args);
waitingDelay = true;
globalThis.setTimeout(() => (waitingDelay = false), limit);
return function (this: TypeContext, ...args: Args) {
if (waitingDelay) {
return;
}
callback.apply(this, args);
waitingDelay = true;
globalThis.setTimeout(() => (waitingDelay = false), limit);
};
}

View File

@@ -1008,7 +1008,6 @@ export default class MainBackground {
this.keyGenerationService,
this.sendStateProvider,
this.encryptService,
this.cryptoFunctionService,
this.configService,
);
this.sendApiService = new SendApiService(

View File

@@ -1,5 +1,6 @@
import {
BehaviorSubject,
catchError,
combineLatest,
combineLatestWith,
concatMap,
@@ -73,9 +74,25 @@ export class BadgeService {
map((evt) => evt.tab),
combineLatestWith(this.stateFunctions),
switchMap(([tab, dynamicStateFunctions]) => {
const functions = [...Object.values(dynamicStateFunctions), defaultTabStateFunction];
const functions = [
...Object.entries(dynamicStateFunctions),
["default" as string, defaultTabStateFunction] as const,
];
return combineLatest(functions.map((f) => f(tab).pipe(startWith(undefined)))).pipe(
return combineLatest(
functions.map(([name, f]) =>
f(tab).pipe(
startWith(undefined),
catchError((error: unknown) => {
this.logService.error(
`BadgeService: State function "${name}" threw an error`,
error,
);
return of(undefined);
}),
),
),
).pipe(
map((states) => ({
tab,
states: states.filter((s): s is BadgeStateSetting => s !== undefined),

View File

@@ -7,7 +7,7 @@
[backAction]="handleBackButton"
showBackButton
>
@if (config?.originalCipher?.archivedDate) {
@if (config?.originalCipher?.archivedDate && (archiveFlagEnabled$ | async)) {
<ng-container slot="end">
<span bitBadge variant="secondary" [appA11yTitle]="'archived' | i18n">
{{ "archived" | i18n }}

View File

@@ -1,7 +1,7 @@
<popup-page>
<popup-header slot="header" [pageTitle]="headerText" showBackButton>
<ng-container slot="end">
@if (cipher?.isArchived) {
@if (cipher?.isArchived && (archiveFlagEnabled$ | async)) {
<span bitBadge variant="secondary" [appA11yTitle]="'archived' | i18n">
{{ "archived" | i18n }}
</span>

View File

@@ -615,7 +615,6 @@ export class ServiceContainer {
this.keyGenerationService,
this.sendStateProvider,
this.encryptService,
this.cryptoFunctionService,
this.configService,
);

View File

@@ -13,7 +13,7 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/re
IgnorableNamespaces="uap rescap com uap10 build"
xmlns:build="http://schemas.microsoft.com/developer/appx/2015/build">
<!-- use single quotes to avoid double quotes escaping in the publisher value -->
<Identity Name="${applicationId}"
<Identity Name="${identityName}"
ProcessorArchitecture="${arch}"
Publisher='${publisher}'
Version="${version}" />

View File

@@ -64,7 +64,7 @@
"customManifestPath": "./custom-appx-manifest.xml",
"applicationId": "BitwardenBeta",
"identityName": "8bitSolutionsLLC.BitwardenBeta",
"publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US",
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",
"publisherDisplayName": "Bitwarden Inc",
"languages": [
"en-US",

View File

@@ -179,7 +179,7 @@
"customManifestPath": "./custom-appx-manifest.xml",
"applicationId": "bitwardendesktop",
"identityName": "8bitSolutionsLLC.bitwardendesktop",
"publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US",
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",
"publisherDisplayName": "Bitwarden Inc",
"languages": [
"en-US",

View File

@@ -176,6 +176,7 @@ $translationMap = @{
'applicationId' = $builderConfig.appx.applicationId
'displayName' = $productName
'executable' = "app\${productName}.exe"
'identityName' = $builderConfig.appx.identityName
'publisher' = $builderConfig.appx.publisher
'publisherDisplayName' = $builderConfig.appx.publisherDisplayName
'version' = $version

View File

@@ -3,7 +3,7 @@ const child_process = require("child_process");
exports.default = async function (configuration) {
const ext = configuration.path.split(".").at(-1);
if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe", "appx"].includes(ext)) {
if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe"].includes(ext)) {
console.log(`[*] Signing file: ${configuration.path}`);
child_process.execFileSync(
"azuresigntool",

View File

@@ -3,7 +3,7 @@
{{ title }}
</span>
@if (isCipherArchived && !params.isAdminConsoleAction) {
@if (isCipherArchived && !params.isAdminConsoleAction && (archiveFlagEnabled$ | async)) {
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
}
@@ -86,8 +86,8 @@
@if (showActionButtons) {
<div class="tw-ml-auto">
@if ((userCanArchive$ | async) && !params.isAdminConsoleAction) {
@if (isCipherArchived && !cipher?.isDeleted) {
@if (showArchiveOptions) {
@if (showUnarchiveBtn) {
<button
type="button"
class="tw-mr-1"
@@ -96,7 +96,7 @@
[label]="'unArchive' | i18n"
></button>
}
@if (cipher?.canBeArchived) {
@if (showArchiveBtn) {
<button
type="button"
class="tw-mr-1"

View File

@@ -119,7 +119,7 @@ describe("VaultItemDialogComponent", () => {
provide: CipherArchiveService,
useValue: {
userCanArchive$: jest.fn().mockReturnValue(of(true)),
hasArchiveFlagEnabled$: jest.fn().mockReturnValue(of(true)),
hasArchiveFlagEnabled$: of(true),
archiveWithServer: jest.fn().mockResolvedValue({}),
unarchiveWithServer: jest.fn().mockResolvedValue({}),
},
@@ -258,19 +258,19 @@ describe("VaultItemDialogComponent", () => {
expect(archiveButton).toBeFalsy();
});
it("should show archive button when the user can archive the item and the item can be archived", () => {
it("should show archive button when the user can archive the item, item can be archived, and dialog is in view mode", () => {
component.setTestCipher({ canBeArchived: true });
(component as any).userCanArchive$ = of(true);
component.setTestParams({ mode: "form" });
component.setTestParams({ mode: "view" });
fixture.detectChanges();
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
expect(archiveButton).toBeTruthy();
});
it("should not show archive button when the user cannot archive the item", () => {
it("should not show archive button when the user does not have premium", () => {
(component as any).userCanArchive$ = of(false);
component.setTestCipher({});
component.setTestParams({ mode: "form" });
component.setTestParams({ mode: "view" });
fixture.detectChanges();
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
expect(archiveButton).toBeFalsy();
@@ -283,18 +283,35 @@ describe("VaultItemDialogComponent", () => {
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
expect(archiveButton).toBeFalsy();
});
it("should not show archive button when dialog is not in view mode", () => {
component.setTestCipher({ canBeArchived: true });
(component as any).userCanArchive$ = of(true);
component.setTestParams({ mode: "form" });
fixture.detectChanges();
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
expect(archiveButton).toBeFalsy();
});
});
describe("unarchive button", () => {
it("should show the unarchive button when the item is archived", () => {
it("should show the unarchive button when the item is archived, and dialog in view mode", () => {
component.setTestCipher({ isArchived: true });
component.setTestParams({ mode: "form" });
component.setTestParams({ mode: "view" });
fixture.detectChanges();
const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']"));
expect(unarchiveButton).toBeTruthy();
});
it("should not show the unarchive button when the item is not archived", () => {
component.setTestCipher({ isArchived: false });
component.setTestParams({ mode: "view" });
fixture.detectChanges();
const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']"));
expect(unarchiveButton).toBeFalsy();
});
it("should not show the unarchive button when dialog is not in view mode", () => {
component.setTestCipher({ isArchived: false });
component.setTestParams({ mode: "form" });
fixture.detectChanges();

View File

@@ -28,7 +28,7 @@ import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
@@ -293,6 +293,20 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
return this.cipher?.isArchived;
}
private _userCanArchive = false;
protected get showArchiveOptions(): boolean {
return this._userCanArchive && !this.params.isAdminConsoleAction && this.params.mode === "view";
}
protected get showArchiveBtn(): boolean {
return this.cipher?.canBeArchived;
}
protected get showUnarchiveBtn(): boolean {
return this.isCipherArchived && !this.cipher?.isDeleted;
}
/**
* Flag to initialize/attach the form component.
*/
@@ -341,6 +355,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
takeUntilDestroyed(),
)
.subscribe();
this.userCanArchive$.pipe(takeUntilDestroyed()).subscribe((v) => (this._userCanArchive = v));
}
async ngOnInit() {
@@ -508,11 +524,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
const dialogRef = this.dialogService.open<
AttachmentDialogCloseResult,
{ cipherId: CipherId; organizationId?: OrganizationId }
{ cipherId: CipherId; organizationId?: OrganizationId; canEditCipher?: boolean }
>(AttachmentsV2Component, {
data: {
cipherId: this.formConfig.originalCipher?.id as CipherId,
organizationId: this.formConfig.originalCipher?.organizationId as OrganizationId,
canEditCipher: this.formConfig.originalCipher?.edit,
},
});
@@ -574,20 +591,14 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
await this.changeMode("view");
};
updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => {
this.cipher.archivedDate = archivedDate;
this.cipher.revisionDate = revisionDate;
updateCipherFromResponse = async (cipherResponse: CipherData, userId: UserId) => {
const cipher: Cipher = new Cipher(cipherResponse);
// If we're in View mode, we don't need to update the form.
if (this.params.mode === "view") {
return;
}
cipher.collectionIds = [...this.cipher.collectionIds];
this.cipherFormComponent().patchCipher((current) => {
current.revisionDate = revisionDate;
current.archivedDate = archivedDate;
return current;
});
const cipherView = await this.cipherService.decrypt(cipher, userId);
await this.onCipherSaved(cipherView);
};
archive = async () => {
@@ -597,10 +608,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.cipher.id as CipherId,
activeUserId,
);
this.updateCipherFromArchive(
new Date(cipherResponse.revisionDate),
cipherResponse.archivedDate ? new Date(cipherResponse.archivedDate) : null,
);
await this.updateCipherFromResponse(cipherResponse, activeUserId);
this.toastService.showToast({
variant: "success",
@@ -621,7 +630,9 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.cipher.id as CipherId,
activeUserId,
);
this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null);
await this.updateCipherFromResponse(cipherResponse, activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasUnarchived"),
@@ -631,7 +642,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
};

View File

@@ -50,6 +50,7 @@
[openApplication]="drawerDetails.invokerId || ''"
[checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="showAppAtRiskMembers"
class="tw-mb-10"
></app-table-row-scrollable-m11>
@if (emptyTableExplanation()) {

View File

@@ -25,6 +25,7 @@
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
[attr.aria-label]="'select' | i18n"
appStopProp
>
<input
@@ -43,7 +44,7 @@
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
role="button"
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
[attr.aria-label]="row.applicationName"
>
@if (row.iconCipher) {
<app-vault-icon [cipher]="row.iconCipher" [size]="24"></app-vault-icon>
@@ -57,12 +58,15 @@
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
role="button"
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
[attr.aria-label]="
row.applicationName + ' ' + (row.isMarkedAsCritical ? ('criticalBadge' | i18n) : '')
"
>
<div class="tw-flex tw-items-center tw-gap-2">
<div class="tw-max-w-md tw-truncate" [title]="row.applicationName">
{{ row.applicationName }}
</div>
@if (row.isMarkedAsCritical) {
<span bitBadge [attr.aria-label]="'criticalBadge' | i18n">{{
"criticalBadge" | i18n
@@ -79,7 +83,7 @@
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
role="button"
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
[attr.aria-label]="('atRiskPasswords' | i18n) + ' ' + row.atRiskPasswordCount"
>
<span>
{{ row.atRiskPasswordCount }}
@@ -94,7 +98,7 @@
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
role="button"
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
[attr.aria-label]="('totalPasswords' | i18n) + ' ' + row.passwordCount"
>
<span>
{{ row.passwordCount }}
@@ -109,7 +113,7 @@
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
role="button"
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
[attr.aria-label]="('atRiskMembers' | i18n) + ' ' + row.atRiskMemberCount"
>
<span>
{{ row.atRiskMemberCount }}
@@ -125,7 +129,7 @@
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
role="button"
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
[attr.aria-label]="('totalMembers' | i18n) + ' ' + row.memberCount"
>
{{ row.memberCount }}
</td>

View File

@@ -859,7 +859,6 @@ const safeProviders: SafeProvider[] = [
KeyGenerationService,
SendStateProviderAbstraction,
EncryptService,
CryptoFunctionServiceAbstraction,
ConfigService,
],
}),

View File

@@ -7,7 +7,7 @@ import { CsprngArray } from "../../../types/csprng";
export abstract class CryptoFunctionService {
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract pbkdf2(
@@ -17,7 +17,7 @@ export abstract class CryptoFunctionService {
iterations: number,
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hkdf(
@@ -28,7 +28,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha256" | "sha512",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hkdfExpand(
@@ -38,7 +38,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha256" | "sha512",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hash(
@@ -46,7 +46,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha1" | "sha256" | "sha512" | "md5",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract hmacFast(
@@ -56,7 +56,7 @@ export abstract class CryptoFunctionService {
): Promise<Uint8Array | string>;
abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise<boolean>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract aesDecryptFastParameters(
@@ -66,7 +66,7 @@ export abstract class CryptoFunctionService {
key: SymmetricCryptoKey,
): CbcDecryptParameters<Uint8Array | string>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract aesDecryptFast({
@@ -76,7 +76,7 @@ export abstract class CryptoFunctionService {
| { mode: "cbc"; parameters: CbcDecryptParameters<Uint8Array | string> }
| { mode: "ecb"; parameters: EcbDecryptParameters<Uint8Array | string> }): Promise<string>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
*/
abstract aesDecrypt(
data: Uint8Array,
@@ -85,7 +85,7 @@ export abstract class CryptoFunctionService {
mode: "cbc" | "ecb",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract rsaEncrypt(
@@ -94,7 +94,7 @@ export abstract class CryptoFunctionService {
algorithm: "sha1",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
*/
abstract rsaDecrypt(

View File

@@ -5,12 +5,20 @@ import { EncString } from "../models/enc-string";
export abstract class EncryptService {
/**
* Encrypts a string to an EncString
*
* @deprecated NOTE: For new use-cases, prefer using the DataEnvelope inside the SDK instead. This
* is both safer and more maintainable.
*
* @param plainValue - The value to encrypt
* @param key - The key to encrypt the value with
*/
abstract encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString>;
/**
* Encrypts bytes to an EncString
*
* @deprecated NOTE: You probably do not want to encrypt raw bytes. Please contact the Key-Management team if you think
* you need to.
*
* @param plainValue - The value to encrypt
* @param key - The key to encrypt the value with
* @deprecated Bytes are not the right abstraction to encrypt in. Use e.g. key wrapping or file encryption instead
@@ -129,6 +137,9 @@ export abstract class EncryptService {
* Encapsulates a symmetric key with an asymmetric public key
* Note: This does not establish sender authenticity
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
*
* @deprecated NOTE: You probably do not want to use this. Please contact the Key-Management team if you think you need to.
*
* @param sharedKey - The symmetric key that is to be shared
* @param encapsulationKey - The encapsulation key (public key) of the receiver that the key is shared with
*/

View File

@@ -143,17 +143,44 @@ describe("DefaultSdkService", () => {
});
it("destroys the internal SDK client when all subscriptions are closed", async () => {
jest.useFakeTimers();
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
await new Promise(process.nextTick);
await jest.advanceTimersByTimeAsync(0);
subscription_1.unsubscribe();
subscription_2.unsubscribe();
await new Promise(process.nextTick);
await jest.advanceTimersByTimeAsync(0);
expect(mockClient.free).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
expect(mockClient.free).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
it("does not destroy the internal SDK client if resubscribed within 1 second", async () => {
jest.useFakeTimers();
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
await jest.advanceTimersByTimeAsync(0);
subscription_1.unsubscribe();
await jest.advanceTimersByTimeAsync(500);
expect(mockClient.free).not.toHaveBeenCalled();
// Resubscribe before the 1 second delay
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
await jest.advanceTimersByTimeAsync(1000);
// Client should not be freed since we resubscribed
expect(mockClient.free).not.toHaveBeenCalled();
expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1);
subscription_2.unsubscribe();
jest.useRealTimers();
});
it("destroys the internal SDK client when the userKey is unset (i.e. lock or logout)", async () => {
@@ -218,6 +245,7 @@ describe("DefaultSdkService", () => {
});
it("destroys the internal client when an override is set", async () => {
jest.useFakeTimers();
const mockInternalClient = createMockClient();
const mockOverrideClient = createMockClient();
sdkClientFactory.createSdkClient.mockResolvedValue(mockInternalClient);
@@ -227,7 +255,10 @@ describe("DefaultSdkService", () => {
service.setClient(userId, mockOverrideClient);
await userClientTracker.pauseUntilReceived(2);
expect(mockInternalClient.free).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
expect(mockInternalClient.free).toHaveBeenCalled();
jest.useRealTimers();
});
it("destroys the override client when explicitly setting the client to undefined", async () => {

View File

@@ -2,7 +2,10 @@ import {
combineLatest,
concatMap,
Observable,
share,
shareReplay,
ReplaySubject,
timer,
map,
distinctUntilChanged,
tap,
@@ -263,7 +266,10 @@ export class DefaultSdkService implements SdkService {
},
),
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
shareReplay({ refCount: true, bufferSize: 1 }),
share({
connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(1000),
}),
);
this.sdkClientCache.set(userId, client$);

View File

@@ -183,8 +183,6 @@ export class DefaultSyncService extends CoreSyncService {
const response = await this.inFlightApiCalls.sync;
await this.cipherService.clear(response.profile.id);
await this.syncUserDecryption(response.profile.id, response.userDecryption);
await this.syncProfile(response.profile);
await this.syncFolders(response.folders, response.profile.id);

View File

@@ -23,7 +23,6 @@ export class SendData {
deletionDate: string;
password: string;
emails: string;
emailHashes: string;
disabled: boolean;
hideEmail: boolean;
authType: AuthType;
@@ -47,7 +46,6 @@ export class SendData {
this.deletionDate = response.deletionDate;
this.password = response.password;
this.emails = response.emails;
this.emailHashes = "";
this.disabled = response.disable;
this.hideEmail = response.hideEmail;
this.authType = response.authType;

View File

@@ -41,7 +41,6 @@ describe("Send", () => {
deletionDate: "2022-01-31T12:00:00.000Z",
password: "password",
emails: "",
emailHashes: "",
disabled: false,
hideEmail: true,
authType: AuthType.None,
@@ -70,8 +69,7 @@ describe("Send", () => {
expirationDate: null,
deletionDate: null,
password: undefined,
emails: null,
emailHashes: undefined,
emails: undefined,
disabled: undefined,
hideEmail: undefined,
});
@@ -97,8 +95,7 @@ describe("Send", () => {
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
password: "password",
emails: null,
emailHashes: "",
emails: "",
disabled: false,
hideEmail: true,
authType: AuthType.None,
@@ -173,7 +170,7 @@ describe("Send", () => {
});
});
describe("Email decryption", () => {
describe("Email parsing", () => {
let encryptService: jest.Mocked<EncryptService>;
let keyService: jest.Mocked<KeyService>;
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
@@ -188,91 +185,45 @@ describe("Send", () => {
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
});
it("should decrypt and parse single email", async () => {
it("should parse single email", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc("test@example.com");
send.emails = "test@example.com";
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve("test@example.com");
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(encryptService.decryptString).toHaveBeenCalledWith(send.emails, "cryptoKey");
expect(view.emails).toEqual(["test@example.com"]);
});
it("should decrypt and parse multiple emails", async () => {
it("should parse multiple emails", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc("test@example.com,user@test.com,admin@domain.com");
send.emails = "test@example.com,user@test.com,admin@domain.com";
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve("test@example.com,user@test.com,admin@domain.com");
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]);
});
it("should trim whitespace from decrypted emails", async () => {
it("should trim whitespace from emails", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc(" test@example.com , user@test.com ");
send.emails = " test@example.com , user@test.com ";
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve(" test@example.com , user@test.com ");
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.emails).toEqual(["test@example.com", "user@test.com"]);
});
@@ -293,61 +244,17 @@ describe("Send", () => {
expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey");
});
it("should return empty array when decrypted emails is empty string", async () => {
it("should return empty array when emails is empty string", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc("");
send.emails = "";
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve("");
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.emails).toEqual([]);
});
it("should return empty array when decrypted emails is null", async () => {
const send = new Send();
send.id = "id";
send.type = SendType.Text;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.key = mockEnc("key");
send.emails = mockEnc("something");
send.text = mock<SendText>();
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
encryptService.decryptString.mockImplementation((encString, key) => {
if (encString === send.emails) {
return Promise.resolve(null);
}
if (encString === send.name) {
return Promise.resolve("name");
}
if (encString === send.notes) {
return Promise.resolve("notes");
}
return Promise.resolve("");
});
const view = await send.decrypt(userId);
expect(view.emails).toEqual([]);
});
});

View File

@@ -31,8 +31,7 @@ export class Send extends Domain {
expirationDate: Date;
deletionDate: Date;
password: string;
emails: EncString;
emailHashes: string;
emails: string;
disabled: boolean;
hideEmail: boolean;
authType: AuthType;
@@ -52,7 +51,6 @@ export class Send extends Domain {
name: null,
notes: null,
key: null,
emails: null,
},
["id", "accessId"],
);
@@ -62,13 +60,13 @@ export class Send extends Domain {
this.maxAccessCount = obj.maxAccessCount;
this.accessCount = obj.accessCount;
this.password = obj.password;
this.emailHashes = obj.emailHashes;
this.disabled = obj.disabled;
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null;
this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null;
this.hideEmail = obj.hideEmail;
this.authType = obj.authType;
this.emails = obj.emails;
switch (this.type) {
case SendType.Text:
@@ -100,8 +98,7 @@ export class Send extends Domain {
this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null;
if (this.emails != null) {
const decryptedEmails = await encryptService.decryptString(this.emails, model.cryptoKey);
model.emails = decryptedEmails ? decryptedEmails.split(",").map((e) => e.trim()) : [];
model.emails = this.emails ? this.emails.split(",").map((e) => e.trim()) : [];
} else {
model.emails = [];
}
@@ -133,7 +130,7 @@ export class Send extends Domain {
key: EncString.fromJSON(obj.key),
name: EncString.fromJSON(obj.name),
notes: EncString.fromJSON(obj.notes),
emails: EncString.fromJSON(obj.emails),
emails: obj.emails,
text: SendText.fromJSON(obj.text),
file: SendFile.fromJSON(obj.file),
revisionDate,

View File

@@ -8,44 +8,6 @@ import { SendRequest } from "./send.request";
describe("SendRequest", () => {
describe("constructor", () => {
it("should populate emails with encrypted string from Send.emails", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = new EncString("encryptedEmailList");
send.emailHashes = "HASH1,HASH2,HASH3";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.emails).toBe("encryptedEmailList");
});
it("should populate emailHashes from Send.emailHashes", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = new EncString("encryptedEmailList");
send.emailHashes = "HASH1,HASH2,HASH3";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.emailHashes).toBe("HASH1,HASH2,HASH3");
});
it("should set emails to null when Send.emails is null", () => {
const send = new Send();
send.type = SendType.Text;
@@ -53,7 +15,6 @@ describe("SendRequest", () => {
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
@@ -63,45 +24,6 @@ describe("SendRequest", () => {
const request = new SendRequest(send);
expect(request.emails).toBeNull();
expect(request.emailHashes).toBe("");
});
it("should handle empty emailHashes", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
expect(request.emailHashes).toBe("");
});
it("should not expose plaintext emails", () => {
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = new EncString("2.encrypted|emaildata|here");
send.emailHashes = "ABC123,DEF456";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
const request = new SendRequest(send);
// Ensure the request contains the encrypted string format, not plaintext
expect(request.emails).toBe("2.encrypted|emaildata|here");
expect(request.emails).not.toContain("@");
});
it("should handle name being null", () => {
@@ -111,7 +33,6 @@ describe("SendRequest", () => {
send.notes = new EncString("encryptedNotes");
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
@@ -130,7 +51,6 @@ describe("SendRequest", () => {
send.notes = null;
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
@@ -148,7 +68,6 @@ describe("SendRequest", () => {
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = null;
send.emailHashes = "";
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
@@ -160,33 +79,4 @@ describe("SendRequest", () => {
expect(request.fileLength).toBe(1024);
});
});
describe("Email auth requirements", () => {
it("should create request with encrypted emails and plaintext emailHashes", () => {
// Setup: A Send with encrypted emails and computed hashes
const send = new Send();
send.type = SendType.Text;
send.name = new EncString("encryptedName");
send.key = new EncString("encryptedKey");
send.emails = new EncString("2.encryptedEmailString|data");
send.emailHashes = "A1B2C3D4,E5F6G7H8"; // Plaintext hashes
send.disabled = false;
send.hideEmail = false;
send.text = new SendText();
send.text.text = new EncString("text");
send.text.hidden = false;
// Act: Create the request
const request = new SendRequest(send);
// emails field contains encrypted value
expect(request.emails).toBe("2.encryptedEmailString|data");
expect(request.emails).toContain("encrypted");
//emailHashes field contains plaintext comma-separated hashes
expect(request.emailHashes).toBe("A1B2C3D4,E5F6G7H8");
expect(request.emailHashes).not.toContain("encrypted");
expect(request.emailHashes.split(",")).toHaveLength(2);
});
});
});

View File

@@ -18,7 +18,6 @@ export class SendRequest {
file: SendFileApi;
password: string;
emails: string;
emailHashes: string;
disabled: boolean;
hideEmail: boolean;
@@ -32,8 +31,7 @@ export class SendRequest {
this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null;
this.key = send.key != null ? send.key.encryptedString : null;
this.password = send.password;
this.emails = send.emails ? send.emails.encryptedString : null;
this.emailHashes = send.emailHashes;
this.emails = send.emails;
this.disabled = send.disabled;
this.hideEmail = send.hideEmail;

View File

@@ -1,7 +1,6 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
// 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";
@@ -51,7 +50,6 @@ describe("SendService", () => {
const keyGenerationService = mock<KeyGenerationService>();
const encryptService = mock<EncryptService>();
const environmentService = mock<EnvironmentService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
const configService = mock<ConfigService>();
let sendStateProvider: SendStateProvider;
let sendService: SendService;
@@ -98,7 +96,6 @@ describe("SendService", () => {
keyGenerationService,
sendStateProvider,
encryptService,
cryptoFunctionService,
configService,
);
});
@@ -612,111 +609,50 @@ describe("SendService", () => {
describe("when SendEmailOTP feature flag is ON", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
cryptoFunctionService.hash.mockClear();
});
describe("email encryption", () => {
it("should encrypt emails when email list is provided", async () => {
describe("email processing", () => {
it("should create a comma separated string when an email list is provided", async () => {
sendView.emails = ["test@example.com", "user@test.com"];
cryptoFunctionService.hash.mockResolvedValue(new Uint8Array([0xab, 0xcd]));
const [send] = await sendService.encrypt(sendView, null, null);
expect(encryptService.encryptString).toHaveBeenCalledWith(
"test@example.com,user@test.com",
mockCryptoKey,
);
expect(send.emails).toEqual({ encryptedString: "encrypted" });
expect(send.emails).toEqual("test@example.com,user@test.com");
expect(send.password).toBeNull();
});
it("should set emails to null when email list is empty", async () => {
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
});
it("should set emails to null when email list is null", async () => {
sendView.emails = null;
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
});
it("should set emails to null when email list is undefined", async () => {
sendView.emails = undefined;
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
});
});
describe("email hashing", () => {
it("should hash emails using SHA-256 and return uppercase hex", async () => {
sendView.emails = ["test@example.com"];
const mockHash = new Uint8Array([0xab, 0xcd, 0xef]);
cryptoFunctionService.hash.mockResolvedValue(mockHash);
const [send] = await sendService.encrypt(sendView, null, null);
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
expect(send.emailHashes).toBe("ABCDEF");
});
it("should hash multiple emails and return comma-separated hashes", async () => {
it("should process multiple emails and return comma-separated string", async () => {
sendView.emails = ["test@example.com", "user@test.com"];
const mockHash1 = new Uint8Array([0xab, 0xcd]);
const mockHash2 = new Uint8Array([0x12, 0x34]);
cryptoFunctionService.hash
.mockResolvedValueOnce(mockHash1)
.mockResolvedValueOnce(mockHash2);
const [send] = await sendService.encrypt(sendView, null, null);
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256");
expect(send.emailHashes).toBe("ABCD,1234");
expect(send.emails).toBe("test@example.com,user@test.com");
});
it("should trim and lowercase emails before hashing", async () => {
it("should trim and lowercase emails", async () => {
sendView.emails = [" Test@Example.COM ", "USER@test.com"];
const mockHash = new Uint8Array([0xff]);
cryptoFunctionService.hash.mockResolvedValue(mockHash);
await sendService.encrypt(sendView, null, null);
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256");
});
it("should set emailHashes to empty string when no emails", async () => {
sendView.emails = [];
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emailHashes).toBe("");
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
expect(send.emails).toBe("test@example.com,user@test.com");
});
it("should handle single email correctly", async () => {
sendView.emails = ["single@test.com"];
const mockHash = new Uint8Array([0xa1, 0xb2, 0xc3]);
cryptoFunctionService.hash.mockResolvedValue(mockHash);
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emailHashes).toBe("A1B2C3");
expect(send.emails).toBe("single@test.com");
});
});
@@ -747,7 +683,6 @@ describe("SendService", () => {
describe("when SendEmailOTP feature flag is OFF", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
cryptoFunctionService.hash.mockClear();
});
it("should NOT encrypt emails even when provided", async () => {
@@ -756,8 +691,6 @@ describe("SendService", () => {
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
});
it("should use password when provided and flag is OFF", async () => {
@@ -769,7 +702,6 @@ describe("SendService", () => {
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
expect(send.password).toBe("hashedPassword");
});
@@ -782,9 +714,7 @@ describe("SendService", () => {
const [send] = await sendService.encrypt(sendView, null, "password123");
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
expect(send.password).toBe("hashedPassword");
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
});
it("should set emails and password to null when neither provided", async () => {
@@ -793,7 +723,6 @@ describe("SendService", () => {
const [send] = await sendService.encrypt(sendView, null, null);
expect(send.emails).toBeNull();
expect(send.emailHashes).toBe("");
expect(send.password).toBeUndefined();
});
});

View File

@@ -9,7 +9,6 @@ import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { KeyGenerationService } from "../../../key-management/crypto";
import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
@@ -54,7 +53,6 @@ export class SendService implements InternalSendServiceAbstraction {
private keyGenerationService: KeyGenerationService,
private stateProvider: SendStateProvider,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
private configService: ConfigService,
) {}
@@ -91,13 +89,13 @@ export class SendService implements InternalSendServiceAbstraction {
const hasEmails = (model.emails?.length ?? 0) > 0;
if (sendEmailOTPEnabled && hasEmails) {
const plaintextEmails = model.emails.join(",");
send.emails = await this.encryptService.encryptString(plaintextEmails, model.cryptoKey);
send.emailHashes = await this.hashEmails(plaintextEmails);
send.emails = model.emails
.map((e) => e.trim())
.join(",")
.toLocaleLowerCase();
send.password = null;
} else {
send.emails = null;
send.emailHashes = "";
if (password != null) {
// Note: Despite being called key, the passwordKey is not used for encryption.
@@ -393,19 +391,4 @@ export class SendService implements InternalSendServiceAbstraction {
decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name"));
return decryptedSends;
}
private async hashEmails(emails: string): Promise<string> {
if (!emails) {
return "";
}
const emailArray = emails.split(",").map((e) => e.trim().toLowerCase());
const hashPromises = emailArray.map(async (email) => {
const hash: Uint8Array = await this.cryptoFunctionService.hash(email, "sha256");
return Utils.fromBufferToHex(hash).toUpperCase();
});
const hashes = await Promise.all(hashPromises);
return hashes.join(",");
}
}

View File

@@ -41,7 +41,6 @@ export function createSendData(value: Partial<SendData> = {}) {
deletionDate: "2024-09-04",
password: "password",
emails: "",
emailHashes: "",
disabled: false,
hideEmail: false,
};
@@ -66,7 +65,6 @@ export function testSendData(id: string, name: string) {
data.notes = "Notes!!";
data.key = null;
data.emails = "";
data.emailHashes = "";
return data;
}
@@ -82,7 +80,6 @@ export function testSend(id: string, name: string) {
data.deletionDate = null;
data.notes = new EncString("Notes!!");
data.key = null;
data.emails = null;
data.emailHashes = "";
data.emails = "";
return data;
}

View File

@@ -2,6 +2,7 @@ import { Observable } from "rxjs";
import { SendView } from "../../tools/send/models/view/send.view";
import { IndexedEntityId, UserId } from "../../types/guid";
import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export abstract class SearchService {
@@ -19,7 +20,7 @@ export abstract class SearchService {
abstract isSearchable(userId: UserId, query: string | null): Promise<boolean>;
abstract indexCiphers(
userId: UserId,
ciphersToIndex: CipherViewLike[],
ciphersToIndex: CipherView[],
indexedEntityGuid?: string,
): Promise<void>;
abstract searchCiphers<C extends CipherViewLike>(

View File

@@ -173,14 +173,13 @@ export class CipherService implements CipherServiceAbstraction {
decryptStartTime = performance.now();
}),
switchMap(async (ciphers) => {
return await this.decryptCiphersWithSdk(ciphers, userId, false);
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
void this.setFailedDecryptedCiphers(failures, userId);
// Trigger full decryption and indexing in background
void this.getAllDecrypted(userId);
return decrypted;
}),
tap(([decrypted, failures]) => {
void Promise.all([
this.setFailedDecryptedCiphers(failures, userId),
this.searchService.indexCiphers(userId, decrypted),
]);
tap((decrypted) => {
this.logService.measure(
decryptStartTime,
"Vault",
@@ -189,11 +188,10 @@ export class CipherService implements CipherServiceAbstraction {
[["Items", decrypted.length]],
);
}),
map(([decrypted]) => decrypted),
);
}),
);
}, this.clearCipherViewsForUser$);
});
/**
* Observable that emits an array of decrypted ciphers for the active user.

View File

@@ -91,21 +91,11 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
const response = new ListResponse(r, CipherResponse);
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
// prevent mutating ciphers$ state
const localCiphers = structuredClone(currentCiphers);
const responseDataArray = response.data.map(
(cipher) => new CipherData(cipher, currentCiphers[cipher.id as CipherId]?.collectionIds),
);
for (const cipher of response.data) {
const localCipher = localCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
}
localCipher.archivedDate = cipher.archivedDate;
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.upsert(Object.values(localCiphers), userId);
await this.cipherService.upsert(responseDataArray, userId);
return response.data[0];
}
@@ -115,21 +105,11 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
const response = new ListResponse(r, CipherResponse);
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
// prevent mutating ciphers$ state
const localCiphers = structuredClone(currentCiphers);
const responseDataArray = response.data.map(
(cipher) => new CipherData(cipher, currentCiphers[cipher.id as CipherId]?.collectionIds),
);
for (const cipher of response.data) {
const localCipher = localCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
}
localCipher.archivedDate = cipher.archivedDate;
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.upsert(Object.values(localCiphers), userId);
await this.cipherService.upsert(responseDataArray, userId);
return response.data[0];
}
}

View File

@@ -21,6 +21,7 @@ import { IndexedEntityId, UserId } from "../../types/guid";
import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service";
import { FieldType } from "../enums";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
// Time to wait before performing a search after the user stops typing.
@@ -168,7 +169,7 @@ export class SearchService implements SearchServiceAbstraction {
async indexCiphers(
userId: UserId,
ciphers: CipherViewLike[],
ciphers: CipherView[],
indexedEntityId?: string,
): Promise<void> {
if (await this.getIsIndexing(userId)) {
@@ -181,47 +182,34 @@ export class SearchService implements SearchServiceAbstraction {
const builder = new lunr.Builder();
builder.pipeline.add(this.normalizeAccentsPipelineFunction);
builder.ref("id");
builder.field("shortid", {
boost: 100,
extractor: (c: CipherViewLike) => uuidAsString(c.id).substr(0, 8),
});
builder.field("shortid", { boost: 100, extractor: (c: CipherView) => c.id.substr(0, 8) });
builder.field("name", {
boost: 10,
});
builder.field("subtitle", {
boost: 5,
extractor: (c: CipherViewLike) => {
const subtitle = CipherViewLikeUtils.subtitle(c);
if (subtitle != null && CipherViewLikeUtils.getType(c) === CipherType.Card) {
return subtitle.replace(/\*/g, "");
extractor: (c: CipherView) => {
if (c.subTitle != null && c.type === CipherType.Card) {
return c.subTitle.replace(/\*/g, "");
}
return subtitle;
return c.subTitle;
},
});
builder.field("notes", { extractor: (c: CipherViewLike) => CipherViewLikeUtils.getNotes(c) });
builder.field("notes");
builder.field("login.username", {
extractor: (c: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(c);
return login?.username ?? null;
},
});
builder.field("login.uris", {
boost: 2,
extractor: (c: CipherViewLike) => this.uriExtractor(c),
});
builder.field("fields", {
extractor: (c: CipherViewLike) => this.fieldExtractor(c, false),
});
builder.field("fields_joined", {
extractor: (c: CipherViewLike) => this.fieldExtractor(c, true),
extractor: (c: CipherView) =>
c.type === CipherType.Login && c.login != null ? c.login.username : null,
});
builder.field("login.uris", { boost: 2, extractor: (c: CipherView) => this.uriExtractor(c) });
builder.field("fields", { extractor: (c: CipherView) => this.fieldExtractor(c, false) });
builder.field("fields_joined", { extractor: (c: CipherView) => this.fieldExtractor(c, true) });
builder.field("attachments", {
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, false),
extractor: (c: CipherView) => this.attachmentExtractor(c, false),
});
builder.field("attachments_joined", {
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, true),
extractor: (c: CipherView) => this.attachmentExtractor(c, true),
});
builder.field("organizationid", { extractor: (c: CipherViewLike) => c.organizationId });
builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId });
ciphers = ciphers || [];
ciphers.forEach((c) => builder.add(c));
const index = builder.build();
@@ -412,44 +400,37 @@ export class SearchService implements SearchServiceAbstraction {
return await firstValueFrom(this.searchIsIndexing$(userId));
}
private fieldExtractor(c: CipherViewLike, joined: boolean) {
const fields = CipherViewLikeUtils.getFields(c);
if (!fields || fields.length === 0) {
private fieldExtractor(c: CipherView, joined: boolean) {
if (!c.hasFields) {
return null;
}
let fieldStrings: string[] = [];
fields.forEach((f) => {
let fields: string[] = [];
c.fields.forEach((f) => {
if (f.name != null) {
fieldStrings.push(f.name);
fields.push(f.name);
}
// For CipherListView, value is only populated for Text fields
// For CipherView, we check the type explicitly
if (f.value != null) {
const fieldType = (f as { type?: FieldType }).type;
if (fieldType === undefined || fieldType === FieldType.Text) {
fieldStrings.push(f.value);
}
if (f.type === FieldType.Text && f.value != null) {
fields.push(f.value);
}
});
fieldStrings = fieldStrings.filter((f) => f.trim() !== "");
if (fieldStrings.length === 0) {
fields = fields.filter((f) => f.trim() !== "");
if (fields.length === 0) {
return null;
}
return joined ? fieldStrings.join(" ") : fieldStrings;
return joined ? fields.join(" ") : fields;
}
private attachmentExtractor(c: CipherViewLike, joined: boolean) {
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(c);
if (!attachmentNames || attachmentNames.length === 0) {
private attachmentExtractor(c: CipherView, joined: boolean) {
if (!c.hasAttachments) {
return null;
}
let attachments: string[] = [];
attachmentNames.forEach((fileName) => {
if (fileName != null) {
if (joined && fileName.indexOf(".") > -1) {
attachments.push(fileName.substring(0, fileName.lastIndexOf(".")));
c.attachments.forEach((a) => {
if (a != null && a.fileName != null) {
if (joined && a.fileName.indexOf(".") > -1) {
attachments.push(a.fileName.substr(0, a.fileName.lastIndexOf(".")));
} else {
attachments.push(fileName);
attachments.push(a.fileName);
}
}
});
@@ -460,39 +441,43 @@ export class SearchService implements SearchServiceAbstraction {
return joined ? attachments.join(" ") : attachments;
}
private uriExtractor(c: CipherViewLike) {
if (CipherViewLikeUtils.getType(c) !== CipherType.Login) {
return null;
}
const login = CipherViewLikeUtils.getLogin(c);
if (!login?.uris?.length) {
private uriExtractor(c: CipherView) {
if (c.type !== CipherType.Login || c.login == null || !c.login.hasUris) {
return null;
}
const uris: string[] = [];
login.uris.forEach((u) => {
c.login.uris.forEach((u) => {
if (u.uri == null || u.uri === "") {
return;
}
// Extract port from URI
// Match ports
const portMatch = u.uri.match(/:(\d+)(?:[/?#]|$)/);
const port = portMatch?.[1];
const hostname = CipherViewLikeUtils.getUriHostname(u);
if (hostname !== undefined) {
uris.push(hostname);
let uri = u.uri;
if (u.hostname !== null) {
uris.push(u.hostname);
if (port) {
uris.push(`${hostname}:${port}`);
uris.push(`${u.hostname}:${port}`);
uris.push(port);
}
return;
} else {
const slash = uri.indexOf("/");
const hostPart = slash > -1 ? uri.substring(0, slash) : uri;
uris.push(hostPart);
if (port) {
uris.push(`${hostPart}`);
uris.push(port);
}
}
// Add processed URI (strip protocol and query params for non-regex matches)
let uri = u.uri;
if (u.match !== UriMatchStrategy.RegularExpression) {
const protocolIndex = uri.indexOf("://");
if (protocolIndex > -1) {
uri = uri.substring(protocolIndex + 3);
uri = uri.substr(protocolIndex + 3);
}
const queryIndex = uri.search(/\?|&|#/);
if (queryIndex > -1) {
@@ -501,7 +486,6 @@ export class SearchService implements SearchServiceAbstraction {
}
uris.push(uri);
});
return uris.length > 0 ? uris : null;
}

View File

@@ -651,198 +651,4 @@ describe("CipherViewLikeUtils", () => {
expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false);
});
});
describe("getNotes", () => {
describe("CipherView", () => {
it("returns notes when present", () => {
const cipherView = createCipherView();
cipherView.notes = "This is a test note";
expect(CipherViewLikeUtils.getNotes(cipherView)).toBe("This is a test note");
});
it("returns undefined when notes are not present", () => {
const cipherView = createCipherView();
cipherView.notes = undefined;
expect(CipherViewLikeUtils.getNotes(cipherView)).toBeUndefined();
});
});
describe("CipherListView", () => {
it("returns notes when present", () => {
const cipherListView = {
type: "secureNote",
notes: "List view notes",
} as CipherListView;
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBe("List view notes");
});
it("returns undefined when notes are not present", () => {
const cipherListView = {
type: "secureNote",
} as CipherListView;
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBeUndefined();
});
});
});
describe("getFields", () => {
describe("CipherView", () => {
it("returns fields when present", () => {
const cipherView = createCipherView();
cipherView.fields = [
{ name: "Field1", value: "Value1" } as any,
{ name: "Field2", value: "Value2" } as any,
];
const fields = CipherViewLikeUtils.getFields(cipherView);
expect(fields).toHaveLength(2);
expect(fields?.[0].name).toBe("Field1");
expect(fields?.[0].value).toBe("Value1");
expect(fields?.[1].name).toBe("Field2");
expect(fields?.[1].value).toBe("Value2");
});
it("returns empty array when fields array is empty", () => {
const cipherView = createCipherView();
cipherView.fields = [];
expect(CipherViewLikeUtils.getFields(cipherView)).toEqual([]);
});
});
describe("CipherListView", () => {
it("returns fields when present", () => {
const cipherListView = {
type: { login: {} },
fields: [
{ name: "Username", value: "user@example.com" },
{ name: "API Key", value: "abc123" },
],
} as CipherListView;
const fields = CipherViewLikeUtils.getFields(cipherListView);
expect(fields).toHaveLength(2);
expect(fields?.[0].name).toBe("Username");
expect(fields?.[0].value).toBe("user@example.com");
expect(fields?.[1].name).toBe("API Key");
expect(fields?.[1].value).toBe("abc123");
});
it("returns empty array when fields array is empty", () => {
const cipherListView = {
type: "secureNote",
fields: [],
} as unknown as CipherListView;
expect(CipherViewLikeUtils.getFields(cipherListView)).toEqual([]);
});
it("returns undefined when fields are not present", () => {
const cipherListView = {
type: "secureNote",
} as CipherListView;
expect(CipherViewLikeUtils.getFields(cipherListView)).toBeUndefined();
});
});
});
describe("getAttachmentNames", () => {
describe("CipherView", () => {
it("returns attachment filenames when present", () => {
const cipherView = createCipherView();
const attachment1 = new AttachmentView();
attachment1.id = "1";
attachment1.fileName = "document.pdf";
const attachment2 = new AttachmentView();
attachment2.id = "2";
attachment2.fileName = "image.png";
const attachment3 = new AttachmentView();
attachment3.id = "3";
attachment3.fileName = "spreadsheet.xlsx";
cipherView.attachments = [attachment1, attachment2, attachment3];
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
expect(attachmentNames).toEqual(["document.pdf", "image.png", "spreadsheet.xlsx"]);
});
it("filters out null and undefined filenames", () => {
const cipherView = createCipherView();
const attachment1 = new AttachmentView();
attachment1.id = "1";
attachment1.fileName = "valid.pdf";
const attachment2 = new AttachmentView();
attachment2.id = "2";
attachment2.fileName = null as any;
const attachment3 = new AttachmentView();
attachment3.id = "3";
attachment3.fileName = undefined;
const attachment4 = new AttachmentView();
attachment4.id = "4";
attachment4.fileName = "another.txt";
cipherView.attachments = [attachment1, attachment2, attachment3, attachment4];
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
expect(attachmentNames).toEqual(["valid.pdf", "another.txt"]);
});
it("returns empty array when attachments have no filenames", () => {
const cipherView = createCipherView();
const attachment1 = new AttachmentView();
attachment1.id = "1";
const attachment2 = new AttachmentView();
attachment2.id = "2";
cipherView.attachments = [attachment1, attachment2];
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
expect(attachmentNames).toEqual([]);
});
it("returns empty array for empty attachments array", () => {
const cipherView = createCipherView();
cipherView.attachments = [];
expect(CipherViewLikeUtils.getAttachmentNames(cipherView)).toEqual([]);
});
});
describe("CipherListView", () => {
it("returns attachment names when present", () => {
const cipherListView = {
type: "secureNote",
attachmentNames: ["report.pdf", "photo.jpg", "data.csv"],
} as CipherListView;
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherListView);
expect(attachmentNames).toEqual(["report.pdf", "photo.jpg", "data.csv"]);
});
it("returns empty array when attachmentNames is empty", () => {
const cipherListView = {
type: "secureNote",
attachmentNames: [],
} as unknown as CipherListView;
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toEqual([]);
});
it("returns undefined when attachmentNames is not present", () => {
const cipherListView = {
type: "secureNote",
} as CipherListView;
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toBeUndefined();
});
});
});
});

View File

@@ -10,7 +10,6 @@ import {
LoginUriView as LoginListUriView,
} from "@bitwarden/sdk-internal";
import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums";
import { Cipher } from "../models/domain/cipher";
import { CardView } from "../models/view/card.view";
@@ -291,71 +290,6 @@ export class CipherViewLikeUtils {
static decryptionFailure = (cipher: CipherViewLike): boolean => {
return "decryptionFailure" in cipher ? cipher.decryptionFailure : false;
};
/**
* Returns the notes from the cipher.
*
* @param cipher - The cipher to extract notes from (either `CipherView` or `CipherListView`)
* @returns The notes string if present, or `undefined` if not set
*/
static getNotes = (cipher: CipherViewLike): string | undefined => {
return cipher.notes;
};
/**
* Returns the fields from the cipher.
*
* @param cipher - The cipher to extract fields from (either `CipherView` or `CipherListView`)
* @returns Array of field objects with `name` and `value` properties, `undefined` if not set
*/
static getFields = (
cipher: CipherViewLike,
): { name?: string | null; value?: string | undefined }[] | undefined => {
if (this.isCipherListView(cipher)) {
return cipher.fields;
}
return cipher.fields;
};
/**
* Returns attachment filenames from the cipher.
*
* @param cipher - The cipher to extract attachment names from (either `CipherView` or `CipherListView`)
* @returns Array of attachment filenames, `undefined` if attachments are not present
*/
static getAttachmentNames = (cipher: CipherViewLike): string[] | undefined => {
if (this.isCipherListView(cipher)) {
return cipher.attachmentNames;
}
return cipher.attachments
?.map((a) => a.fileName)
.filter((name): name is string => name != null);
};
/**
* Extracts hostname from a login URI.
*
* @param uri - The URI object (either `LoginUriView` class or `LoginListUriView`)
* @returns The hostname if available, `undefined` otherwise
*
* @remarks
* - For `LoginUriView` (CipherView): Uses the built-in `hostname` getter
* - For `LoginListUriView` (CipherListView): Computes hostname using `Utils.getHostname()`
* - Returns `undefined` for RegularExpression match types or when hostname cannot be extracted
*/
static getUriHostname = (uri: LoginListUriView | LoginUriView): string | undefined => {
if ("hostname" in uri && typeof uri.hostname !== "undefined") {
return uri.hostname ?? undefined;
}
if (uri.match !== UriMatchStrategy.RegularExpression && uri.uri) {
const hostname = Utils.getHostname(uri.uri);
return hostname === "" ? undefined : hostname;
}
return undefined;
};
}
/**

View File

@@ -1,9 +1,9 @@
<bit-simple-dialog>
<i
<bit-icon
bitDialogIcon
class="bwi bwi-exclamation-triangle tw-text-warning tw-text-3xl"
aria-hidden="true"
></i>
name="bwi-exclamation-triangle"
class="tw-text-warning tw-text-3xl"
></bit-icon>
<span bitDialogTitle>{{ "leaveConfirmationDialogTitle" | i18n }}</span>
@@ -25,9 +25,9 @@
{{ "goBack" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm">
{{ "howToManageMyVault" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
<bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon>
</a>
</ng-container>
</bit-simple-dialog>

View File

@@ -10,6 +10,7 @@ import {
DialogService,
ButtonModule,
DialogModule,
IconModule,
LinkModule,
TypographyModule,
CenterPositionStrategy,
@@ -35,7 +36,7 @@ export type LeaveConfirmationDialogResultType = UnionOfValues<typeof LeaveConfir
@Component({
templateUrl: "./leave-confirmation-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule],
})
export class LeaveConfirmationDialogComponent {
private readonly params = inject<LeaveConfirmationDialogParams>(DIALOG_DATA);

View File

@@ -14,9 +14,9 @@
{{ "declineAndLeave" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm">
{{ "whyAmISeeingThis" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
<bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon>
</a>
</ng-container>
</bit-simple-dialog>

View File

@@ -10,6 +10,7 @@ import {
DialogService,
ButtonModule,
DialogModule,
IconModule,
LinkModule,
TypographyModule,
CenterPositionStrategy,
@@ -35,7 +36,7 @@ export type TransferItemsDialogResultType = UnionOfValues<typeof TransferItemsDi
@Component({
templateUrl: "./transfer-items-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule],
})
export class TransferItemsDialogComponent {
private readonly params = inject<TransferItemsDialogParams>(DIALOG_DATA);