1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 20:50:28 +00:00

Merge remote-tracking branch 'origin/main' into PM-29033

This commit is contained in:
Miles Blackwood
2026-02-02 17:58:58 -05:00
94 changed files with 4547 additions and 1537 deletions

3
.github/CODEOWNERS vendored
View File

@@ -154,6 +154,9 @@ apps/desktop/macos/autofill-extension @bitwarden/team-autofill-desktop-dev
apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/windows_plugin_authenticator @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/autotype @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/autofill.rs @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/autotype.rs @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/sshagent.rs @bitwarden/team-autofill-desktop-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-desktop-dev
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-desktop-dev

View File

@@ -236,7 +236,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -399,7 +399,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -562,7 +562,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -827,7 +827,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -1032,14 +1032,14 @@ jobs:
- name: Cache Build
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Cache Safari
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@@ -1185,7 +1185,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -1272,14 +1272,14 @@ jobs:
- name: Get Build Cache
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Setup Safari Cache
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@@ -1409,7 +1409,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -1547,14 +1547,14 @@ jobs:
- name: Get Build Cache
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Setup Safari Cache
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@@ -1692,7 +1692,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |

View File

@@ -63,6 +63,11 @@ jobs:
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
steps:
- name: Log inputs to job summary
uses: bitwarden/ios/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
@@ -181,6 +186,19 @@ jobs:
ref: ${{ steps.set-server-ref.outputs.server_ref }}
persist-credentials: false
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
artifacts: sdk-internal
repo: bitwarden/sdk-internal
path: sdk-internal
if_no_artifact_found: fail
- name: Check Branch to Publish
env:
PUBLISH_BRANCHES: "main,rc,hotfix-rc-web"

View File

@@ -65,7 +65,7 @@ jobs:
- name: Cache NPM
id: npm-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: "~/.npm"
key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }}

View File

@@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SecurityTask } from "@bitwarden/common/vault/tasks";
import AutofillPageDetails from "../../models/autofill-page-details";
import { NotificationTypes } from "../../notification/abstractions/notification-bar";
export type NotificationTypeData = {
isVaultLocked?: boolean;
@@ -17,10 +18,26 @@ export type LoginSecurityTaskInfo = {
uri: ModifyLoginCipherFormData["uri"];
};
/**
* Distinguished from `NotificationTypes` in that this represents the
* pre-resolved notification scenario, vs the notification component
* (e.g. "Add" and "Change" will be removed
* post-`useUndeterminedCipherScenarioTriggeringLogic` migration)
*/
export const NotificationScenarios = {
...NotificationTypes,
/** represents scenarios handling saving new and updated ciphers after form submit */
Cipher: "cipher",
} as const;
export type NotificationScenario =
(typeof NotificationScenarios)[keyof typeof NotificationScenarios];
export type WebsiteOriginsWithFields = Map<chrome.tabs.Tab["id"], Set<string>>;
export type ActiveFormSubmissionRequests = Set<chrome.webRequest.WebRequestDetails["requestId"]>;
/** This type represents an expectation of nullish values being represented as empty strings */
export type ModifyLoginCipherFormData = {
uri: string;
username: string;

View File

@@ -22,6 +22,7 @@ import {
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
@@ -79,6 +80,30 @@ import {
} from "./abstractions/overlay-notifications.background";
import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background";
const inputScenarios = {
usernamePasswordNewPassword: "usernamePasswordNewPassword",
usernameNewPassword: "usernameNewPassword",
usernamePassword: "usernamePassword",
username: "username",
passwordNewPassword: "passwordNewPassword",
newPassword: "newPassword",
password: "password",
} as const;
type InputScenarioKey = keyof typeof inputScenarios;
type InputScenario = (typeof inputScenarios)[InputScenarioKey];
type CiphersByInputMatchCategory = {
allFieldMatches: CipherView["id"][];
newPasswordOnlyMatches: CipherView["id"][];
noFieldMatches: CipherView["id"][];
passwordNewPasswordMatches: CipherView["id"][];
passwordOnlyMatches: CipherView["id"][];
usernameNewPasswordMatches: CipherView["id"][];
usernameOnlyMatches: CipherView["id"][];
usernamePasswordMatches: CipherView["id"][];
};
export default class NotificationBackground {
private openUnlockPopout = openUnlockPopout;
private openAddEditVaultItemPopout = openAddEditVaultItemPopout;
@@ -152,6 +177,10 @@ export default class NotificationBackground {
this.cleanupNotificationQueue();
}
useUndeterminedCipherScenarioTriggeringLogic$ = this.configService.getFeatureFlag$(
FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic,
);
/**
* Gets the enableChangedPasswordPrompt setting from the user notification settings service.
*/
@@ -292,7 +321,7 @@ export default class NotificationBackground {
type: CipherType.Login,
reprompt,
favorite,
...(organizationCategories.length ? { organizationCategories } : {}),
...(organizationCategories.length > 0 ? { organizationCategories } : {}),
icon: buildCipherIcon(iconsServerUrl, view, showFavicons),
login: login && { username: login.username },
};
@@ -309,7 +338,7 @@ export default class NotificationBackground {
activeUserId: UserId,
): Promise<LoginSecurityTaskInfo | null> {
const tasks: SecurityTask[] = await this.getSecurityTasks(activeUserId);
if (!tasks?.length) {
if (!(tasks?.length > 0)) {
return null;
}
@@ -317,7 +346,7 @@ export default class NotificationBackground {
modifyLoginData.uri,
activeUserId,
);
if (!urlCiphers?.length) {
if (!(urlCiphers?.length > 0)) {
return null;
}
@@ -596,6 +625,216 @@ export default class NotificationBackground {
await this.checkNotificationQueue(tab);
}
/**
* Receives filled form values and determines if a notification should be
* triggered, and if so, what kind and with what data.
*
* If an update scenario is identified, a change password message is added to the
* notification queue, prompting the user to update a stored login that has changed.
*
* A new cipher notification is triggered in other defined scenarios
* with the user's form input.
*
* Returns `true` or `false` to indicate if such a notification was
* triggered or not.
*
* For the purposes of this function, form field inputs should be assumed to be
* qualified accurately.
*/
async triggerCipherNotification(
data: ModifyLoginCipherFormData,
tab: chrome.tabs.Tab,
): Promise<boolean> {
const usernameFieldValue: string | null = data.username || null;
const currentPasswordFieldValue = data.password || null;
const newPasswordFieldValue = data.newPassword || null;
// If no values were entered, exit early
if (!usernameFieldValue && !currentPasswordFieldValue && !newPasswordFieldValue) {
return false;
}
// If the entered data doesn't have an associated URI, exit early
const loginDomain = Utils.getDomain(data.uri);
if (loginDomain === null) {
return false;
}
// If no cipher add/update notifications are enabled, we can exit early
const changePasswordNotificationIsEnabled = await this.getEnableChangedPasswordPrompt();
const newLoginNotificationIsEnabled = await this.getEnableAddedLoginPrompt();
if (!changePasswordNotificationIsEnabled && !newLoginNotificationIsEnabled) {
return false;
}
// If there is no account logged in (as opposed to only being locked), exit early
const authStatus = await this.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
return false;
}
// If there is no active user, exit early
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (activeUserId === null) {
return false;
}
const normalizedUsername: string = usernameFieldValue ? usernameFieldValue.toLowerCase() : "";
const currentPasswordFieldHasValue =
typeof currentPasswordFieldValue === "string" && currentPasswordFieldValue.length > 0;
const newPasswordFieldHasValue =
typeof newPasswordFieldValue === "string" && newPasswordFieldValue.length > 0;
const usernameFieldHasValue =
typeof usernameFieldValue === "string" && usernameFieldValue.length > 0;
// If the current and new password inputs both have values and those values
// match, return early, since no change was made
if (
currentPasswordFieldHasValue &&
newPasswordFieldHasValue &&
currentPasswordFieldValue === newPasswordFieldValue
) {
return false;
}
/*
* We only show the unlock notification if a new password field was filled, since
* it's very likely to blindly represent an updated cipher value whereas other
* scenarios below require the vault to be unlocked in order to determine
* if an update has been made.
*/
if (authStatus === AuthenticationStatus.Locked) {
if (!newPasswordFieldHasValue) {
return false;
}
// This needs to be the call that includes the full form data
await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true);
return true;
}
const ciphersForURL: CipherView[] = await this.cipherService.getAllDecryptedForUrl(
data.uri,
activeUserId,
);
// Reducer structured to avoid subsequent array iterations
const ciphersByInputMatchCategory = ciphersForURL.reduce(
(acc, { id, login }) => {
const usernameInputMatchesCipher =
usernameFieldHasValue && login.username?.toLowerCase() === normalizedUsername;
const passwordInputMatchesCipher =
currentPasswordFieldHasValue && login.password === currentPasswordFieldValue;
const newPasswordInputMatchesCipher =
newPasswordFieldHasValue && login.password === newPasswordFieldValue;
if (
!newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, noFieldMatches: [...acc.noFieldMatches, id] };
} else if (
newPasswordInputMatchesCipher &&
usernameInputMatchesCipher &&
passwordInputMatchesCipher
) {
// Note: this case should be unreachable due to the early exit comparing
// the password input values against each other, but leaving this bit here
// as a defense against future changes to the pre-match checks.
return { ...acc, allFieldMatches: [...acc.allFieldMatches, id] };
} else if (
newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, newPasswordOnlyMatches: [...acc.newPasswordOnlyMatches, id] };
} else if (
passwordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, passwordOnlyMatches: [...acc.passwordOnlyMatches, id] };
} else if (
passwordInputMatchesCipher &&
newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher
) {
// Note: this case should be unreachable due to the early exit comparing
// the password input values against each other, but leaving this bit here
// as a defense against future changes to the pre-match checks.
return { ...acc, passwordNewPasswordMatches: [...acc.passwordNewPasswordMatches, id] };
} else if (
usernameInputMatchesCipher &&
!passwordInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, usernameOnlyMatches: [...acc.usernameOnlyMatches, id] };
} else if (
usernameInputMatchesCipher &&
passwordInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, usernamePasswordMatches: [...acc.usernamePasswordMatches, id] };
} else if (
usernameInputMatchesCipher &&
newPasswordInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, usernameNewPasswordMatches: [...acc.usernameNewPasswordMatches, id] };
}
return acc;
},
{
allFieldMatches: [],
newPasswordOnlyMatches: [],
noFieldMatches: [],
passwordNewPasswordMatches: [],
passwordOnlyMatches: [],
usernameNewPasswordMatches: [],
usernameOnlyMatches: [],
usernamePasswordMatches: [],
},
);
// Handle different field fill combinations and determine the input scenario
const inputScenariosByKey = {
upn: inputScenarios.usernamePasswordNewPassword,
un: inputScenarios.usernameNewPassword,
up: inputScenarios.usernamePassword,
u: inputScenarios.username,
pn: inputScenarios.passwordNewPassword,
n: inputScenarios.newPassword,
p: inputScenarios.password,
} as const;
type InputScenarioKeys = keyof typeof inputScenariosByKey;
const key = ((usernameFieldHasValue ? "u" : "") +
(currentPasswordFieldHasValue ? "p" : "") +
(newPasswordFieldHasValue ? "n" : "")) as InputScenarioKeys;
const inputScenario = key in inputScenariosByKey ? inputScenariosByKey[key] : null;
if (inputScenario) {
return await this.handleInputMatchScenario({
ciphersByInputMatchCategory,
ciphersForURL,
loginDomain,
tab,
data,
inputScenario,
changePasswordNotificationIsEnabled,
newLoginNotificationIsEnabled,
});
}
return false;
}
/**
* Adds a change password message to the notification queue, prompting the user
* to update the password for a login that has changed.
@@ -668,13 +907,14 @@ export default class NotificationBackground {
if (
ciphers.length > 0 &&
currentPasswordFieldValue?.length &&
(currentPasswordFieldValue?.length || 0) > 0 &&
// Only use current password for change if no new password present.
!newPasswordFieldValue
) {
const currentPasswordMatchesAnExistingValue = ciphers.some(
(cipher) =>
cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue,
(cipher.login?.password?.length || 0) > 0 &&
cipher.login.password === currentPasswordFieldValue,
);
// The password entered matched a stored cipher value with
@@ -710,6 +950,212 @@ export default class NotificationBackground {
return false;
}
private async handleInputMatchScenario({
inputScenario,
ciphersByInputMatchCategory,
ciphersForURL,
loginDomain,
tab,
data,
changePasswordNotificationIsEnabled,
newLoginNotificationIsEnabled,
}: {
ciphersByInputMatchCategory: CiphersByInputMatchCategory;
ciphersForURL: CipherView[];
loginDomain: string;
tab: chrome.tabs.Tab;
data: ModifyLoginCipherFormData;
inputScenario: InputScenario;
changePasswordNotificationIsEnabled: boolean;
newLoginNotificationIsEnabled: boolean;
}): Promise<boolean> {
const {
newPasswordOnlyMatches,
noFieldMatches,
passwordOnlyMatches,
usernameNewPasswordMatches,
usernameOnlyMatches,
usernamePasswordMatches,
} = ciphersByInputMatchCategory;
// IMPORTANT! The order of statements matters here; later evaluations
// depend on the assumptions of the early exits in preceding logic
// If no ciphers match any filled input values
// (Note, this block may uniquely exit early since this match scenario
// involves all ciphers, making it mutually exclusive from any other scenario)
if (noFieldMatches.length === ciphersForURL.length) {
// trigger a new cipher notification in these input scenarios
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
inputScenarios.usernamePassword,
inputScenarios.username,
] as InputScenario[]
).includes(inputScenario) &&
newLoginNotificationIsEnabled
) {
await this.pushAddLoginToQueue(
loginDomain,
{ username: data.username, url: data.uri, password: data.newPassword || data.password },
tab,
);
return true;
}
// Trigger an update cipher notification with all URI ciphers
// in these input scenarios
if (
([inputScenarios.password, inputScenarios.newPassword] as InputScenario[]).includes(
inputScenario,
) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
ciphersForURL.map((c) => c.id),
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
return false;
}
// If ciphers match entered username and new password values
if (usernameNewPasswordMatches.length > 0) {
// Early exit in these scenarios as they represent "no change"
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
] as InputScenario[]
).includes(inputScenario)
) {
return false;
}
}
// If ciphers match entered username and password values
if (usernamePasswordMatches.length > 0) {
// and username, password, and new password values were entered
if (
inputScenario === inputScenarios.usernamePasswordNewPassword &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
usernamePasswordMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
if (inputScenario === inputScenarios.usernamePassword) {
return false;
}
}
// If ciphers match entered username value (only)
if (usernameOnlyMatches.length > 0) {
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
inputScenarios.usernamePassword,
] as InputScenario[]
).includes(inputScenario) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
usernameOnlyMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
// Early exit in this scenario as it represents "no change"
if (inputScenario === inputScenarios.username) {
return false;
}
}
// If ciphers match entered new password value (only)
if (newPasswordOnlyMatches.length > 0) {
// Early exit in these scenarios
if (
(
[
inputScenarios.usernameNewPassword, // unclear user expectation
inputScenarios.password, // likely nothing to change
inputScenarios.newPassword, // nothing to change
] as InputScenario[]
).includes(inputScenario)
) {
return false;
}
// and username, password, and new password values were entered
if (
inputScenario === inputScenarios.usernamePasswordNewPassword &&
newLoginNotificationIsEnabled
) {
await this.pushAddLoginToQueue(
loginDomain,
{ username: data.username, url: data.uri, password: data.newPassword || data.password },
tab,
);
return true;
}
}
// If ciphers match entered password value (only)
if (passwordOnlyMatches.length > 0) {
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernamePassword,
inputScenarios.passwordNewPassword,
] as InputScenario[]
).includes(inputScenario) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
passwordOnlyMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
// Early exit in this scenario as it represents "no change"
if (inputScenario === inputScenarios.password) {
return false;
}
}
return false;
}
/**
* Sends the page details to the notification bar. Will query all
* forms with a password field and pass them to the notification bar.
@@ -730,6 +1176,7 @@ export default class NotificationBackground {
});
}
// @TODO this needs the whole input record, and not just newPassword
private async pushChangePasswordToQueue(
cipherIds: CipherView["id"][],
loginDomain: string,

View File

@@ -1,4 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
@@ -32,6 +33,7 @@ describe("OverlayNotificationsBackground", () => {
jest.useFakeTimers();
logService = mock<LogService>();
notificationBackground = mock<NotificationBackground>();
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false);
getEnableChangedPasswordPromptSpy = jest
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
.mockResolvedValue(true);
@@ -323,6 +325,7 @@ describe("OverlayNotificationsBackground", () => {
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
let notificationChangedPasswordSpy: jest.SpyInstance;
let notificationAddLoginSpy: jest.SpyInstance;
let cipherNotificationSpy: jest.SpyInstance;
beforeEach(async () => {
sender = mock<chrome.runtime.MessageSender>({
@@ -334,6 +337,7 @@ describe("OverlayNotificationsBackground", () => {
"triggerChangedPasswordNotification",
);
notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification");
cipherNotificationSpy = jest.spyOn(notificationBackground, "triggerCipherNotification");
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
@@ -456,6 +460,7 @@ describe("OverlayNotificationsBackground", () => {
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
beforeEach(async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false);
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
@@ -519,6 +524,44 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationAddLoginSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, waits for the tab's navigation to complete using the web navigation API before initializing the notification", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "loading",
url: sender.url,
}),
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.OnCompletedDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebNavigationOnCompletedEvent(
mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
tabId: sender.tab.id,
url: sender.url,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
it("initializes the notification immediately when the tab's navigation is complete", async () => {
sendMockExtensionMessage(
{
@@ -552,6 +595,40 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationAddLoginSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, initializes the notification immediately when the tab's navigation is complete", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "username",
password: "password",
newPassword: "newPassword",
},
sender,
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.OnCompletedDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => {
sender.tab = mock<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
@@ -601,6 +678,57 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationChangedPasswordSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
sender.tab = mock<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "",
password: "password",
newPassword: "newPassword",
},
sender,
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId,
}),
);
await flushPromises();
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: "https://example.com/redirect",
tabId: sender.tab.id,
method: "GET",
requestId,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,10 +1,9 @@
import { Subject, switchMap, timer } from "rxjs";
import { firstValueFrom, Subject, switchMap, timer } from "rxjs";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar";
import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils";
import {
@@ -14,6 +13,8 @@ import {
OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface,
OverlayNotificationsExtensionMessage,
OverlayNotificationsExtensionMessageHandlers,
NotificationScenarios,
NotificationScenario,
WebsiteOriginsWithFields,
} from "./abstractions/overlay-notifications.background";
import NotificationBackground from "./notification.background";
@@ -32,7 +33,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
collectPageDetailsResponse: ({ message, sender }) =>
this.handleCollectPageDetailsResponse(message, sender),
};
constructor(
private logService: LogService,
private notificationBackground: NotificationBackground,
@@ -281,7 +281,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
const shouldAttemptAddNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Add,
NotificationScenarios.Add,
);
if (shouldAttemptAddNotification) {
@@ -290,7 +290,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
const shouldAttemptChangeNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Change,
NotificationScenarios.Change,
);
if (shouldAttemptChangeNotification) {
@@ -445,29 +445,45 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
requestId: chrome.webRequest.WebRequestDetails["requestId"],
modifyLoginData: ModifyLoginCipherFormData,
tab: chrome.tabs.Tab,
config: { skippable: NotificationType[] } = { skippable: [] },
config: { skippable: NotificationScenario[] } = { skippable: [] },
) => {
const notificationCandidates = [
{
type: NotificationTypes.Change,
trigger: this.notificationBackground.triggerChangedPasswordNotification,
},
{
type: NotificationTypes.Add,
trigger: this.notificationBackground.triggerAddLoginNotification,
},
{
type: NotificationTypes.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
].filter(
const useUndeterminedCipherScenarioTriggeringLogic = await firstValueFrom(
this.notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$,
);
const notificationCandidates = useUndeterminedCipherScenarioTriggeringLogic
? [
{
type: NotificationScenarios.Cipher,
trigger: this.notificationBackground.triggerCipherNotification,
},
{
type: NotificationScenarios.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
]
: [
{
type: NotificationScenarios.Change,
trigger: this.notificationBackground.triggerChangedPasswordNotification,
},
{
type: NotificationScenarios.Add,
trigger: this.notificationBackground.triggerAddLoginNotification,
},
{
type: NotificationScenarios.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
];
const filteredNotificationCandidates = notificationCandidates.filter(
(candidate) =>
this.shouldAttemptNotification(modifyLoginData, candidate.type) ||
config.skippable.includes(candidate.type),
);
const results: string[] = [];
for (const { trigger, type } of notificationCandidates) {
for (const { trigger, type } of filteredNotificationCandidates) {
const success = await trigger.bind(this.notificationBackground)(modifyLoginData, tab);
if (success) {
results.push(`Success: ${type}`);
@@ -489,8 +505,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*/
private shouldAttemptNotification = (
modifyLoginData: ModifyLoginCipherFormData,
notificationType: NotificationType,
notificationType: NotificationScenario,
): boolean => {
if (notificationType === NotificationScenarios.Cipher) {
// The logic after this block pre-qualifies some cipher add/update scenarios
// prematurely (where matching against vault contents is required) and should be
// skipped for this case (these same checks are performed early in the
// notification triggering logic).
return true;
}
// Intentionally not stripping whitespace characters here as they
// represent user entry.
const usernameFieldHasValue = !!(modifyLoginData?.username || "").length;
@@ -504,15 +528,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
// `Add` case included because all forms with cached usernames (from previous
// visits) will appear to be "password only" and otherwise trigger the new login
// save notification.
case NotificationTypes.Add:
case NotificationScenarios.Add:
// Can be values for nonstored login or account creation
return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue);
case NotificationTypes.Change:
case NotificationScenarios.Change:
// Can be login with nonstored login changes or account password update
return canBeUserLogin || canBePasswordUpdate;
case NotificationTypes.AtRiskPassword:
case NotificationScenarios.AtRiskPassword:
return !newPasswordFieldHasValue;
case NotificationTypes.Unlock:
case NotificationScenarios.Unlock:
// Unlock notifications are handled separately and do not require form data
return false;
default:

View File

@@ -8,9 +8,13 @@ import {
} from "../../../autofill/content/components/common-types";
const NotificationTypes = {
/** represents scenarios handling saving new ciphers after form submit */
Add: "add",
/** represents scenarios handling saving updated ciphers after form submit */
Change: "change",
/** represents scenarios where user has interacted with an unlock action prompt or action otherwise requiring unlock as a prerequisite */
Unlock: "unlock",
/** represents scenarios where the user has security tasks after updating ciphers */
AtRiskPassword: "at-risk-password",
} as const;

View File

@@ -109,7 +109,7 @@ describe("AttachmentsV2Component", () => {
});
it("passes the submit button to the cipher attachments component", () => {
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1]
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[0]
.componentInstance;
expect(cipherAttachment.submitBtn()).toEqual(submitBtn);

View File

@@ -5,7 +5,8 @@
[showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
isAutofillList
showAutofillButton
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
[groupByType]="groupByType()"
></app-vault-list-items-container>

View File

@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, map, Observable } from "rxjs";
import { combineLatest, map, Observable, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
@@ -42,6 +42,12 @@ export class AutofillVaultListItemsComponent {
*/
protected showRefresh: boolean = BrowserPopupUtils.inSidebar(window);
/** Flag indicating whether the login item should automatically autofill when clicked */
protected clickItemsToAutofillVaultView$: Observable<boolean> =
this.vaultSettingsService.clickItemsToAutofillVaultView$.pipe(
startWith(true), // Start with true to avoid flashing the fill button on first load
);
protected readonly groupByType = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)),
);

View File

@@ -8,14 +8,14 @@
></button>
<bit-menu #moreOptions>
@if (!decryptionFailure) {
<ng-container *ngIf="canAutofill && showAutofill()">
<ng-container *ngIf="canAutofill && !hideAutofillOptions">
<ng-container *ngIf="autofillAllowed$ | async">
<button type="button" bitMenuItem (click)="doAutofill()">
{{ "autofill" | i18n }}
</button>
</ng-container>
</ng-container>
<ng-container>
<ng-container *ngIf="showViewOption">
<button type="button" bitMenuItem (click)="onView()">
{{ "view" | i18n }}
</button>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, input, Input } from "@angular/core";
import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
@@ -76,10 +76,22 @@ export class ItemMoreOptionsComponent {
}
/**
* Flag to show the autofill menu options. Used for items that are
* Flag to show view item menu option. Used when something else is
* assigned as the primary action for the item, such as autofill.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
showViewOption = false;
/**
* Flag to hide the autofill menu options. Used for items that are
* already in the autofill list suggestion.
*/
readonly showAutofill = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
hideAutofillOptions = false;
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;

View File

@@ -90,11 +90,11 @@
</ng-container>
<cdk-virtual-scroll-viewport [itemSize]="itemHeight$ | async" bitScrollLayout>
<bit-item *cdkVirtualFor="let cipher of group.ciphers" class="tw-group/vault-item">
<bit-item *cdkVirtualFor="let cipher of group.ciphers">
<button
bit-item-content
type="button"
(click)="onCipherSelect(cipher)"
(click)="primaryActionOnSelect(cipher)"
(dblclick)="launchCipher(cipher)"
[appA11yTitle]="
cipherItemTitleKey()(cipher)
@@ -125,14 +125,19 @@
</button>
<ng-container slot="end">
<bit-item-action *ngIf="isAutofillList()">
<span
class="tw-opacity-0 tw-text-sm tw-text-primary-600 tw-px-2 group-hover/vault-item:tw-opacity-100 group-focus-within/vault-item:tw-opacity-100"
<bit-item-action *ngIf="!hideAutofillButton()">
<button
type="button"
bitBadge
variant="primary"
(click)="doAutofill(cipher)"
[title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
>
{{ "fill" | i18n }}
</span>
</button>
</bit-item-action>
<bit-item-action *ngIf="!isAutofillList() && CipherViewLikeUtils.canLaunch(cipher)">
<bit-item-action *ngIf="!showAutofillButton() && CipherViewLikeUtils.canLaunch(cipher)">
<button
type="button"
bitIconButton="bwi-external-link"
@@ -144,7 +149,8 @@
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<app-item-more-options
[cipher]="cipher"
[showAutofill]="!isAutofillList()"
[hideAutofillOptions]="hideAutofillMenuOptions()"
[showViewOption]="primaryActionAutofill()"
></app-item-more-options>
</ng-container>
</bit-item>

View File

@@ -136,18 +136,24 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/
private viewCipherTimeout?: number;
readonly ciphers = input<PopupCipherViewLike[]>([]);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
ciphers = input<PopupCipherViewLike[]>([]);
/**
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
readonly groupByType = input<boolean | undefined>(false);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
groupByType = input<boolean | undefined>(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
readonly cipherGroups = computed<
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherGroups = computed<
{
subHeaderKey?: string;
ciphers: PopupCipherViewLike[];
@@ -189,7 +195,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Title for the vault list item section.
*/
readonly title = input<string | undefined>(undefined);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
title = input<string | undefined>(undefined);
/**
* Optionally allow the items to be collapsed.
@@ -197,20 +205,24 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
* The key must be added to the state definition in `vault-popup-section.service.ts` since the
* collapsed state is stored locally.
*/
readonly collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
readonly description = input<string | undefined>(undefined);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
description = input<string | undefined>(undefined);
/**
* Option to show a refresh button in the section header.
*/
readonly showRefresh = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showRefresh = input(false, { transform: booleanAttribute });
/**
* Event emitted when the refresh button is clicked.
@@ -223,16 +235,23 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Flag indicating that the current tab location is blocked
*/
readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
/**
* Resolved i18n key to use for suggested cipher items
*/
readonly cipherItemTitleKey = computed(() => {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherItemTitleKey = computed(() => {
return (cipher: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(cipher);
const hasUsername = login?.username != null;
const key = !this.currentUriIsBlocked() ? "autofillTitle" : "viewItemTitle";
const key =
this.primaryActionAutofill() && !this.currentURIIsBlocked()
? "autofillTitle"
: "viewItemTitle";
return hasUsername ? `${key}WithField` : key;
};
});
@@ -240,25 +259,47 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Option to show the autofill button for each item.
*/
readonly isAutofillList = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showAutofillButton = input(false, { transform: booleanAttribute });
/**
* Computed property whether the cipher select action should perform autofill
* Flag indicating whether the suggested cipher item autofill button should be shown or not
*/
readonly shouldAutofillOnSelect = computed(
() => this.isAutofillList() && !this.currentUriIsBlocked(),
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillButton = computed(
() => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(),
);
/**
* Flag indicating whether the cipher item autofill menu options should be shown or not
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton());
/**
* Option to perform autofill operation as the primary action for autofill suggestions.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
primaryActionAutofill = input(false, { transform: booleanAttribute });
/**
* Remove the bottom margin from the bit-section in this component
* (used for containers at the end of the page where bottom margin is not needed)
*/
readonly disableSectionMargin = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableSectionMargin = input(false, { transform: booleanAttribute });
/**
* Remove the description margin
*/
readonly disableDescriptionMargin = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableDescriptionMargin = input(false, { transform: booleanAttribute });
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
@@ -272,7 +313,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
return collections[0]?.name;
}
protected readonly autofillShortcutTooltip = signal<string | undefined>(undefined);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected autofillShortcutTooltip = signal<string | undefined>(undefined);
constructor(
private i18nService: I18nService,
@@ -297,8 +340,10 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
}
}
onCipherSelect(cipher: PopupCipherViewLike) {
return this.shouldAutofillOnSelect() ? this.doAutofill(cipher) : this.onViewCipher(cipher);
primaryActionOnSelect(cipher: PopupCipherViewLike) {
return this.primaryActionAutofill() && !this.currentURIIsBlocked()
? this.doAutofill(cipher)
: this.onViewCipher(cipher);
}
/**

View File

@@ -50,10 +50,16 @@
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
</bit-label>
</bit-form-control>
<bit-form-control disableMargin>
<bit-form-control>
<input bitCheckbox formControlName="showQuickCopyActions" type="checkbox" />
<bit-label>{{ "showQuickCopyActions" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control disableMargin>
<input bitCheckbox formControlName="clickItemsToAutofillVaultView" type="checkbox" />
<bit-label>
{{ "clickToAutofill" | i18n }}
</bit-label>
</bit-form-control>
</bit-card>
</form>
</popup-page>

View File

@@ -59,12 +59,14 @@ describe("AppearanceV2Component", () => {
const enableRoutingAnimation$ = new BehaviorSubject<boolean>(true);
const enableCompactMode$ = new BehaviorSubject<boolean>(false);
const showQuickCopyActions$ = new BehaviorSubject<boolean>(false);
const clickItemsToAutofillVaultView$ = new BehaviorSubject<boolean>(false);
const setSelectedTheme = jest.fn().mockResolvedValue(undefined);
const setShowFavicons = jest.fn().mockResolvedValue(undefined);
const setEnableBadgeCounter = jest.fn().mockResolvedValue(undefined);
const setEnableRoutingAnimation = jest.fn().mockResolvedValue(undefined);
const setEnableCompactMode = jest.fn().mockResolvedValue(undefined);
const setShowQuickCopyActions = jest.fn().mockResolvedValue(undefined);
const setClickItemsToAutofillVaultView = jest.fn().mockResolvedValue(undefined);
const mockWidthService: Partial<PopupSizeService> = {
width$: new BehaviorSubject("default"),
@@ -111,7 +113,10 @@ describe("AppearanceV2Component", () => {
},
{
provide: VaultSettingsService,
useValue: mock<VaultSettingsService>(),
useValue: {
clickItemsToAutofillVaultView$,
setClickItemsToAutofillVaultView,
},
},
],
})
@@ -142,6 +147,7 @@ describe("AppearanceV2Component", () => {
enableCompactMode: false,
showQuickCopyActions: false,
width: "default",
clickItemsToAutofillVaultView: false,
});
});
@@ -187,5 +193,11 @@ describe("AppearanceV2Component", () => {
expect(mockWidthService.setWidth).toHaveBeenCalledWith("wide");
});
it("updates the click items to autofill vault view setting", () => {
component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true);
expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true);
});
});
});

View File

@@ -66,6 +66,7 @@ export class AppearanceV2Component implements OnInit {
enableCompactMode: false,
showQuickCopyActions: false,
width: "default" as PopupWidthOption,
clickItemsToAutofillVaultView: false,
});
/** To avoid flashes of inaccurate values, only show the form after the entire form is populated. */
@@ -111,6 +112,9 @@ export class AppearanceV2Component implements OnInit {
this.copyButtonsService.showQuickCopyActions$,
);
const width = await firstValueFrom(this.popupSizeService.width$);
const clickItemsToAutofillVaultView = await firstValueFrom(
this.vaultSettingsService.clickItemsToAutofillVaultView$,
);
// Set initial values for the form
this.appearanceForm.setValue({
@@ -121,6 +125,7 @@ export class AppearanceV2Component implements OnInit {
enableCompactMode,
showQuickCopyActions,
width,
clickItemsToAutofillVaultView,
});
this.formLoading = false;
@@ -166,6 +171,16 @@ export class AppearanceV2Component implements OnInit {
.subscribe((width) => {
void this.updateWidth(width);
});
this.appearanceForm.controls.clickItemsToAutofillVaultView.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((clickItemsToAutofillVaultView) => {
void this.updateClickItemsToAutofillVaultView(clickItemsToAutofillVaultView);
});
}
async updateClickItemsToAutofillVaultView(clickItemsToAutofillVaultView: boolean) {
await this.vaultSettingsService.setClickItemsToAutofillVaultView(clickItemsToAutofillVaultView);
}
async updateFavicon(enableFavicon: boolean) {

View File

@@ -918,6 +918,7 @@ dependencies = [
"oo7",
"pin-project",
"rand 0.9.2",
"rsa",
"scopeguard",
"secmem-proc",
"security-framework",
@@ -2115,10 +2116,11 @@ dependencies = [
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
dependencies = [
"byteorder",
"lazy_static",
"libm",
"num-integer",
@@ -2805,9 +2807,9 @@ dependencies = [
[[package]]
name = "rsa"
version = "0.9.10"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc"
dependencies = [
"const-oid",
"digest",

View File

@@ -50,7 +50,7 @@ oo7 = "=0.5.0"
pin-project = "=1.1.10"
pkcs8 = "=0.10.2"
rand = "=0.9.2"
rsa = "=0.9.10"
rsa = "=0.9.6"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
secmem-proc = "=0.3.7"

View File

@@ -31,6 +31,7 @@ futures = { workspace = true }
interprocess = { workspace = true, features = ["tokio"] }
memsec = { workspace = true, features = ["alloc_ext"] }
rand = { workspace = true }
rsa = "=0.9.6"
sha2 = { workspace = true }
ssh-key = { workspace = true, features = [
"encryption",
@@ -85,5 +86,8 @@ windows = { workspace = true, features = [
], optional = true }
windows-future = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
[lints]
workspace = true

View File

@@ -307,3 +307,128 @@ fn parse_key_safe(pem: &str) -> Result<ssh_key::private::PrivateKey, anyhow::Err
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use ssh_key::Signature;
use super::*;
// Test Ed25519 key (unencrypted OpenSSH format)
const TEST_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9gAAAJj79ujB+/bo
wQAAAAtzc2gtZWQyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9g
AAAEAgAQkLDKjON00XO+Y09BoIBuQsAXAx6HUhQoTEodVzig5iivf6TICxdizawaKSZS6G
nGZV/aEAZ3ZMrsrA3g32AAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ==
-----END OPENSSH PRIVATE KEY-----";
// Test RSA 2048-bit key (unencrypted OpenSSH format)
const TEST_RSA_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAy0YUFvgBLMZXIKjsBfcdO6N2Kk2VmjSpxa2aFD1TrAcVyyIZ9v8o
slQITyFL4GCK5VCJX9bqXBwc9ml8G/zt21ue6nadeZLhp2iXeQ+VUxmola9HhaFvxSNqi0
MOJaWIfmisH4jt7Msdv4jwlDE5AkHAFig8wiwDgvSV3kmfhyPs38aq8Pa+wT3zBneGXT17
34OhH4nicuq+L0GcR9BJQ5+jXNQIgGdqd7sKa8JchPXLXAbTug2SfwRmKgiCM0L6JQ5NSQ
FdRHW/iz4ARacSkHP3w0pH6ZtAd8+glzvZn1KcXwrN/CYl3fqFwiwcQXIF0KDoOI/UyiKZ
uDE+DW5M1wAAA8g2Sf0XNkn9FwAAAAdzc2gtcnNhAAABAQDLRhQW+AEsxlcgqOwF9x07o3
YqTZWaNKnFrZoUPVOsBxXLIhn2/yiyVAhPIUvgYIrlUIlf1upcHBz2aXwb/O3bW57qdp15
kuGnaJd5D5VTGaiVr0eFoW/FI2qLQw4lpYh+aKwfiO3syx2/iPCUMTkCQcAWKDzCLAOC9J
XeSZ+HI+zfxqrw9r7BPfMGd4ZdPXvfg6EfieJy6r4vQZxH0ElDn6Nc1AiAZ2p3uwprwlyE
9ctcBtO6DZJ/BGYqCIIzQvolDk1JAV1Edb+LPgBFpxKQc/fDSkfpm0B3z6CXO9mfUpxfCs
38JiXd+oXCLBxBcgXQoOg4j9TKIpm4MT4NbkzXAAAAAwEAAQAAAQB9HWssIAYJGyNxlMeB
fHJfzOLkctCME7ITXCEkKAMiNVIyr5CvuKnB6XsbyXC8cG/NaV7EwLGLdDpXaOHdEDcO9z
u/MLcIp2GA+x2QhAjzFy3uw+4P0CfNfVkM0n8YqOR0edTHrC5Vu0daJt19OTbPrsyeVrHf
Cdw3dHfyU/p+4IMP9NRA5ZSmYuOacC7ZoZU7xeVBpeZ4KEzrO98iIWtscncaQv4AcaAehL
VpvZWG1QmRhdbooU2ce5KH3aFKiyszcMGPMzn4aTZS14ycLFzmrMSa+nYf+nHXmyR5KmBd
A5P6ZLtcpT1xw6CC/ItRsdD7E67bugG38lgQpzloHAsRAAAAgBVKGMFi+lP+HKYdSzPAQN
n3HxVuuZ5VIjM6Rq2SxfdyGKj5PH4+ofNGBrF5j1du1oqfPypMM/B75bkBNOlzn6TQcgyX
YlsVOF31aE1hRg8eN1BH2bc1DC43MyTHgunAFzIYfs1hbX8i+cMybzXSTDsIc/xvQHkJ2w
TrPuz7+MATAAAAgQDk6e4ywxrINaOcuDKmRQxTs7rlkJk/tX59OkkqD/gYLMBRMfeKeuFD
Y8M1f5vlDkGFD/Jy0RtTfEJh02VjKTrszaaGCDFHe9tt6DAHY457tzr856zsq5hKDFEU0+
jd+yE8QaloegGrcpujrxHnrpZx/7mA2qjQxLveHyCGWH3Q2wAAAIEA41N7DKxeb0doXai7
Sl8+RpZBoyCyNkexWKHAeATKb4abd+k5/EEoLAb6aKaGMzMPm+s82l0lozVreKvHdAdZsY
fq1lhaVvnRWZhN/DXf7Akgicrg/TLqHH9w6db0Vg5A+zHmbkUzZ4A30CYIgn4vzVv5YIq3
CmfliIQWtUylhrUAAAAQdGVzdEBleGFtcGxlLmNvbQECAw==
-----END OPENSSH PRIVATE KEY-----";
fn create_test_agent() -> (
BitwardenDesktopAgent,
tokio::sync::mpsc::Receiver<SshAgentUIRequest>,
tokio::sync::broadcast::Sender<(u32, bool)>,
) {
let (request_tx, request_rx) = tokio::sync::mpsc::channel::<SshAgentUIRequest>(16);
let (response_tx, response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(16);
let response_rx = Arc::new(Mutex::new(response_rx));
let agent = BitwardenDesktopAgent::new(request_tx, response_rx);
(agent, request_rx, response_tx)
}
#[tokio::test]
async fn test_agent_sign_with_ed25519_key() {
let (mut agent, _request_rx, _response_tx) = create_test_agent();
agent.is_running.store(true, Ordering::Relaxed);
let keys = vec![(
TEST_ED25519_KEY.to_string(),
"ed25519-key".to_string(),
"ed25519-uuid".to_string(),
)];
agent.set_keys(keys).expect("set_keys should succeed");
let keystore = agent.keystore.0.read().expect("RwLock is not poisoned");
assert_eq!(keystore.len(), 1);
let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key");
// Verify the key metadata
assert_eq!(ssh_key.name, "ed25519-key");
assert_eq!(ssh_key.cipher_uuid, "ed25519-uuid");
// Verify the key can sign data
let signing_key = ssh_key.private_key().expect("should have signing key");
let message = b"test message for ed25519";
let signature: Signature = signing_key.try_sign(message).expect("signing should work");
// Verify signature is non-empty and has expected algorithm
assert!(!signature.as_bytes().is_empty());
assert_eq!(signature.algorithm(), ssh_key::Algorithm::Ed25519);
}
#[tokio::test]
async fn test_agent_sign_with_rsa_key() {
let (mut agent, _request_rx, _response_tx) = create_test_agent();
agent.is_running.store(true, Ordering::Relaxed);
let keys = vec![(
TEST_RSA_KEY.to_string(),
"rsa-key".to_string(),
"rsa-uuid".to_string(),
)];
agent.set_keys(keys).expect("set_keys should succeed");
let keystore = agent.keystore.0.read().expect("RwLock is not poisoned");
assert_eq!(keystore.len(), 1);
let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key");
// Verify the key metadata
assert_eq!(ssh_key.name, "rsa-key");
assert_eq!(ssh_key.cipher_uuid, "rsa-uuid");
// Verify the key can sign data
let signing_key = ssh_key.private_key().expect("should have signing key");
let message = b"test message for rsa";
let signature: Signature = signing_key.try_sign(message).expect("signing should work");
// Verify signature is non-empty and has expected algorithm
assert!(!signature.as_bytes().is_empty());
assert_eq!(
signature.algorithm(),
ssh_key::Algorithm::Rsa {
hash: Some(ssh_key::HashAlg::Sha512)
}
);
}
}

View File

@@ -0,0 +1,332 @@
#[napi]
pub mod autofill {
use desktop_core::ipc::server::{Message, MessageType};
use napi::{
bindgen_prelude::FnArgs,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::error;
#[napi]
pub async fn run_command(value: String) -> napi::Result<String> {
desktop_core::autofill::run_command(value)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[derive(Debug, serde::Serialize, serde:: Deserialize)]
pub enum BitwardenError {
Internal(String),
}
#[napi(string_enum)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UserVerification {
#[napi(value = "preferred")]
Preferred,
#[napi(value = "required")]
Required,
#[napi(value = "discouraged")]
Discouraged,
}
#[derive(Serialize, Deserialize)]
#[serde(bound = "T: Serialize + DeserializeOwned")]
pub struct PasskeyMessage<T: Serialize + DeserializeOwned> {
pub sequence_number: u32,
pub value: Result<T, BitwardenError>,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationRequest {
pub rp_id: String,
pub user_name: String,
pub user_handle: Vec<u8>,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub supported_algorithms: Vec<i32>,
pub window_xy: Position,
pub excluded_credentials: Vec<Vec<u8>>,
}
#[napi(object)]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationResponse {
pub rp_id: String,
pub client_data_hash: Vec<u8>,
pub credential_id: Vec<u8>,
pub attestation_object: Vec<u8>,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
pub rp_id: String,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub allowed_credentials: Vec<Vec<u8>>,
pub window_xy: Position,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
pub rp_id: String,
pub credential_id: Vec<u8>,
pub user_name: String,
pub user_handle: Vec<u8>,
pub record_identifier: Option<String>,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub window_xy: Position,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeStatus {
pub key: String,
pub value: String,
}
#[napi(object)]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionResponse {
pub rp_id: String,
pub user_handle: Vec<u8>,
pub signature: Vec<u8>,
pub client_data_hash: Vec<u8>,
pub authenticator_data: Vec<u8>,
pub credential_id: Vec<u8>,
}
#[napi]
pub struct AutofillIpcServer {
server: desktop_core::ipc::server::Server,
}
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
#[napi]
impl AutofillIpcServer {
/// Create and start the IPC server without blocking.
///
/// @param name The endpoint name to listen on. This name uniquely identifies the IPC
/// connection and must be the same for both the server and client. @param callback
/// This function will be called whenever a message is received from a client.
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi(factory)]
pub async fn listen(
name: String,
// Ideally we'd have a single callback that has an enum containing the request values,
// but NAPI doesn't support that just yet
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void"
)]
registration_callback: ThreadsafeFunction<
FnArgs<(u32, u32, PasskeyRegistrationRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void"
)]
assertion_callback: ThreadsafeFunction<
FnArgs<(u32, u32, PasskeyAssertionRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void"
)]
assertion_without_user_interface_callback: ThreadsafeFunction<
FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void"
)]
native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>,
) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
while let Some(Message {
client_id,
kind,
message,
}) = recv.recv().await
{
match kind {
// TODO: We're ignoring the connection and disconnection messages for now
MessageType::Connected | MessageType::Disconnected => continue,
MessageType::Message => {
let Some(message) = message else {
error!("Message is empty");
continue;
};
match serde_json::from_str::<PasskeyMessage<PasskeyAssertionRequest>>(
&message,
) {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
assertion_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(e) => {
error!(error = %e, "Error deserializing message1");
}
}
match serde_json::from_str::<
PasskeyMessage<PasskeyAssertionWithoutUserInterfaceRequest>,
>(&message)
{
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
assertion_without_user_interface_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(e) => {
error!(error = %e, "Error deserializing message1");
}
}
match serde_json::from_str::<PasskeyMessage<PasskeyRegistrationRequest>>(
&message,
) {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
registration_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(e) => {
error!(error = %e, "Error deserializing message2");
}
}
match serde_json::from_str::<PasskeyMessage<NativeStatus>>(&message) {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
native_status_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(error) => {
error!(%error, "Unable to deserialze native status.");
}
}
error!(message, "Received an unknown message2");
}
}
}
});
let path = desktop_core::ipc::path(&name);
let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| {
napi::Error::from_reason(format!(
"Error listening to server - Path: {path:?} - Error: {e} - {e:?}"
))
})?;
Ok(AutofillIpcServer { server })
}
/// Return the path to the IPC server.
#[napi]
pub fn get_path(&self) -> String {
self.server.path.to_string_lossy().to_string()
}
/// Stop the IPC server.
#[napi]
pub fn stop(&self) -> napi::Result<()> {
self.server.stop();
Ok(())
}
#[napi]
pub fn complete_registration(
&self,
client_id: u32,
sequence_number: u32,
response: PasskeyRegistrationResponse,
) -> napi::Result<u32> {
let message = PasskeyMessage {
sequence_number,
value: Ok(response),
};
self.send(client_id, serde_json::to_string(&message).unwrap())
}
#[napi]
pub fn complete_assertion(
&self,
client_id: u32,
sequence_number: u32,
response: PasskeyAssertionResponse,
) -> napi::Result<u32> {
let message = PasskeyMessage {
sequence_number,
value: Ok(response),
};
self.send(client_id, serde_json::to_string(&message).unwrap())
}
#[napi]
pub fn complete_error(
&self,
client_id: u32,
sequence_number: u32,
error: String,
) -> napi::Result<u32> {
let message: PasskeyMessage<()> = PasskeyMessage {
sequence_number,
value: Err(BitwardenError::Internal(error)),
};
self.send(client_id, serde_json::to_string(&message).unwrap())
}
// TODO: Add a way to send a message to a specific client?
fn send(&self, _client_id: u32, message: String) -> napi::Result<u32> {
self.server
.send(message)
.map_err(|e| {
napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}"))
})
// NAPI doesn't support u64 or usize, so we need to convert to u32
.map(|u| u32::try_from(u).unwrap_or_default())
}
}
}

View File

@@ -0,0 +1,9 @@
#[napi]
pub mod autostart {
#[napi]
pub async fn set_autostart(autostart: bool, params: Vec<String>) -> napi::Result<()> {
desktop_core::autostart::set_autostart(autostart, params)
.await
.map_err(|e| napi::Error::from_reason(format!("Error setting autostart - {e} - {e:?}")))
}
}

View File

@@ -0,0 +1,20 @@
#[napi]
pub mod autotype {
#[napi]
pub fn get_foreground_window_title() -> napi::Result<String> {
autotype::get_foreground_window_title().map_err(|_| {
napi::Error::from_reason(
"Autotype Error: failed to get foreground window title".to_string(),
)
})
}
#[napi]
pub fn type_input(
input: Vec<u16>,
keyboard_shortcut: Vec<String>,
) -> napi::Result<(), napi::Status> {
autotype::type_input(&input, &keyboard_shortcut)
.map_err(|e| napi::Error::from_reason(format!("Autotype Error: {e}")))
}
}

View File

@@ -0,0 +1,100 @@
#[napi]
pub mod biometrics {
use desktop_core::biometric::{Biometric, BiometricTrait};
// Prompt for biometric confirmation
#[napi]
pub async fn prompt(
hwnd: napi::bindgen_prelude::Buffer,
message: String,
) -> napi::Result<bool> {
Biometric::prompt(hwnd.into(), message)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn available() -> napi::Result<bool> {
Biometric::available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn set_biometric_secret(
service: String,
account: String,
secret: String,
key_material: Option<KeyMaterial>,
iv_b64: String,
) -> napi::Result<String> {
Biometric::set_biometric_secret(
&service,
&account,
&secret,
key_material.map(|m| m.into()),
&iv_b64,
)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Retrieves the biometric secret for the given service and account.
/// Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist.
#[napi]
pub async fn get_biometric_secret(
service: String,
account: String,
key_material: Option<KeyMaterial>,
) -> napi::Result<String> {
Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into()))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Derives key material from biometric data. Returns a string encoded with a
/// base64 encoded key and the base64 encoded challenge used to create it
/// separated by a `|` character.
///
/// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will
/// be generated.
///
/// `format!("<key_base64>|<iv_base64>")`
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn derive_key_material(iv: Option<String>) -> napi::Result<OsDerivedKey> {
Biometric::derive_key_material(iv.as_deref())
.map(|k| k.into())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi(object)]
pub struct KeyMaterial {
pub os_key_part_b64: String,
pub client_key_part_b64: Option<String>,
}
impl From<KeyMaterial> for desktop_core::biometric::KeyMaterial {
fn from(km: KeyMaterial) -> Self {
desktop_core::biometric::KeyMaterial {
os_key_part_b64: km.os_key_part_b64,
client_key_part_b64: km.client_key_part_b64,
}
}
}
#[napi(object)]
pub struct OsDerivedKey {
pub key_b64: String,
pub iv_b64: String,
}
impl From<desktop_core::biometric::OsDerivedKey> for OsDerivedKey {
fn from(km: desktop_core::biometric::OsDerivedKey) -> Self {
OsDerivedKey {
key_b64: km.key_b64,
iv_b64: km.iv_b64,
}
}
}
}

View File

@@ -0,0 +1,116 @@
#[napi]
pub mod biometrics_v2 {
use desktop_core::biometric_v2::BiometricTrait;
#[napi]
pub struct BiometricLockSystem {
inner: desktop_core::biometric_v2::BiometricLockSystem,
}
#[napi]
pub fn init_biometric_system() -> napi::Result<BiometricLockSystem> {
Ok(BiometricLockSystem {
inner: desktop_core::biometric_v2::BiometricLockSystem::new(),
})
}
#[napi]
pub async fn authenticate(
biometric_lock_system: &BiometricLockSystem,
hwnd: napi::bindgen_prelude::Buffer,
message: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.authenticate(hwnd.into(), message)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn authenticate_available(
biometric_lock_system: &BiometricLockSystem,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.authenticate_available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn enroll_persistent(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
key: napi::bindgen_prelude::Buffer,
) -> napi::Result<()> {
biometric_lock_system
.inner
.enroll_persistent(&user_id, &key)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn provide_key(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
key: napi::bindgen_prelude::Buffer,
) -> napi::Result<()> {
biometric_lock_system
.inner
.provide_key(&user_id, &key)
.await;
Ok(())
}
#[napi]
pub async fn unlock(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
hwnd: napi::bindgen_prelude::Buffer,
) -> napi::Result<napi::bindgen_prelude::Buffer> {
biometric_lock_system
.inner
.unlock(&user_id, hwnd.into())
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
.map(|v| v.into())
}
#[napi]
pub async fn unlock_available(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.unlock_available(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn has_persistent(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.has_persistent(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn unenroll(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<()> {
biometric_lock_system
.inner
.unenroll(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,116 @@
#[napi]
pub mod chromium_importer {
use std::collections::HashMap;
use chromium_importer::{
chromium::{
DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult,
ProfileInfo as _ProfileInfo,
},
metadata::NativeImporterMetadata as _NativeImporterMetadata,
};
#[napi(object)]
pub struct ProfileInfo {
pub id: String,
pub name: String,
}
#[napi(object)]
pub struct Login {
pub url: String,
pub username: String,
pub password: String,
pub note: String,
}
#[napi(object)]
pub struct LoginImportFailure {
pub url: String,
pub username: String,
pub error: String,
}
#[napi(object)]
pub struct LoginImportResult {
pub login: Option<Login>,
pub failure: Option<LoginImportFailure>,
}
#[napi(object)]
pub struct NativeImporterMetadata {
pub id: String,
pub loaders: Vec<String>,
pub instructions: String,
}
impl From<_LoginImportResult> for LoginImportResult {
fn from(l: _LoginImportResult) -> Self {
match l {
_LoginImportResult::Success(l) => LoginImportResult {
login: Some(Login {
url: l.url,
username: l.username,
password: l.password,
note: l.note,
}),
failure: None,
},
_LoginImportResult::Failure(l) => LoginImportResult {
login: None,
failure: Some(LoginImportFailure {
url: l.url,
username: l.username,
error: l.error,
}),
},
}
}
}
impl From<_ProfileInfo> for ProfileInfo {
fn from(p: _ProfileInfo) -> Self {
ProfileInfo {
id: p.folder,
name: p.name,
}
}
}
impl From<_NativeImporterMetadata> for NativeImporterMetadata {
fn from(m: _NativeImporterMetadata) -> Self {
NativeImporterMetadata {
id: m.id,
loaders: m.loaders,
instructions: m.instructions,
}
}
}
#[napi]
/// Returns OS aware metadata describing supported Chromium based importers as a JSON string.
pub fn get_metadata() -> HashMap<String, NativeImporterMetadata> {
chromium_importer::metadata::get_supported_importers::<DefaultInstalledBrowserRetriever>()
.into_iter()
.map(|(browser, metadata)| (browser, NativeImporterMetadata::from(metadata)))
.collect()
}
#[napi]
pub fn get_available_profiles(browser: String) -> napi::Result<Vec<ProfileInfo>> {
chromium_importer::chromium::get_available_profiles(&browser)
.map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn import_logins(
browser: String,
profile_id: String,
) -> napi::Result<Vec<LoginImportResult>> {
chromium_importer::chromium::import_logins(&browser, &profile_id)
.await
.map(|logins| logins.into_iter().map(LoginImportResult::from).collect())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,15 @@
#[napi]
pub mod clipboards {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn read() -> napi::Result<String> {
desktop_core::clipboard::read().map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn write(text: String, password: bool) -> napi::Result<()> {
desktop_core::clipboard::write(&text, password)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,106 @@
#[napi]
pub mod ipc {
use desktop_core::ipc::server::{Message, MessageType};
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
#[napi(object)]
pub struct IpcMessage {
pub client_id: u32,
pub kind: IpcMessageType,
pub message: Option<String>,
}
impl From<Message> for IpcMessage {
fn from(message: Message) -> Self {
IpcMessage {
client_id: message.client_id,
kind: message.kind.into(),
message: message.message,
}
}
}
#[napi]
pub enum IpcMessageType {
Connected,
Disconnected,
Message,
}
impl From<MessageType> for IpcMessageType {
fn from(message_type: MessageType) -> Self {
match message_type {
MessageType::Connected => IpcMessageType::Connected,
MessageType::Disconnected => IpcMessageType::Disconnected,
MessageType::Message => IpcMessageType::Message,
}
}
}
#[napi]
pub struct NativeIpcServer {
server: desktop_core::ipc::server::Server,
}
#[napi]
impl NativeIpcServer {
/// Create and start the IPC server without blocking.
///
/// @param name The endpoint name to listen on. This name uniquely identifies the IPC
/// connection and must be the same for both the server and client. @param callback
/// This function will be called whenever a message is received from a client.
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi(factory)]
pub async fn listen(
name: String,
#[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")]
callback: ThreadsafeFunction<IpcMessage>,
) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
while let Some(message) = recv.recv().await {
callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
}
});
let path = desktop_core::ipc::path(&name);
let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| {
napi::Error::from_reason(format!(
"Error listening to server - Path: {path:?} - Error: {e} - {e:?}"
))
})?;
Ok(NativeIpcServer { server })
}
/// Return the path to the IPC server.
#[napi]
pub fn get_path(&self) -> String {
self.server.path.to_string_lossy().to_string()
}
/// Stop the IPC server.
#[napi]
pub fn stop(&self) -> napi::Result<()> {
self.server.stop();
Ok(())
}
/// Send a message over the IPC server to all the connected clients
///
/// @return The number of clients that the message was sent to. Note that the number of
/// messages actually received may be less, as some clients could disconnect before
/// receiving the message.
#[napi]
pub fn send(&self, message: String) -> napi::Result<u32> {
self.server
.send(message)
.map_err(|e| {
napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}"))
})
// NAPI doesn't support u64 or usize, so we need to convert to u32
.map(|u| u32::try_from(u).unwrap_or_default())
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
#[napi]
pub mod logging {
//! `logging` is the interface between the native desktop's usage of the `tracing` crate
//! for logging, to intercept events and write to the JS space.
//!
//! # Example
//!
//! [Elec] 14:34:03.517 [NAPI] [INFO] desktop_core::ssh_agent::platform_ssh_agent: Starting
//! SSH Agent server {socket=/Users/foo/.bitwarden-ssh-agent.sock}
use std::{fmt::Write, sync::OnceLock};
use napi::{
bindgen_prelude::FnArgs,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use tracing::Level;
use tracing_subscriber::{
filter::EnvFilter,
fmt::format::{DefaultVisitor, Writer},
layer::SubscriberExt,
util::SubscriberInitExt,
Layer,
};
struct JsLogger(OnceLock<ThreadsafeFunction<FnArgs<(LogLevel, String)>>>);
static JS_LOGGER: JsLogger = JsLogger(OnceLock::new());
#[napi]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl From<&Level> for LogLevel {
fn from(level: &Level) -> Self {
match *level {
Level::TRACE => LogLevel::Trace,
Level::DEBUG => LogLevel::Debug,
Level::INFO => LogLevel::Info,
Level::WARN => LogLevel::Warn,
Level::ERROR => LogLevel::Error,
}
}
}
// JsLayer lets us intercept events and write them to the JS Logger.
struct JsLayer;
impl<S> Layer<S> for JsLayer
where
S: tracing::Subscriber,
{
// This function builds a log message buffer from the event data and
// calls the JS logger with it.
//
// For example, this log call:
//
// ```
// mod supreme {
// mod module {
// let foo = "bar";
// info!(best_variable_name = %foo, "Foo done it again.");
// }
// }
// ```
//
// , results in the following string:
//
// [INFO] supreme::module: Foo done it again. {best_variable_name=bar}
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut buffer = String::new();
// create the preamble text that precedes the message and vars. e.g.:
// [INFO] desktop_core::ssh_agent::platform_ssh_agent:
let level = event.metadata().level().as_str();
let module_path = event.metadata().module_path().unwrap_or_default();
write!(&mut buffer, "[{level}] {module_path}:")
.expect("Failed to write tracing event to buffer");
let writer = Writer::new(&mut buffer);
// DefaultVisitor adds the message and variables to the buffer
let mut visitor = DefaultVisitor::new(writer, false);
event.record(&mut visitor);
let msg = (event.metadata().level().into(), buffer);
if let Some(logger) = JS_LOGGER.0.get() {
let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking);
};
}
}
#[napi]
pub fn init_napi_log(js_log_fn: ThreadsafeFunction<FnArgs<(LogLevel, String)>>) {
let _ = JS_LOGGER.0.set(js_log_fn);
// the log level hierarchy is determined by:
// - if RUST_LOG is detected at runtime
// - if RUST_LOG is provided at compile time
// - default to INFO
let filter = EnvFilter::builder()
.with_default_directive(
option_env!("RUST_LOG")
.unwrap_or("info")
.parse()
.expect("should provide valid log level at compile time."),
)
// parse directives from the RUST_LOG environment variable,
// overriding the default directive for matching targets.
.from_env_lossy();
// With the `tracing-log` feature enabled for the `tracing_subscriber`,
// the registry below will initialize a log compatibility layer, which allows
// the subscriber to consume log::Records as though they were tracing Events.
// https://docs.rs/tracing-subscriber/latest/tracing_subscriber/util/trait.SubscriberInitExt.html#method.init
tracing_subscriber::registry()
.with(filter)
.with(JsLayer)
.init();
}
}

View File

@@ -0,0 +1,9 @@
#[napi]
pub mod passkey_authenticator {
#[napi]
pub fn register() -> napi::Result<()> {
crate::passkey_authenticator_internal::register().map_err(|e| {
napi::Error::from_reason(format!("Passkey registration failed - Error: {e} - {e:?}"))
})
}
}

View File

@@ -0,0 +1,46 @@
#[napi]
pub mod passwords {
/// The error message returned when a password is not found during retrieval or deletion.
#[napi]
pub const PASSWORD_NOT_FOUND: &str = desktop_core::password::PASSWORD_NOT_FOUND;
/// Fetch the stored password from the keychain.
/// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
#[napi]
pub async fn get_password(service: String, account: String) -> napi::Result<String> {
desktop_core::password::get_password(&service, &account)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Save the password to the keychain. Adds an entry if none exists otherwise updates the
/// existing entry.
#[napi]
pub async fn set_password(
service: String,
account: String,
password: String,
) -> napi::Result<()> {
desktop_core::password::set_password(&service, &account, &password)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Delete the stored password from the keychain.
/// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
#[napi]
pub async fn delete_password(service: String, account: String) -> napi::Result<()> {
desktop_core::password::delete_password(&service, &account)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Checks if the os secure storage is available
#[napi]
pub async fn is_available() -> napi::Result<bool> {
desktop_core::password::is_available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,26 @@
#[napi]
pub mod powermonitors {
use napi::{
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
tokio,
};
#[napi]
pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> {
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
desktop_core::powermonitor::on_lock(tx)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
tokio::spawn(async move {
while let Some(()) = rx.recv().await {
callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
}
});
Ok(())
}
#[napi]
pub async fn is_lock_monitor_available() -> napi::Result<bool> {
Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
}
}

View File

@@ -0,0 +1,23 @@
#[napi]
pub mod processisolations {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn disable_coredumps() -> napi::Result<()> {
desktop_core::process_isolation::disable_coredumps()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn is_core_dumping_disabled() -> napi::Result<bool> {
desktop_core::process_isolation::is_core_dumping_disabled()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn isolate_process() -> napi::Result<()> {
desktop_core::process_isolation::isolate_process()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,163 @@
#[napi]
pub mod sshagent {
use std::sync::Arc;
use napi::{
bindgen_prelude::Promise,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use tokio::{self, sync::Mutex};
use tracing::error;
#[napi]
pub struct SshAgentState {
state: desktop_core::ssh_agent::BitwardenDesktopAgent,
}
#[napi(object)]
pub struct PrivateKey {
pub private_key: String,
pub name: String,
pub cipher_id: String,
}
#[napi(object)]
pub struct SshKey {
pub private_key: String,
pub public_key: String,
pub key_fingerprint: String,
}
#[napi(object)]
pub struct SshUIRequest {
pub cipher_id: Option<String>,
pub is_list: bool,
pub process_name: String,
pub is_forwarding: bool,
pub namespace: Option<String>,
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn serve(
callback: ThreadsafeFunction<SshUIRequest, Promise<bool>>,
) -> napi::Result<SshAgentState> {
let (auth_request_tx, mut auth_request_rx) =
tokio::sync::mpsc::channel::<desktop_core::ssh_agent::SshAgentUIRequest>(32);
let (auth_response_tx, auth_response_rx) =
tokio::sync::broadcast::channel::<(u32, bool)>(32);
let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
// Wrap callback in Arc so it can be shared across spawned tasks
let callback = Arc::new(callback);
tokio::spawn(async move {
let _ = auth_response_rx;
while let Some(request) = auth_request_rx.recv().await {
let cloned_response_tx_arc = auth_response_tx_arc.clone();
let cloned_callback = callback.clone();
tokio::spawn(async move {
let auth_response_tx_arc = cloned_response_tx_arc;
let callback = cloned_callback;
// In NAPI v3, obtain the JS callback return as a Promise<boolean> and await it
// in Rust
let (tx, rx) = std::sync::mpsc::channel::<Promise<bool>>();
let status = callback.call_with_return_value(
Ok(SshUIRequest {
cipher_id: request.cipher_id,
is_list: request.is_list,
process_name: request.process_name,
is_forwarding: request.is_forwarding,
namespace: request.namespace,
}),
ThreadsafeFunctionCallMode::Blocking,
move |ret: Result<Promise<bool>, napi::Error>, _env| {
if let Ok(p) = ret {
let _ = tx.send(p);
}
Ok(())
},
);
let result = if status == napi::Status::Ok {
match rx.recv() {
Ok(promise) => match promise.await {
Ok(v) => v,
Err(e) => {
error!(error = %e, "UI callback promise rejected");
false
}
},
Err(e) => {
error!(error = %e, "Failed to receive UI callback promise");
false
}
}
} else {
error!(error = ?status, "Calling UI callback failed");
false
};
let _ = auth_response_tx_arc
.lock()
.await
.send((request.request_id, result))
.expect("should be able to send auth response to agent");
});
}
});
match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server(
auth_request_tx,
Arc::new(Mutex::new(auth_response_rx)),
) {
Ok(state) => Ok(SshAgentState { state }),
Err(e) => Err(napi::Error::from_reason(e.to_string())),
}
}
#[napi]
pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
bitwarden_agent_state.stop();
Ok(())
}
#[napi]
pub fn is_running(agent_state: &mut SshAgentState) -> bool {
let bitwarden_agent_state = agent_state.state.clone();
bitwarden_agent_state.is_running()
}
#[napi]
pub fn set_keys(
agent_state: &mut SshAgentState,
new_keys: Vec<PrivateKey>,
) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
bitwarden_agent_state
.set_keys(
new_keys
.iter()
.map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone()))
.collect(),
)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(())
}
#[napi]
pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
bitwarden_agent_state
.lock()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
bitwarden_agent_state
.clear_keys()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -0,0 +1,16 @@
#[napi]
pub mod windows_registry {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn create_key(key: String, subkey: String, value: String) -> napi::Result<()> {
crate::registry::create_key(&key, &subkey, &value)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn delete_key(key: String, subkey: String) -> napi::Result<()> {
crate::registry::delete_key(&key, &subkey)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}

View File

@@ -87,6 +87,10 @@ describe("DesktopSetInitialPasswordService", () => {
expect(sut).not.toBeFalsy();
});
/**
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
* in the test setup above that are now un-used and can also be removed.
*/
describe("setInitialPassword(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;
@@ -116,6 +120,8 @@ describe("DesktopSetInitialPasswordService", () => {
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId",
resetPasswordAutoEnroll: false,
newPassword: "Test@Password123!",
salt: "user@example.com" as MasterPasswordSalt,
};
userId = "userId" as UserId;
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;

View File

@@ -54,6 +54,9 @@ export class DesktopSetInitialPasswordService
);
}
/**
* @deprecated To be removed in PM-28143
*/
override async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,

View File

@@ -121,7 +121,7 @@
}}"
[attr.aria-pressed]="activeFilter.selectedOrganizationId === organization.id"
>
<i class="bwi bwi-fw bwi-business" aria-hidden="true"></i>
<i class="bwi bwi-fw {{ getIconString(organization) }}" aria-hidden="true"></i>
&nbsp;{{ organization.name }}
</button>
<span *ngIf="!organization.enabled" class="tw-ml-auto">

View File

@@ -5,6 +5,7 @@ import { Component } from "@angular/core";
import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component";
import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
@@ -50,4 +51,15 @@ export class OrganizationFilterComponent extends BaseOrganizationFilterComponent
});
}
}
getIconString(organization: Organization): string {
if (
organization?.productTierType === ProductTierType.Free ||
organization?.productTierType === ProductTierType.Families
) {
return "bwi-family";
} else {
return "bwi-business";
}
}
}

View File

@@ -518,6 +518,7 @@ export class VaultV2Component<C extends CipherViewLike>
}
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
cipherId: this.cipherId as CipherId,
canEditCipher: this.cipher().edit,
});
const result = await firstValueFrom(dialogRef.closed).catch((): any => null);
if (

View File

@@ -15,6 +15,12 @@ RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \
rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \
fi
# Override SDK if custom artifacts are present
RUN if [ -d "sdk-internal" ]; then \
echo "Overriding SDK with custom artifacts from sdk-internal" ; \
npm link ./sdk-internal ; \
fi
WORKDIR /source/apps/web
ARG NPM_COMMAND=dist:bit:selfhost
RUN npm run ${NPM_COMMAND}

View File

@@ -6,6 +6,7 @@ import { BehaviorSubject, of } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserResetPasswordDetailsResponse,
OrganizationUserResetPasswordRequest,
} from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -13,6 +14,15 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationData,
MasterPasswordAuthenticationHash,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -21,7 +31,7 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
import { KdfType, KeyService } from "@bitwarden/key-management";
import { DEFAULT_KDF_CONFIG, KdfConfig, KdfType, KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
@@ -39,6 +49,8 @@ describe("OrganizationUserResetPasswordService", () => {
let i18nService: MockProxy<I18nService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
let configService: MockProxy<ConfigService>;
beforeAll(() => {
keyService = mock<KeyService>();
@@ -48,6 +60,8 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService = mock<OrganizationApiService>();
i18nService = mock<I18nService>();
accountService = mockAccountServiceWith(mockUserId);
masterPasswordService = new FakeMasterPasswordService();
configService = mock<ConfigService>();
sut = new OrganizationUserResetPasswordService(
keyService,
@@ -57,6 +71,8 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService,
i18nService,
accountService,
masterPasswordService,
configService,
);
});
@@ -129,13 +145,23 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
describe("resetMasterPassword", () => {
/**
* @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are
* any imports/properties in the test setup above that are now un-used and can also be removed.
*/
describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = false;
const mockNewMP = "new-password";
const mockEmail = "test@example.com";
const mockOrgUserId = "test-org-user-id";
const mockOrgId = "test-org-id";
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(
PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
);
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
new OrganizationUserResetPasswordDetailsResponse({
kdf: KdfType.PBKDF2_SHA256,
@@ -185,6 +211,164 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
// Mock sut method parameters
const newMasterPassword = "new-master-password";
const email = "user@example.com";
const orgUserId = "org-user-id";
const orgId = "org-id" as OrganizationId;
// Mock feature flag value
const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = true;
// Mock method data
let organizationUserResetPasswordDetailsResponse: OrganizationUserResetPasswordDetailsResponse;
let salt: MasterPasswordSalt;
let kdfConfig: KdfConfig;
let authenticationData: MasterPasswordAuthenticationData;
let unlockData: MasterPasswordUnlockData;
let userKey: UserKey;
beforeEach(() => {
// Mock feature flag value
configService.getFeatureFlag.mockResolvedValue(
PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
);
// Mock method data
kdfConfig = DEFAULT_KDF_CONFIG;
organizationUserResetPasswordDetailsResponse =
new OrganizationUserResetPasswordDetailsResponse({
organizationUserId: orgUserId,
kdf: kdfConfig.kdfType,
kdfIterations: kdfConfig.iterations,
resetPasswordKey: "test-reset-password-key",
encryptedPrivateKey: "test-encrypted-private-key",
});
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
organizationUserResetPasswordDetailsResponse,
);
const mockDecryptedOrgKeyBytes = new Uint8Array(64).fill(1);
const mockDecryptedOrgKey = new SymmetricCryptoKey(mockDecryptedOrgKeyBytes) as OrgKey;
keyService.orgKeys$.mockReturnValue(
of({ [orgId]: mockDecryptedOrgKey } as Record<OrganizationId, OrgKey>),
);
const mockDecryptedPrivateKeyBytes = new Uint8Array(64).fill(2);
encryptService.unwrapDecapsulationKey.mockResolvedValue(mockDecryptedPrivateKeyBytes);
const mockDecryptedUserKeyBytes = new Uint8Array(64).fill(3);
const mockUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes);
encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockUserKey); // returns `SymmetricCryptoKey`
userKey = mockUserKey as UserKey; // type cast to `UserKey` (see code implementation). Points to same object as mockUserKey.
salt = email as MasterPasswordSalt;
masterPasswordService.mock.emailToSalt.mockReturnValue(salt);
authenticationData = {
salt,
kdf: kdfConfig,
masterPasswordAuthenticationHash:
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
};
unlockData = {
salt,
kdf: kdfConfig,
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
} as MasterPasswordUnlockData;
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
authenticationData,
);
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
});
it("should throw an error if the organizationUserResetPasswordDetailsResponse is nullish", async () => {
// Arrange
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(null);
// Act
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
await expect(promise).rejects.toThrow();
});
it("should throw an error if the org key cannot be found", async () => {
// Arrange
keyService.orgKeys$.mockReturnValue(of({} as Record<OrganizationId, OrgKey>));
// Act
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
await expect(promise).rejects.toThrow("No org key found");
});
it("should throw an error if orgKeys$ returns null", async () => {
// Arrange
keyService.orgKeys$.mockReturnValue(of(null));
// Act
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
await expect(promise).rejects.toThrow();
});
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
// Act
await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
const request = OrganizationUserResetPasswordRequest.newConstructor(
authenticationData,
unlockData,
);
expect(masterPasswordService.mock.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
newMasterPassword,
kdfConfig,
salt,
);
expect(masterPasswordService.mock.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
newMasterPassword,
kdfConfig,
salt,
userKey,
);
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
orgId,
orgUserId,
request,
);
});
it("should call the API method to reset the user's master password", async () => {
// Act
await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
// Assert
const request = OrganizationUserResetPasswordRequest.newConstructor(
authenticationData,
unlockData,
);
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledTimes(1);
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
orgId,
orgUserId,
request,
);
});
});
describe("getPublicKeys", () => {
it("should return public keys for organizations that have reset password enrolled", async () => {
const result = await sut.getPublicKeys("userId" as UserId);

View File

@@ -12,11 +12,15 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -47,6 +51,8 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
private organizationApiService: OrganizationApiServiceAbstraction,
private i18nService: I18nService,
private accountService: AccountService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private configService: ConfigService,
) {}
/**
@@ -140,6 +146,44 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
? new PBKDF2KdfConfig(response.kdfIterations)
: new Argon2KdfConfig(response.kdfIterations, response.kdfMemory, response.kdfParallelism);
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
);
if (newApisWithInputPasswordFlagEnabled) {
const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
// Create authentication and unlock data
const authenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
newMasterPassword,
kdfConfig,
salt,
);
const unlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
newMasterPassword,
kdfConfig,
salt,
existingUserKey,
);
// Create request
const request = OrganizationUserResetPasswordRequest.newConstructor(
authenticationData,
unlockData,
);
// Change user's password
await this.organizationUserApiService.putOrganizationUserResetPassword(
orgId,
orgUserId,
request,
);
return; // EARLY RETURN for flagged code
}
// Create new master key and hash new password
const newMasterKey = await this.keyService.makeMasterKey(
newMasterPassword,

View File

@@ -90,6 +90,10 @@ describe("WebSetInitialPasswordService", () => {
expect(sut).not.toBeFalsy();
});
/**
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
* in the test setup above that are now un-used and can also be removed.
*/
describe("setInitialPassword(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;
@@ -119,6 +123,8 @@ describe("WebSetInitialPasswordService", () => {
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId",
resetPasswordAutoEnroll: false,
newPassword: "Test@Password123!",
salt: "user@example.com" as MasterPasswordSalt,
};
userId = "userId" as UserId;
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;

View File

@@ -56,6 +56,9 @@ export class WebSetInitialPasswordService
);
}
/**
* @deprecated To be removed in PM-28143
*/
override async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,

View File

@@ -54,7 +54,7 @@
{{ "owner" | i18n }}
</th>
}
<th bitCell class="tw-text-right" bitSortable="scoreKey" default>
<th bitCell class="tw-text-right" bitSortable="scoreKey" default="desc">
{{ "weakness" | i18n }}
</th>
</ng-container>

View File

@@ -8,7 +8,7 @@
[ngClass]="{ 'tw-grayscale': disabled }"
>
<div class="tw-m-auto tw-size-20 tw-content-center">
<bit-svg [content]="icon" aria-hidden="true"></bit-svg>
<bit-svg [content]="icon" aria-hidden="true" class="tw-h-full"></bit-svg>
</div>
</div>
<bit-card-content [ngClass]="{ 'tw-grayscale': disabled }">

View File

@@ -3,8 +3,7 @@ import { FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import {
emailAndOtpRequiredEmailSent,
emailInvalid,
emailAndOtpRequired,
emailRequired,
otpInvalid,
passwordHashB64Invalid,
@@ -161,7 +160,7 @@ export class SendAuthComponent implements OnInit {
this.expiredAuthAttempts = 0;
if (emailRequired(response.error)) {
this.sendAuthType.set(AuthType.Email);
} else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) {
} else if (emailAndOtpRequired(response.error)) {
this.enterOtp.set(true);
} else if (otpInvalid(response.error)) {
this.toastService.showToast({

View File

@@ -593,7 +593,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemsWereSentToArchive"),
message: this.i18nService.t("itemWasSentToArchive"),
});
} catch {
this.toastService.showToast({

View File

@@ -157,7 +157,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
// If item is archived always show unarchive button, even if user is not premium
protected get showUnArchiveButton() {
if (!this.archiveEnabled()) {
if (!this.archiveEnabled() || this.viewingOrgVault) {
return false;
}

View File

@@ -925,6 +925,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
cipherId: cipher.id as CipherId,
organizationId: cipher.organizationId as OrganizationId,
canEditCipher: cipher.edit,
});
const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed);

View File

@@ -32,6 +32,9 @@
}
}
},
"criticalBadge":{
"message": "Critical"
},
"accessIntelligence": {
"message": "Access Intelligence"
},

View File

@@ -19,6 +19,7 @@
[ngModel]="selectedFilter()"
(ngModelChange)="setFilterApplicationsByStatus($event)"
fullWidth="false"
class="tw-min-w-48"
></bit-chip-select>
<button
@@ -37,7 +38,6 @@
<app-table-row-scrollable-m11
[dataSource]="dataSource"
[showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls()"
[openApplication]="drawerDetails.invokerId || ''"

View File

@@ -12,32 +12,18 @@
<th bitSortable="memberCount" bitCell tabindex="0">{{ "totalMembers" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
@if (showRowCheckBox) {
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
appStopProp
>
@if (!row.isMarkedAsCritical) {
<input
bitCheckbox
type="checkbox"
[checked]="selectedUrls.has(row.applicationName)"
(change)="checkboxChange(row.applicationName, $event)"
/>
}
@if (row.isMarkedAsCritical) {
<i class="bwi bwi-star-f"></i>
}
</td>
}
@if (!showRowCheckBox) {
<td bitCell [ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }">
@if (row.isMarkedAsCritical) {
<i class="bwi bwi-star-f"></i>
}
</td>
}
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
appStopProp
>
<input
bitCheckbox
type="checkbox"
[checked]="selectedUrls.has(row.applicationName)"
(change)="checkboxChange(row.applicationName, $event)"
/>
</td>
<td
bitCell
class="tw-cursor-pointer"
@@ -54,7 +40,7 @@
}
</td>
<td
class="tw-cursor-pointer"
class="tw-cursor-pointer tw-align-middle"
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
(click)="showAppAtRiskMembers(row.applicationName)"
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
@@ -63,7 +49,14 @@
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
>
<span>{{ row.applicationName }}</span>
<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>{{ "criticalBadge" | i18n }}</span>
}
</div>
</td>
<td
bitCell

View File

@@ -28,9 +28,6 @@ export class AppTableRowScrollableM11Component {
@Input() showRowMenuForCriticalApps: boolean = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() showRowCheckBox: boolean = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() selectedUrls: Set<string> = new Set<string>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals

View File

@@ -63,6 +63,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
protected registerSdkService: RegisterSdkService,
) {}
/**
* @deprecated To be removed in PM-28143. When you remove this, also check for any objects/methods
* in this default service that are now un-used and can also be removed.
*/
async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,
@@ -333,6 +337,9 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
);
}
/**
* @deprecated To be removed in PM-28143
*/
private async makeMasterKeyEncryptedUserKey(
masterKey: MasterKey,
userId: UserId,
@@ -410,6 +417,8 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
}
/**
* @deprecated To be removed in PM-28143
*
* As part of [PM-28494], adding this setting path to accommodate the changes that are
* emerging with pm-23246-unlock-with-master-password-unlock-data.
* Without this, immediately locking/unlocking the vault with the new password _may_ still fail

View File

@@ -124,6 +124,10 @@ describe("DefaultSetInitialPasswordService", () => {
expect(sut).not.toBeFalsy();
});
/**
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
* in the test setup above that are now un-used and can also be removed.
*/
describe("setInitialPassword(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;

View File

@@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
@@ -38,6 +39,7 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { I18nPipe } from "@bitwarden/ui-common";
import {
@@ -76,6 +78,7 @@ export class SetInitialPasswordComponent implements OnInit {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private dialogService: DialogService,
private i18nService: I18nService,
private keyService: KeyService,
private logoutService: LogoutService,
private logService: LogService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
@@ -110,16 +113,72 @@ export class SetInitialPasswordComponent implements OnInit {
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: {
/**
* "KM flag" = EnableAccountEncryptionV2JitPasswordRegistration
* "Auth flag" = PM27086_UpdateAuthenticationApisForInputPassword (checked in InputPasswordComponent and
* passed through via PasswordInputResult)
*
* Flag unwinding for this specific `case` will depend on which flag gets unwound first:
* - If KM flag gets unwound first, remove all code (in this `case`) after the call
* to setInitialPasswordJitMPUserV2Encryption(), as the V2Encryption method is the
* end-goal for this `case`.
* - If Auth flag gets unwound first (in PM-28143), keep the KM code & early return,
* but unwind the auth flagging logic and then remove the method call marked with
* the "Default Scenario" comment.
*/
const accountEncryptionV2 = await this.configService.getFeatureFlag(
FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration,
);
// Scenario 1: KM flag ON
if (accountEncryptionV2) {
await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult);
return;
}
await this.setInitialPassword(passwordInputResult);
// Scenario 2: KM flag OFF, Auth flag ON
if (passwordInputResult.newApisWithInputPasswordFlagEnabled) {
/**
* If the Auth flag is enabled, it means the InputPasswordComponent will not emit a newMasterKey,
* newServerMasterKeyHash, and newLocalMasterKeyHash. So we must create them here and add them late
* to the PasswordInputResult before calling setInitialPassword().
*
* This is a temporary state. The end-goal will be to use KM's V2Encryption method above.
*/
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx);
assertTruthy(this.email, "email", ctx);
const newMasterKey = await this.keyService.makeMasterKey(
passwordInputResult.newPassword,
this.email.trim().toLowerCase(),
passwordInputResult.kdfConfig,
);
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
passwordInputResult.newPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
passwordInputResult.newPassword,
newMasterKey,
HashPurpose.LocalAuthorization,
);
passwordInputResult.newMasterKey = newMasterKey;
passwordInputResult.newServerMasterKeyHash = newServerMasterKeyHash;
passwordInputResult.newLocalMasterKeyHash = newLocalMasterKeyHash;
await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the SetInitialPasswordComponent (just above)
return;
}
// Default Scenario: both flags OFF
await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the InputPasswordComponent (default)
break;
}
@@ -274,6 +333,9 @@ export class SetInitialPasswordComponent implements OnInit {
}
}
/**
* @deprecated To be removed in PM-28143
*/
private async setInitialPassword(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);

View File

@@ -87,6 +87,8 @@ export interface InitializeJitPasswordCredentials {
*/
export abstract class SetInitialPasswordService {
/**
* @deprecated To be removed in PM-28143
*
* Sets an initial password for an existing authed user who is either:
* - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER}
* - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP}

View File

@@ -21,6 +21,9 @@ export class VaultProfileService {
* Returns the creation date of the profile.
* Note: `Date`s are mutable in JS, creating a new
* instance is important to avoid unwanted changes.
*
* @deprecated use `creationDate` directly from the `AccountService.activeAccount$` instead,
* PM-31409 will replace all usages of this service.
*/
async getProfileCreationDate(userId: string): Promise<Date> {
if (this.profileCreatedDate && userId === this.userId) {

View File

@@ -10,7 +10,9 @@ import {
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -209,6 +211,7 @@ export class InputPasswordComponent implements OnInit {
constructor(
private auditService: AuditService,
private cipherService: CipherService,
private configService: ConfigService,
private dialogService: DialogService,
private formBuilder: FormBuilder,
private i18nService: I18nService,
@@ -312,7 +315,7 @@ export class InputPasswordComponent implements OnInit {
}
if (!this.email) {
throw new Error("Email is required to create master key.");
throw new Error("Email not found.");
}
// 1. Determine kdfConfig
@@ -320,13 +323,13 @@ export class InputPasswordComponent implements OnInit {
this.kdfConfig = DEFAULT_KDF_CONFIG;
} else {
if (!this.userId) {
throw new Error("userId not passed down");
throw new Error("userId not found.");
}
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
}
if (this.kdfConfig == null) {
throw new Error("KdfConfig is required to create master key.");
throw new Error("KdfConfig not found.");
}
const salt =
@@ -334,7 +337,7 @@ export class InputPasswordComponent implements OnInit {
? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId))
: this.masterPasswordService.emailToSalt(this.email);
if (salt == null) {
throw new Error("Salt is required to create master key.");
throw new Error("Salt not found.");
}
// 2. Verify current password is correct (if necessary)
@@ -361,6 +364,41 @@ export class InputPasswordComponent implements OnInit {
return;
}
// When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
);
if (newApisWithInputPasswordFlagEnabled) {
// 4. Build a PasswordInputResult object
const passwordInputResult: PasswordInputResult = {
newPassword,
kdfConfig: this.kdfConfig,
salt,
newPasswordHint,
newApisWithInputPasswordFlagEnabled, // To be removed in PM-28143
};
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
passwordInputResult.currentPassword = currentPassword;
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
}
// 5. Emit and return PasswordInputResult object
this.onPasswordFormSubmit.emit(passwordInputResult);
return passwordInputResult;
}
/*******************************************************************
* The following code (within this `try`) to be removed in PM-28143
*******************************************************************/
// 4. Create cryptographic keys and build a PasswordInputResult object
const newMasterKey = await this.keyService.makeMasterKey(
newPassword,

View File

@@ -6,14 +6,12 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component
The `InputPasswordComponent` allows a user to enter master password related credentials.
Specifically, it does the following:
The `InputPasswordComponent` allows a user to enter a new master password for the purpose of setting
an initial password or changing an existing password. Specifically, it does the following:
1. Displays form fields in the UI
2. Validates form fields
3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`,
`newServerMasterKeyHash`, etc.)
4. Emits the generated properties to the parent component
3. Emits values to the parent component
The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our
form UI and validation logic consistent. As such, it is intended for re-use in different set/change
@@ -30,7 +28,6 @@ those values as needed.
- [The InputPasswordFlow](#the-inputpasswordflow)
- [Use Cases](#use-cases)
- [HTML - Form Fields](#html---form-fields)
- [TypeScript - Credential Generation](#typescript---credential-generation)
- [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser)
- [Validation](#validation)
- [Submit Logic](#submit-logic)
@@ -44,20 +41,20 @@ those values as needed.
**Required**
- `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine
which form input elements will be displayed in the UI and which cryptographic keys will be created
and emitted. [Click here](#the-inputpasswordflow) to learn more about the different
`InputPasswordFlow` options.
which form input elements will be displayed in the UI and which values will be emitted.
[Click here](#the-inputpasswordflow) to learn more about the different `InputPasswordFlow`
options.
**Optional (sometimes)**
These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs`
are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that
the `email` and/or `userId` is present in certain flows, while not present in other flows.
These `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` are
not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that the
`email` and/or `userId` is present in certain flows, while not present in other flows.
- `email` - allows the `InputPasswordComponent` to generate a master key
- `email` - allows the `InputPasswordComponent` to use the email as a salt (if needed)
- `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`,
verify that a current password is correct, and perform validation prior to user key rotation on
the parent
verify that a current password is correct, and perform validation prior to user key rotation (if
selected) on the parent
**Optional**
@@ -87,8 +84,7 @@ These `@Inputs` are truly optional.
## The `InputPasswordFlow`
The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the
credential generation logic of the component. It is important for the dev to understand when to use
each flow.
logic of the component. It is important for the dev to understand when to use each flow.
### Use Cases
@@ -106,8 +102,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set
their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a
starting role that requires them to have/set their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with the reset
password permission ("manage account recovery") from the start, which requires them to have/set
their initial password
- A note on JIT provisioned user flows:
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider
them to be an “existing authed user” _from the perspective of the set-password flow_. This is
@@ -117,8 +114,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their
initial password, their account does not yet exist in the database, and will only be created
once they set an initial password.
- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now
requires them to have/set their initial password
- An existing user in a TDE org logs in after an org admin upgraded the user to have the reset
password persmission ("manage account recovery"), which now requires the user to have/set their
initial password
- An existing user logs in after their org admin offboarded the org from TDE, and the user must now
have/set their initial password<br /><br />
@@ -126,7 +124,7 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
Used in scenarios where we simply want to offer the user the ability to change their password:
- User clicks an org email invite link an logs in with their password which does not meet the org's
- User clicks an org email invite link and logs in with their password which does not meet the org's
policy requirements
- User logs in with password that does not meet the org's policy requirements
- User logs in after their password was reset via Account Recovery (and now they must change their
@@ -156,26 +154,10 @@ which form field UI elements get displayed.
<br />
### TypeScript - Credential Generation
- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`**
- These flows involve a user setting their password for the first time. Therefore on submit the
component will only generate new credentials (`newMasterKey`) and not current credentials
(`currentMasterKey`).<br /><br />
- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`**
- These flows both require the user to enter a current password along with a new password.
Therefore on submit the component will generate current credentials (`currentMasterKey`) along
with new credentials (`newMasterKey`).<br /><br />
- **`ChangePasswordDelegation`**
- This flow does not generate any credentials, but simply validates the new password and emits it
up to the parent.
<br />
### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser`
These two flows are similar in that they display the same form fields and only generate new
credentials, but we need to keep them separate for the following reasons:
These two flows are similar in that they display the same form fields, but we need to keep them
separate for the following reasons:
- `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and
**thus NO active account `userId`**:
@@ -183,7 +165,7 @@ credentials, but we need to keep them separate for the following reasons:
and **thus an active account `userId`**:
The presence or absence of an active account `userId` is important because it determines how we get
the correct `kdfConfig` prior to key generation:
the correct `kdfConfig`:
- If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG`
- If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the
@@ -223,25 +205,16 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
checkbox)
- Checks that the new password adheres to any enforced master password policies that were
optionally passed down by the parent
2. Uses the form inputs to create cryptographic properties (`newMasterKey`,
`newServerMasterKeyHash`, etc.)
3. Emits those cryptographic properties up to the parent (along with other values defined in
`PasswordInputResult`) to be used by the parent as needed.
2. Emits values up to the parent (along with other values defined in `PasswordInputResult`) to be
used by the parent as needed.
```typescript
export interface PasswordInputResult {
currentPassword?: string;
currentMasterKey?: MasterKey;
currentServerMasterKeyHash?: string;
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint?: string;
newMasterKey?: MasterKey;
newServerMasterKeyHash?: string;
newLocalMasterKeyHash?: string;
kdfConfig?: KdfConfig;
salt?: MasterPasswordSalt;
newPasswordHint?: string;
rotateUserKey?: boolean;
}
```

View File

@@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
@@ -59,6 +60,13 @@ export default {
getAllDecrypted: () => Promise.resolve([]),
},
},
// Can remove ConfigService from component and stories in PM-28143 (if it is no longer used)
{
provide: ConfigService,
useValue: {
getFeatureFlag: () => false, // default to false since flag does not effect UI
},
},
{
provide: KdfConfigService,
useValue: {

View File

@@ -10,6 +10,20 @@ export interface PasswordInputResult {
newPasswordHint?: string;
rotateUserKey?: boolean;
/**
* Temporary property that persists the flag state through the entire set/change password process.
* This allows flows to consume this value instead of re-checking the flag state via ConfigService themselves.
*
* The ChangePasswordDelegation flows (Emergency Access Takeover and Account Recovery), however, only ever
* require a raw newPassword from the InputPasswordComponent regardless of whether the flag is on or off.
* Flagging for those 2 flows will be done via the ConfigService in their respective services.
*
* To be removed in PM-28143
*/
newApisWithInputPasswordFlagEnabled?: boolean;
// The deprecated properties below will be removed in PM-28143: https://bitwarden.atlassian.net/browse/PM-28143
/** @deprecated This low-level cryptographic state will be removed. It will be replaced by high level calls to masterpassword service, in the consumers of this interface. */
currentMasterKey?: MasterKey;
/** @deprecated */

View File

@@ -64,14 +64,13 @@ describe("SendTokenService", () => {
"send_id_required",
"password_hash_b64_required",
"email_required",
"email_and_otp_required_otp_sent",
"email_and_otp_required",
"unknown",
];
const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [
"send_id_invalid",
"password_hash_b64_invalid",
"email_invalid",
"otp_invalid",
"otp_generation_failed",
"unknown",

View File

@@ -31,13 +31,6 @@ export function passwordHashB64Invalid(
return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid";
}
export type EmailInvalid = InvalidGrant & {
send_access_error_type: "email_invalid";
};
export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid";
}
export type OtpInvalid = InvalidGrant & {
send_access_error_type: "otp_invalid";
};

View File

@@ -39,16 +39,12 @@ export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailReq
return e.error === "invalid_request" && e.send_access_error_type === "email_required";
}
export type EmailAndOtpRequiredEmailSent = InvalidRequest & {
send_access_error_type: "email_and_otp_required_otp_sent";
export type EmailAndOtpRequired = InvalidRequest & {
send_access_error_type: "email_and_otp_required";
};
export function emailAndOtpRequiredEmailSent(
e: SendAccessTokenApiErrorResponse,
): e is EmailAndOtpRequiredEmailSent {
return (
e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent"
);
export function emailAndOtpRequired(e: SendAccessTokenApiErrorResponse): e is EmailAndOtpRequired {
return e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required";
}
export type UnknownInvalidRequest = InvalidRequest & {

View File

@@ -18,9 +18,11 @@ export enum FeatureFlag {
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
SafariAccountSwitching = "pm-5594-safari-account-switching",
/* Autofill */
UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic",
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
@@ -109,6 +111,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.MembersComponentRefactor]: FALSE,
/* Autofill */
[FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
@@ -137,6 +140,7 @@ export const DefaultFeatureFlagValue = {
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
[FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE,
[FeatureFlag.SafariAccountSwitching]: FALSE,
/* Billing */

View File

@@ -75,7 +75,7 @@ export class Fido2CredentialExport {
domain.userDisplayName =
req.userDisplayName != null ? new EncString(req.userDisplayName) : null;
domain.discoverable = req.discoverable != null ? new EncString(req.discoverable) : null;
domain.creationDate = req.creationDate;
domain.creationDate = req.creationDate != null ? new Date(req.creationDate) : null;
return domain;
}
@@ -111,10 +111,12 @@ export class Fido2CredentialExport {
this.rpId = safeGetString(o.rpId);
this.userHandle = safeGetString(o.userHandle);
this.userName = safeGetString(o.userName);
this.counter = safeGetString(String(o.counter));
this.counter = safeGetString(o instanceof Fido2CredentialView ? String(o.counter) : o.counter);
this.rpName = safeGetString(o.rpName);
this.userDisplayName = safeGetString(o.userDisplayName);
this.discoverable = safeGetString(String(o.discoverable));
this.discoverable = safeGetString(
o instanceof Fido2CredentialView ? String(o.discoverable) : o.discoverable,
);
this.creationDate = o.creationDate;
}
}

View File

@@ -39,7 +39,11 @@ export class LoginExport {
domain.username = req.username != null ? new EncString(req.username) : null;
domain.password = req.password != null ? new EncString(req.password) : null;
domain.totp = req.totp != null ? new EncString(req.totp) : null;
// Fido2credentials are currently not supported for exports.
if (req.fido2Credentials != null) {
domain.fido2Credentials = req.fido2Credentials.map((f2) =>
Fido2CredentialExport.toDomain(f2),
);
}
return domain;
}

View File

@@ -16,6 +16,11 @@ export abstract class VaultSettingsService {
* An observable monitoring the state of the show identities on the current tab.
*/
abstract showIdentitiesCurrentTab$: Observable<boolean>;
/**
* An observable monitoring the state of the click items on the Vault view
* for Autofill suggestions.
*/
abstract clickItemsToAutofillVaultView$: Observable<boolean>;
/**
* Saves the enable passkeys setting to disk.
@@ -32,4 +37,10 @@ export abstract class VaultSettingsService {
* @param value The new value for the show identities on tab page setting.
*/
abstract setShowIdentitiesCurrentTab(value: boolean): Promise<void>;
/**
* Saves the click items on vault View for Autofill suggestions to disk.
* @param value The new value for the click items on vault View for
* Autofill suggestions setting.
*/
abstract setClickItemsToAutofillVaultView(value: boolean): Promise<void>;
}

View File

@@ -25,3 +25,12 @@ export const SHOW_IDENTITIES_CURRENT_TAB = new UserKeyDefinition<boolean>(
clearOn: [], // do not clear user settings
},
);
export const CLICK_ITEMS_AUTOFILL_VAULT_VIEW = new UserKeyDefinition<boolean>(
VAULT_SETTINGS_DISK,
"clickItemsToAutofillOnVaultView",
{
deserializer: (obj) => obj,
clearOn: [], // do not clear user settings
},
);

View File

@@ -1,4 +1,4 @@
import { Observable, combineLatest, map } from "rxjs";
import { Observable, combineLatest, map, shareReplay } from "rxjs";
import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service";
@@ -7,6 +7,7 @@ import {
SHOW_CARDS_CURRENT_TAB,
SHOW_IDENTITIES_CURRENT_TAB,
USER_ENABLE_PASSKEYS,
CLICK_ITEMS_AUTOFILL_VAULT_VIEW,
} from "../key-state/vault-settings.state";
import { RestrictedItemTypesService } from "../restricted-item-types.service";
@@ -48,6 +49,17 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
readonly showIdentitiesCurrentTab$: Observable<boolean> =
this.showIdentitiesCurrentTabState.state$.pipe(map((x) => x ?? true));
private clickItemsToAutofillVaultViewState: ActiveUserState<boolean> =
this.stateProvider.getActive(CLICK_ITEMS_AUTOFILL_VAULT_VIEW);
/**
* {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$}
*/
readonly clickItemsToAutofillVaultView$: Observable<boolean> =
this.clickItemsToAutofillVaultViewState.state$.pipe(
map((x) => x ?? false),
shareReplay({ bufferSize: 1, refCount: false }),
);
constructor(
private stateProvider: StateProvider,
private restrictedItemTypesService: RestrictedItemTypesService,
@@ -67,6 +79,13 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
await this.showIdentitiesCurrentTabState.update(() => value);
}
/**
* {@link VaultSettingsServiceAbstraction.setClickItemsToAutofillVaultView}
*/
async setClickItemsToAutofillVaultView(value: boolean): Promise<void> {
await this.clickItemsToAutofillVaultViewState.update(() => value);
}
/**
* {@link VaultSettingsServiceAbstraction.setEnablePasskeys}
*/

View File

@@ -91,4 +91,33 @@ describe("BitwardenCsvImporter", () => {
expect(result.collections[0].name).toBe("collection1/collection2");
expect(result.collections[1].name).toBe("collection1");
});
it("should parse archived items correctly", async () => {
const archivedDate = "2025-01-15T10:30:00.000Z";
const data =
`name,type,archivedDate,login_uri,login_username,login_password` +
`\nArchived Login,login,${archivedDate},https://example.com,user,pass`;
importer.organizationId = null;
const result = await importer.parse(data);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(1);
const cipher = result.ciphers[0];
expect(cipher.name).toBe("Archived Login");
expect(cipher.archivedDate).toBeDefined();
expect(cipher.archivedDate.toISOString()).toBe(archivedDate);
});
it("should handle missing archivedDate gracefully", async () => {
const data = `name,type,login_uri` + `\nTest Login,login,https://example.com`;
importer.organizationId = null;
const result = await importer.parse(data);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(1);
expect(result.ciphers[0].archivedDate).toBeUndefined();
});
});

View File

@@ -51,6 +51,15 @@ export class BitwardenCsvImporter extends BaseImporter implements Importer {
cipher.reprompt = CipherRepromptType.None;
}
if (!this.isNullOrWhitespace(value.archivedDate)) {
try {
cipher.archivedDate = new Date(value.archivedDate);
} catch (e) {
// eslint-disable-next-line
console.error("Unable to parse archivedDate value", e);
}
}
if (!this.isNullOrWhitespace(value.fields)) {
const fields = this.splitNewLine(value.fields);
for (let i = 0; i < fields.length; i++) {

View File

@@ -0,0 +1,87 @@
import { ButtercupCsvImporter } from "./buttercup-csv-importer";
import {
buttercupCsvTestData,
buttercupCsvWithCustomFieldsTestData,
buttercupCsvWithNoteTestData,
buttercupCsvWithSubfoldersTestData,
buttercupCsvWithUrlFieldTestData,
} from "./spec-data/buttercup-csv/testdata.csv";
describe("Buttercup CSV Importer", () => {
let importer: ButtercupCsvImporter;
beforeEach(() => {
importer = new ButtercupCsvImporter();
});
describe("given basic login data", () => {
it("should parse login data when provided valid CSV", async () => {
const result = await importer.parse(buttercupCsvTestData);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(2);
const cipher = result.ciphers[0];
expect(cipher.name).toEqual("Test Entry");
expect(cipher.login.username).toEqual("testuser");
expect(cipher.login.password).toEqual("testpass123");
expect(cipher.login.uris.length).toEqual(1);
expect(cipher.login.uris[0].uri).toEqual("https://example.com");
});
it("should assign entries to folders based on group_name", async () => {
const result = await importer.parse(buttercupCsvTestData);
expect(result.success).toBe(true);
expect(result.folders.length).toBe(1);
expect(result.folders[0].name).toEqual("General");
expect(result.folderRelationships.length).toBe(2);
});
});
describe("given URL field variations", () => {
it("should handle lowercase url field", async () => {
const result = await importer.parse(buttercupCsvWithUrlFieldTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.login.uris.length).toEqual(1);
expect(cipher.login.uris[0].uri).toEqual("https://lowercase-url.com");
});
});
describe("given note field", () => {
it("should map note field to notes", async () => {
const result = await importer.parse(buttercupCsvWithNoteTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.notes).toEqual("This is a note");
});
});
describe("given custom fields", () => {
it("should import custom fields and exclude official props", async () => {
const result = await importer.parse(buttercupCsvWithCustomFieldsTestData);
expect(result.success).toBe(true);
const cipher = result.ciphers[0];
expect(cipher.fields.length).toBe(2);
expect(cipher.fields[0].name).toEqual("custom_field");
expect(cipher.fields[0].value).toEqual("custom value");
expect(cipher.fields[1].name).toEqual("another_field");
expect(cipher.fields[1].value).toEqual("another value");
});
});
describe("given subfolders", () => {
it("should create nested folder structure", async () => {
const result = await importer.parse(buttercupCsvWithSubfoldersTestData);
expect(result.success).toBe(true);
const folderNames = result.folders.map((f) => f.name);
expect(folderNames).toContain("Work/Projects");
expect(folderNames).toContain("Work");
expect(folderNames).toContain("Personal/Finance");
expect(folderNames).toContain("Personal");
});
});
});

View File

@@ -3,7 +3,18 @@ import { ImportResult } from "../models/import-result";
import { BaseImporter } from "./base-importer";
import { Importer } from "./importer";
const OfficialProps = ["!group_id", "!group_name", "title", "username", "password", "URL", "id"];
const OfficialProps = [
"!group_id",
"!group_name",
"!type",
"title",
"username",
"password",
"URL",
"url",
"note",
"id",
];
export class ButtercupCsvImporter extends BaseImporter implements Importer {
parse(data: string): Promise<ImportResult> {
@@ -21,16 +32,24 @@ export class ButtercupCsvImporter extends BaseImporter implements Importer {
cipher.name = this.getValueOrDefault(value.title, "--");
cipher.login.username = this.getValueOrDefault(value.username);
cipher.login.password = this.getValueOrDefault(value.password);
cipher.login.uris = this.makeUriArray(value.URL);
let processingCustomFields = false;
// Handle URL field (case-insensitive)
const urlValue = value.URL || value.url || value.Url;
cipher.login.uris = this.makeUriArray(urlValue);
// Handle note field (case-insensitive)
const noteValue = value.note || value.Note || value.notes || value.Notes;
if (noteValue) {
cipher.notes = noteValue;
}
// Process custom fields, excluding official props (case-insensitive)
for (const prop in value) {
// eslint-disable-next-line
if (value.hasOwnProperty(prop)) {
if (!processingCustomFields && OfficialProps.indexOf(prop) === -1) {
processingCustomFields = true;
}
if (processingCustomFields) {
const lowerProp = prop.toLowerCase();
const isOfficialProp = OfficialProps.some((p) => p.toLowerCase() === lowerProp);
if (!isOfficialProp && value[prop]) {
this.processKvp(cipher, prop, value[prop]);
}
}

View File

@@ -0,0 +1,16 @@
export const buttercupCsvTestData = `!group_id,!group_name,title,username,password,URL,id
1,General,Test Entry,testuser,testpass123,https://example.com,entry1
1,General,Another Entry,anotheruser,anotherpass,https://another.com,entry2`;
export const buttercupCsvWithUrlFieldTestData = `!group_id,!group_name,title,username,password,url,id
1,General,Entry With Lowercase URL,user1,pass1,https://lowercase-url.com,entry1`;
export const buttercupCsvWithNoteTestData = `!group_id,!group_name,title,username,password,URL,note,id
1,General,Entry With Note,user1,pass1,https://example.com,This is a note,entry1`;
export const buttercupCsvWithCustomFieldsTestData = `!group_id,!group_name,title,username,password,URL,custom_field,another_field,id
1,General,Entry With Custom Fields,user1,pass1,https://example.com,custom value,another value,entry1`;
export const buttercupCsvWithSubfoldersTestData = `!group_id,!group_name,title,username,password,URL,id
1,Work/Projects,Project Entry,projectuser,projectpass,https://project.com,entry1
2,Personal/Finance,Finance Entry,financeuser,financepass,https://finance.com,entry2`;

View File

@@ -59,6 +59,7 @@ export class BaseVaultExportService {
cipher.notes = c.notes;
cipher.fields = null;
cipher.reprompt = c.reprompt;
cipher.archivedDate = c.archivedDate ? c.archivedDate.toISOString() : null;
// Login props
cipher.login_uri = null;
cipher.login_username = null;

View File

@@ -12,6 +12,7 @@ export type BitwardenCsvExportType = {
login_password: string;
login_totp: string;
favorite: number | null;
archivedDate: string | null;
};
export type BitwardenCsvIndividualExportType = BitwardenCsvExportType & {

View File

@@ -38,14 +38,16 @@
</button>
}
</bit-item-action>
<bit-item-action>
<app-delete-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher().id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
</bit-item-action>
@if (cipher().edit) {
<bit-item-action>
<app-delete-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher().id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
</bit-item-action>
}
</ng-container>
</bit-item>
</li>
@@ -54,46 +56,48 @@
}
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
<bit-card>
<label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5">
{{ "addAttachment" | i18n }}
</label>
<div class="tw-relative">
<!-- Input elements are notoriously difficult to style, --->
<!-- The native `<input>` will be used for screen readers -->
<!-- Visual & keyboard users will interact with the styled button element -->
<input
#fileInput
class="tw-sr-only"
type="file"
id="file"
name="file"
aria-describedby="fileHelp"
tabindex="-1"
required
(change)="onFileChange($event)"
/>
<div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true">
<button
bitButton
buttonType="secondary"
type="button"
(click)="fileInput.click()"
class="tw-whitespace-nowrap"
>
{{ "chooseFile" | i18n }}
</button>
<p bitTypography="body2" class="tw-text-muted tw-mb-0">
{{
this.attachmentForm.controls.file?.value
? this.attachmentForm.controls.file.value.name
: ("noFileChosen" | i18n)
}}
</p>
@if (cipher()?.edit) {
<bit-card>
<label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5">
{{ "addAttachment" | i18n }}
</label>
<div class="tw-relative">
<!-- Input elements are notoriously difficult to style, --->
<!-- The native `<input>` will be used for screen readers -->
<!-- Visual & keyboard users will interact with the styled button element -->
<input
#fileInput
class="tw-sr-only"
type="file"
id="file"
name="file"
aria-describedby="fileHelp"
tabindex="-1"
required
(change)="onFileChange($event)"
/>
<div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true">
<button
bitButton
buttonType="secondary"
type="button"
(click)="fileInput.click()"
class="tw-whitespace-nowrap"
>
{{ "chooseFile" | i18n }}
</button>
<p bitTypography="body2" class="tw-text-muted tw-mb-0">
{{
this.attachmentForm.controls.file?.value
? this.attachmentForm.controls.file.value.name
: ("noFileChosen" | i18n)
}}
</p>
</div>
</div>
</div>
<p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0">
{{ "maxFileSizeSansPunctuation" | i18n }}
</p>
</bit-card>
<p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0">
{{ "maxFileSizeSansPunctuation" | i18n }}
</p>
</bit-card>
}
</form>

View File

@@ -51,6 +51,7 @@ describe("CipherAttachmentsComponent", () => {
username: "username",
password: "password",
},
edit: true,
} as CipherView;
const cipherDomain = {
@@ -197,6 +198,10 @@ describe("CipherAttachmentsComponent", () => {
let file: File;
beforeEach(() => {
const nonEditableCipherView = { ...cipherView, edit: false };
cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView);
fixture.detectChanges();
submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
file = new File([""], "attachment.txt", { type: "text/plain" });
@@ -371,6 +376,32 @@ describe("CipherAttachmentsComponent", () => {
expect(emitSpy).toHaveBeenCalled();
});
});
describe("close", () => {
async function setup(): Promise<void> {
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
submitBtnFixture = TestBed.createComponent(ButtonComponent);
// Set organizationId BEFORE cipherId so the effect picks it up
fixture.componentRef.setInput("organizationId", organization.id);
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
await waitForInitialization();
const nonEditableCipherView = { ...cipherView, edit: false };
cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView);
fixture.detectChanges();
}
it('emits "onCloseButtonPress"', async () => {
await setup();
const emitSpy = jest.spyOn(component.onCloseButtonPress, "emit");
await component.submit();
expect(emitSpy).toHaveBeenCalled();
});
});
});
describe("removeAttachment", () => {

View File

@@ -105,6 +105,8 @@ export class CipherAttachmentsComponent {
/** Emits after a file has been successfully removed */
readonly onRemoveSuccess = output<void>();
readonly onCloseButtonPress = output<void>();
protected readonly organization = signal<Organization | null>(null);
protected readonly cipher = signal<CipherView | null>(null);
@@ -154,7 +156,7 @@ export class CipherAttachmentsComponent {
// Update the initial state of the submit button
const btn = this.submitBtn();
if (btn) {
btn.disabled.set(!this.attachmentForm.valid);
btn.disabled.set(!this.attachmentForm.valid && (this.cipher()?.edit ?? true));
}
});
@@ -192,6 +194,12 @@ export class CipherAttachmentsComponent {
/** Save the attachments to the cipher */
submit = async () => {
//user can't edit cipher and will close the bit-dialog
if (!(this.cipher()?.edit ?? false)) {
this.onCloseButtonPress.emit();
return;
}
this.onUploadStarted.emit();
const file = this.attachmentForm.value.file;

View File

@@ -13,11 +13,12 @@
(onUploadSuccess)="uploadSuccessful()"
(onUploadFailed)="uploadFailed()"
(onRemoveSuccess)="removalSuccessful()"
(onCloseButtonPress)="closeButtonPressed()"
></app-cipher-attachments>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="submit" buttonType="primary" [attr.form]="attachmentFormId" #submitBtn>
{{ "upload" | i18n }}
{{ buttonText }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -69,4 +69,12 @@ describe("AttachmentsV2Component", () => {
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed });
});
it("closes the dialog with 'closed' result on closedButtonPressed", () => {
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
component.closeButtonPressed();
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Closed });
});
});

View File

@@ -3,6 +3,7 @@
import { CommonModule } from "@angular/common";
import { Component, HostListener, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
@@ -18,6 +19,7 @@ import { CipherAttachmentsComponent } from "../../cipher-form/components/attachm
export interface AttachmentsDialogParams {
cipherId: CipherId;
canEditCipher?: boolean;
admin?: boolean;
organizationId?: OrganizationId;
}
@@ -51,7 +53,9 @@ export class AttachmentsV2Component {
cipherId: CipherId;
admin: boolean = false;
organizationId?: OrganizationId;
canEditCipher: boolean;
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
buttonText: string;
private isUploading = false;
/**
@@ -62,10 +66,14 @@ export class AttachmentsV2Component {
constructor(
private dialogRef: DialogRef<AttachmentDialogCloseResult>,
@Inject(DIALOG_DATA) public params: AttachmentsDialogParams,
private i18nService: I18nService,
) {
this.cipherId = params.cipherId;
this.organizationId = params.organizationId;
this.admin = params.admin ?? false;
this.canEditCipher = params?.canEditCipher ?? false;
this.buttonText =
this.canEditCipher || this.admin ? this.i18nService.t("upload") : this.i18nService.t("close");
}
/**
@@ -140,4 +148,10 @@ export class AttachmentsV2Component {
action: AttachmentDialogResult.Removed,
});
}
closeButtonPressed() {
this.dialogRef.close({
action: AttachmentDialogResult.Closed,
});
}
}

View File

@@ -26,6 +26,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -184,7 +185,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
const orgNodes: TreeNode<OrganizationFilter>[] = [];
orgs.forEach((org) => {
const orgCopy = org as OrganizationFilter;
orgCopy.icon = "bwi-business";
if (
org?.productTierType === ProductTierType.Free ||
org?.productTierType === ProductTierType.Families
) {
orgCopy.icon = "bwi-family";
} else {
orgCopy.icon = "bwi-business";
}
const node = new TreeNode<OrganizationFilter>(orgCopy, headNode, orgCopy.name);
orgNodes.push(node);
});

18
package-lock.json generated
View File

@@ -23,8 +23,8 @@
"@angular/platform-browser": "20.3.16",
"@angular/platform-browser-dynamic": "20.3.16",
"@angular/router": "20.3.16",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
"@bitwarden/sdk-internal": "0.2.0-main.470",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.506",
"@bitwarden/sdk-internal": "0.2.0-main.506",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",
@@ -4982,10 +4982,9 @@
"link": true
},
"node_modules/@bitwarden/commercial-sdk-internal": {
"version": "0.2.0-main.470",
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.470.tgz",
"integrity": "sha512-QYhxv5eX6ouFJv94gMtBW7MjuK6t2KAN9FLz+/w1wnq8dScnA9Iky25phNPw+iHMgWwhq/dzZq45asKUFF//oA==",
"license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT",
"version": "0.2.0-main.506",
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.506.tgz",
"integrity": "sha512-aRzcxOcj8vXxz0jN3q2xxj26zxBfjg3oRm5QXbWE7zXJ2PGrgxTaePca9pQYYpwgr7iufYMnZcq5dH+qttNEmA==",
"dependencies": {
"type-fest": "^4.41.0"
}
@@ -5087,10 +5086,9 @@
"link": true
},
"node_modules/@bitwarden/sdk-internal": {
"version": "0.2.0-main.470",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.470.tgz",
"integrity": "sha512-XKvcUtoU6NnxeEzl3WK7bATiCh2RNxRmuX6JYNgcQHUtHUH+x3ckToR6II1qM3nha0VH0u1ijy3+07UdNQM+JQ==",
"license": "GPL-3.0",
"version": "0.2.0-main.506",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.506.tgz",
"integrity": "sha512-BbTSU5Acx74Hr32zDj2kV8sbdclyvdIti5t6kXnCvJmA5dZbu+5j5Xw1luS9mGL9Vfi4w3OjVug/TiSxyhwLzQ==",
"dependencies": {
"type-fest": "^4.41.0"
}

View File

@@ -162,8 +162,8 @@
"@angular/platform-browser": "20.3.16",
"@angular/platform-browser-dynamic": "20.3.16",
"@angular/router": "20.3.16",
"@bitwarden/sdk-internal": "0.2.0-main.470",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.506",
"@bitwarden/sdk-internal": "0.2.0-main.506",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",