1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 14:34:02 +00:00

Merge branch 'main' into km/beeep/epheremal-value-store-rust

This commit is contained in:
Bernd Schoolmann
2024-12-09 13:10:41 +01:00
205 changed files with 3379 additions and 1113 deletions

6
.github/CODEOWNERS vendored
View File

@@ -4,10 +4,6 @@
#
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
## Secrets Manager team files ##
bitwarden_license/bit-web/src/app/secrets-manager @bitwarden/team-secrets-manager-dev
apps/web/src/app/secrets-manager/ @bitwarden/team-secrets-manager-dev
## Auth team files ##
apps/browser/src/auth @bitwarden/team-auth-dev
apps/cli/src/auth @bitwarden/team-auth-dev
@@ -107,6 +103,8 @@ apps/web/src/app/layouts @bitwarden/team-design-system
## Desktop native module ##
apps/desktop/desktop_native @bitwarden/team-platform-dev
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-dev
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-dev
## Key management team files ##
apps/desktop/src/key-management @bitwarden/team-key-management-dev

View File

@@ -10,7 +10,7 @@
},
{
"matchManagers": ["github-actions"],
"commitMessagePrefix": "[deps] DevOps:"
"commitMessagePrefix": "[deps] BRE:"
},
{
"matchManagers": ["cargo"],
@@ -39,6 +39,10 @@
"groupName": "macOS/iOS bindings",
"matchPackageNames": ["core-foundation", "security-framework", "security-framework-sys"]
},
{
"groupName": "zbus",
"matchPackageNames": ["zbus", "zbus_polkit"]
},
{
"matchPackageNames": [
"base64-loader",

View File

@@ -170,7 +170,7 @@ jobs:
- name: Set up environment
run: |
sudo apt-get update
sudo apt-get -y install pkg-config libxss-dev libsecret-1-dev rpm musl-dev musl-tools flatpak flatpak-builder
sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder
- name: Set up Snap
run: sudo snap install snapcraft --classic

View File

@@ -138,7 +138,7 @@ jobs:
- name: Set up environment
run: |
sudo apt-get update
sudo apt-get -y install pkg-config libxss-dev libsecret-1-dev rpm
sudo apt-get -y install pkg-config libxss-dev rpm
- name: Set up Snap
run: sudo snap install snapcraft --classic

View File

@@ -57,6 +57,7 @@ const config: StorybookConfig = {
return config;
},
docs: {},
staticDirs: ["../apps/web/src/images"],
};
export default config;

View File

@@ -1319,6 +1319,12 @@
"enterVerificationCodeApp": {
"message": "Enter the 6 digit verification code from your authenticator app."
},
"authenticationTimeout": {
"message": "Authentication timeout"
},
"authenticationSessionTimedOut": {
"message": "The authentication session timed out. Please restart the login process."
},
"enterVerificationCodeEmail": {
"message": "Enter the 6 digit verification code that was emailed to $EMAIL$.",
"placeholders": {

View File

@@ -438,8 +438,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
const successful = await this.trySetupBiometrics();
this.form.controls.biometric.setValue(successful);
await this.biometricStateService.setBiometricUnlockEnabled(successful);
if (!successful) {
await this.biometricStateService.setBiometricUnlockEnabled(false);
await this.biometricStateService.setFingerprintValidated(false);
}
} else {

View File

@@ -1,7 +1,7 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<header>
<div class="left">
<a (click)="logOut()">{{ "logOut" | i18n }}</a>
<button type="button" (click)="logOut()">{{ "logOut" | i18n }}</button>
</div>
<h1 class="center">
<span class="title">{{ "updateMasterPassword" | i18n }}</span>

View File

@@ -362,8 +362,6 @@ export class OverlayBackground implements OverlayBackgroundInterface {
this.inlineMenuFido2Credentials.clear();
this.storeInlineMenuFido2Credentials$.next(currentTab.id);
await this.generatePassword();
const ciphersViews = await this.getCipherViews(currentTab, updateAllCipherTypes);
for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) {
this.inlineMenuCiphers.set(`inline-menu-cipher-${cipherIndex}`, ciphersViews[cipherIndex]);

View File

@@ -6,6 +6,7 @@ import {
EnvironmentSelectorRouteData,
ExtensionDefaultOverlayPosition,
} from "@bitwarden/angular/auth/components/environment-selector.component";
import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component";
import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect";
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
import {
@@ -38,6 +39,7 @@ import {
VaultIcon,
LoginDecryptionOptionsComponent,
DevicesIcon,
TwoFactorTimeoutIcon,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -199,6 +201,29 @@ const routes: Routes = [
],
},
),
{
path: "",
component: ExtensionAnonLayoutWrapperComponent,
children: [
{
path: "2fa-timeout",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
children: [
{
path: "",
component: TwoFactorTimeoutComponent,
},
],
data: {
pageTitle: {
key: "authenticationTimeout",
},
pageIcon: TwoFactorTimeoutIcon,
elevation: 1,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
],
},
{
path: "2fa-options",
component: TwoFactorOptionsComponent,

View File

@@ -49,7 +49,7 @@ export class FilePopoutUtilsService {
}
/**
* Determines whether to show a file popout callout message for Chromium-based browsers in Linux and Mac OS X Big Sur
* Determines whether to show a file popout callout message for Chromium-based browsers in Linux and Mac OS X
* @param win - The window context in which the check should be performed.
* @returns True if the extension is not in a sidebar or popout; otherwise, false.
*/
@@ -66,8 +66,6 @@ export class FilePopoutUtilsService {
}
private isUnsupportedMac(win: Window): boolean {
return (
this.platformUtilsService.isChrome() && win?.navigator?.appVersion.includes("Mac OS X 11")
);
return this.platformUtilsService.isChrome() && win?.navigator?.appVersion.includes("Mac OS X");
}
}

View File

@@ -3,19 +3,27 @@
{{ "new" | i18n }}
</button>
<bit-menu #itemOptions>
<a bitMenuItem (click)="newItemNavigate(cipherType.Login)">
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Login)">
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
{{ "typeLogin" | i18n }}
</a>
<a bitMenuItem (click)="newItemNavigate(cipherType.Card)">
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Card)">
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
{{ "typeCard" | i18n }}
</a>
<a bitMenuItem (click)="newItemNavigate(cipherType.Identity)">
<a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.Identity)"
>
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</a>
<a bitMenuItem (click)="newItemNavigate(cipherType.SecureNote)">
<a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.SecureNote)"
>
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</a>

View File

@@ -1,141 +1,163 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { mock } from "jest-mock-extended";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components";
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component";
import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component";
describe("NewItemDropdownV2Component", () => {
let component: NewItemDropdownV2Component;
let fixture: ComponentFixture<NewItemDropdownV2Component>;
const open = jest.fn();
const navigate = jest.fn();
let dialogServiceMock: jest.Mocked<DialogService>;
let browserApiMock: jest.Mocked<typeof BrowserApi>;
jest
.spyOn(BrowserApi, "getTabFromCurrentWindow")
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
const mockTab = { url: "https://example.com" };
beforeAll(() => {
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(mockTab as chrome.tabs.Tab);
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest.spyOn(Utils, "getHostname").mockReturnValue("example.com");
});
beforeEach(async () => {
open.mockClear();
navigate.mockClear();
dialogServiceMock = mock<DialogService>();
dialogServiceMock.open.mockClear();
const activatedRouteMock = {
snapshot: { paramMap: { get: jest.fn() } },
};
const i18nServiceMock = mock<I18nService>();
const folderServiceMock = mock<FolderService>();
const folderApiServiceAbstractionMock = mock<FolderApiServiceAbstraction>();
const accountServiceMock = mock<AccountService>();
await TestBed.configureTestingModule({
imports: [NewItemDropdownV2Component, MenuModule, ButtonModule, JslibModule, CommonModule],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: Router, useValue: { navigate } },
imports: [
CommonModule,
RouterLink,
ButtonModule,
MenuModule,
NoItemsModule,
NewItemDropdownV2Component,
],
})
.overrideProvider(DialogService, { useValue: { open } })
.compileComponents();
providers: [
{ provide: DialogService, useValue: dialogServiceMock },
{ provide: I18nService, useValue: i18nServiceMock },
{ provide: ActivatedRoute, useValue: activatedRouteMock },
{ provide: BrowserApi, useValue: browserApiMock },
{ provide: FolderService, useValue: folderServiceMock },
{ provide: FolderApiServiceAbstraction, useValue: folderApiServiceAbstractionMock },
{ provide: AccountService, useValue: accountServiceMock },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(NewItemDropdownV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("opens new folder dialog", () => {
component.openFolderDialog();
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent);
});
describe("new item", () => {
const emptyParams: AddEditQueryParams = {
collectionId: undefined,
organizationId: undefined,
folderId: undefined,
};
beforeEach(() => {
jest.spyOn(component, "newItemNavigate");
});
it("navigates to new login", async () => {
await component.newItemNavigate(CipherType.Login);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: {
type: CipherType.Login.toString(),
name: "example.com",
uri: "https://example.com",
...emptyParams,
},
});
});
it("navigates to new card", async () => {
await component.newItemNavigate(CipherType.Card);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: { type: CipherType.Card.toString(), ...emptyParams },
});
});
it("navigates to new identity", async () => {
await component.newItemNavigate(CipherType.Identity);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: { type: CipherType.Identity.toString(), ...emptyParams },
});
});
it("navigates to new note", async () => {
await component.newItemNavigate(CipherType.SecureNote);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: { type: CipherType.SecureNote.toString(), ...emptyParams },
});
});
it("includes initial values", async () => {
describe("buildQueryParams", () => {
it("should build query params for a Login cipher when not popped out", async () => {
await component.ngOnInit();
component.initialValues = {
folderId: "222-333-444",
organizationId: "444-555-666",
collectionId: "777-888-999",
} as NewItemInitialValues;
await component.newItemNavigate(CipherType.Login);
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest.spyOn(Utils, "getHostname").mockReturnValue("example.com");
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: {
type: CipherType.Login.toString(),
folderId: "222-333-444",
organizationId: "444-555-666",
collectionId: "777-888-999",
uri: "https://example.com",
name: "example.com",
},
const params = component.buildQueryParams(CipherType.Login);
expect(params).toEqual({
type: CipherType.Login.toString(),
collectionId: "777-888-999",
organizationId: "444-555-666",
folderId: "222-333-444",
uri: "https://example.com",
name: "example.com",
});
});
it("does not include name or uri when the extension is popped out", async () => {
it("should build query params for a Login cipher when popped out", () => {
component.initialValues = {
collectionId: "777-888-999",
} as NewItemInitialValues;
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
const params = component.buildQueryParams(CipherType.Login);
expect(params).toEqual({
type: CipherType.Login.toString(),
collectionId: "777-888-999",
});
});
it("should build query params for a secure note", () => {
component.initialValues = {
folderId: "222-333-444",
organizationId: "444-555-666",
collectionId: "777-888-999",
} as NewItemInitialValues;
await component.newItemNavigate(CipherType.Login);
const params = component.buildQueryParams(CipherType.SecureNote);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: {
type: CipherType.Login.toString(),
folderId: "222-333-444",
organizationId: "444-555-666",
collectionId: "777-888-999",
},
expect(params).toEqual({
type: CipherType.SecureNote.toString(),
collectionId: "777-888-999",
});
});
it("should build query params for an Identity", () => {
component.initialValues = {
collectionId: "777-888-999",
} as NewItemInitialValues;
const params = component.buildQueryParams(CipherType.Identity);
expect(params).toEqual({
type: CipherType.Identity.toString(),
collectionId: "777-888-999",
});
});
it("should build query params for a Card", () => {
component.initialValues = {
collectionId: "777-888-999",
} as NewItemInitialValues;
const params = component.buildQueryParams(CipherType.Card);
expect(params).toEqual({
type: CipherType.Card.toString(),
collectionId: "777-888-999",
});
});
it("should build query params for a SshKey", () => {
component.initialValues = {
collectionId: "777-888-999",
} as NewItemInitialValues;
const params = component.buildQueryParams(CipherType.SshKey);
expect(params).toEqual({
type: CipherType.SshKey.toString(),
collectionId: "777-888-999",
});
});
});

View File

@@ -1,6 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { Component, Input, OnInit } from "@angular/core";
import { RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -25,31 +25,31 @@ export interface NewItemInitialValues {
standalone: true,
imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule],
})
export class NewItemDropdownV2Component {
export class NewItemDropdownV2Component implements OnInit {
cipherType = CipherType;
private tab?: chrome.tabs.Tab;
/**
* Optional initial values to pass to the add cipher form
*/
@Input()
initialValues: NewItemInitialValues;
constructor(
private router: Router,
private dialogService: DialogService,
) {}
constructor(private dialogService: DialogService) {}
private async buildQueryParams(type: CipherType): Promise<AddEditQueryParams> {
const tab = await BrowserApi.getTabFromCurrentWindow();
async ngOnInit() {
this.tab = await BrowserApi.getTabFromCurrentWindow();
}
buildQueryParams(type: CipherType): AddEditQueryParams {
const poppedOut = BrowserPopupUtils.inPopout(window);
const loginDetails: { uri?: string; name?: string } = {};
// When a Login Cipher is created and the extension is not popped out,
// pass along the uri and name
if (!poppedOut && type === CipherType.Login && tab) {
loginDetails.uri = tab.url;
loginDetails.name = Utils.getHostname(tab.url);
if (!poppedOut && type === CipherType.Login && this.tab) {
loginDetails.uri = this.tab.url;
loginDetails.name = Utils.getHostname(this.tab.url);
}
return {
@@ -61,10 +61,6 @@ export class NewItemDropdownV2Component {
};
}
async newItemNavigate(type: CipherType) {
await this.router.navigate(["/add-cipher"], { queryParams: await this.buildQueryParams(type) });
}
openFolderDialog() {
this.dialogService.open(AddEditFolderDialogComponent);
}

View File

@@ -5,7 +5,12 @@ import { Subject } from "rxjs";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AUTOFILL_ID } from "@bitwarden/common/autofill/constants";
import {
AUTOFILL_ID,
COPY_PASSWORD_ID,
COPY_USERNAME_ID,
COPY_VERIFICATION_CODE_ID,
} from "@bitwarden/common/autofill/constants";
import { EventType } from "@bitwarden/common/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -17,7 +22,10 @@ import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { CopyCipherFieldService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service";
@@ -34,17 +42,26 @@ describe("ViewV2Component", () => {
const params$ = new Subject();
const mockNavigate = jest.fn();
const collect = jest.fn().mockResolvedValue(null);
const doAutofill = jest.fn();
const doAutofill = jest.fn().mockResolvedValue(true);
const copy = jest.fn().mockResolvedValue(true);
const mockCipher = {
id: "122-333-444",
type: CipherType.Login,
orgId: "222-444-555",
login: {
username: "test-username",
password: "test-password",
totp: "123",
},
};
const mockVaultPopupAutofillService = {
doAutofill,
};
const mockCopyCipherFieldService = {
copy,
};
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
@@ -57,6 +74,7 @@ describe("ViewV2Component", () => {
mockNavigate.mockClear();
collect.mockClear();
doAutofill.mockClear();
copy.mockClear();
await TestBed.configureTestingModule({
imports: [ViewV2Component],
@@ -91,6 +109,10 @@ describe("ViewV2Component", () => {
canDeleteCipher$: jest.fn().mockReturnValue(true),
},
},
{
provide: CopyCipherFieldService,
useValue: mockCopyCipherFieldService,
},
],
}).compileComponents();
@@ -159,5 +181,46 @@ describe("ViewV2Component", () => {
expect(doAutofill).toHaveBeenCalledOnce();
}));
it('invokes `copy` when action="copy-username"', fakeAsync(() => {
params$.next({ action: COPY_USERNAME_ID });
flush(); // Resolve all promises
expect(copy).toHaveBeenCalledOnce();
}));
it('invokes `copy` when action="copy-password"', fakeAsync(() => {
params$.next({ action: COPY_PASSWORD_ID });
flush(); // Resolve all promises
expect(copy).toHaveBeenCalledOnce();
}));
it('invokes `copy` when action="copy-totp"', fakeAsync(() => {
params$.next({ action: COPY_VERIFICATION_CODE_ID });
flush(); // Resolve all promises
expect(copy).toHaveBeenCalledOnce();
}));
it("closes the popout after a load action", fakeAsync(() => {
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValueOnce(true);
jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockReturnValueOnce(true);
const closeSpy = jest.spyOn(BrowserPopupUtils, "closeSingleActionPopout");
const focusSpy = jest
.spyOn(BrowserApi, "focusTab")
.mockImplementation(() => Promise.resolve());
params$.next({ action: AUTOFILL_ID, senderTabId: 99 });
flush(); // Resolve all promises
expect(doAutofill).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenCalledWith(99);
expect(closeSpy).toHaveBeenCalledOnce();
}));
});
});

View File

@@ -10,7 +10,13 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AUTOFILL_ID, SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants";
import {
AUTOFILL_ID,
COPY_PASSWORD_ID,
COPY_USERNAME_ID,
COPY_VERIFICATION_CODE_ID,
SHOW_AUTOFILL_BUTTON,
} from "@bitwarden/common/autofill/constants";
import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -18,7 +24,6 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
AsyncActionsModule,
@@ -28,19 +33,34 @@ import {
SearchModule,
ToastService,
} from "@bitwarden/components";
import { CopyCipherFieldService } from "@bitwarden/vault";
import { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service";
import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service";
import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window";
import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component";
import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service";
/**
* The types of actions that can be triggered when loading the view vault item popout via the
* extension ContextMenu. See context-menu-clicked-handler.ts for more information.
*/
type LoadAction =
| typeof AUTOFILL_ID
| typeof SHOW_AUTOFILL_BUTTON
| typeof COPY_USERNAME_ID
| typeof COPY_PASSWORD_ID
| typeof COPY_VERIFICATION_CODE_ID;
@Component({
selector: "app-view-v2",
templateUrl: "view-v2.component.html",
@@ -68,10 +88,10 @@ export class ViewV2Component {
headerText: string;
cipher: CipherView;
organization$: Observable<Organization>;
folder$: Observable<FolderView>;
canDeleteCipher$: Observable<boolean>;
collections$: Observable<CollectionView[]>;
loadAction: typeof AUTOFILL_ID | typeof SHOW_AUTOFILL_BUTTON;
loadAction: LoadAction;
senderTabId?: number;
constructor(
private route: ActivatedRoute,
@@ -86,6 +106,7 @@ export class ViewV2Component {
private eventCollectionService: EventCollectionService,
private popupRouterCacheService: PopupRouterCacheService,
protected cipherAuthorizationService: CipherAuthorizationService,
private copyCipherFieldService: CopyCipherFieldService,
) {
this.subscribeToParams();
}
@@ -95,13 +116,15 @@ export class ViewV2Component {
.pipe(
switchMap(async (params): Promise<CipherView> => {
this.loadAction = params.action;
this.senderTabId = params.senderTabId ? parseInt(params.senderTabId, 10) : undefined;
return await this.getCipherData(params.cipherId);
}),
switchMap(async (cipher) => {
this.cipher = cipher;
this.headerText = this.setHeader(cipher.type);
if (this.loadAction === AUTOFILL_ID) {
await this.vaultPopupAutofillService.doAutofill(this.cipher);
if (this.loadAction) {
await this._handleLoadAction(this.loadAction, this.senderTabId);
}
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(cipher);
@@ -211,4 +234,65 @@ export class ViewV2Component {
protected showFooter(): boolean {
return this.cipher && (!this.cipher.isDeleted || (this.cipher.isDeleted && this.cipher.edit));
}
/**
* Handles the load action for the view vault item popout. These actions are typically triggered
* via the extension context menu. It is necessary to render the view for items that have password
* reprompt enabled.
* @param loadAction
* @param senderTabId
* @private
*/
private async _handleLoadAction(loadAction: LoadAction, senderTabId?: number): Promise<void> {
let actionSuccess = false;
// Both vaultPopupAutofillService and copyCipherFieldService will perform password re-prompting internally.
switch (loadAction) {
case "show-autofill-button":
// This action simply shows the cipher view, no need to do anything.
return;
case "autofill":
actionSuccess = await this.vaultPopupAutofillService.doAutofill(this.cipher, false);
break;
case "copy-username":
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.username,
"username",
this.cipher,
);
break;
case "copy-password":
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.password,
"password",
this.cipher,
);
break;
case "copy-totp":
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.totp,
"totp",
this.cipher,
);
break;
}
if (BrowserPopupUtils.inPopout(window)) {
setTimeout(
async () => {
if (
BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.viewVaultItem) &&
senderTabId
) {
await BrowserApi.focusTab(senderTabId);
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
} else {
await this.popupRouterCacheService.back();
}
},
actionSuccess ? 1000 : 0,
);
}
}
}

View File

@@ -118,6 +118,7 @@
type="button"
class="box-content-row"
appStopClick
*ngIf="isSshKeysEnabled"
(click)="selectType(cipherType.SshKey)"
>
<div class="row-main">

View File

@@ -7,7 +7,9 @@ import { first, switchMap, takeUntil } from "rxjs/operators";
import { CollectionView } from "@bitwarden/admin-console/common";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
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 { SyncService } from "@bitwarden/common/platform/sync";
@@ -62,6 +64,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
selectedOrganization: string = null;
showCollections = true;
isSshKeysEnabled = false;
private loadedTimeout: number;
private selectedTimeout: number;
private preventSelected = false;
@@ -95,6 +99,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
private location: Location,
private vaultFilterService: VaultFilterService,
private vaultBrowserStateService: VaultBrowserStateService,
private configService: ConfigService,
) {
this.noFolderListSize = 100;
}
@@ -166,6 +171,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
.subscribe((isSearchable) => {
this.isSearchable = isSearchable;
});
this.isSshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
}
ngOnDestroy() {

View File

@@ -24,8 +24,7 @@ export const GLOBAL_VAULT_UI_ONBOARDING = new KeyDefinition<boolean>(
@Injectable()
export class VaultUiOnboardingService {
// TODO: Update this date to the release date of the new Browser UI
private onboardingUiReleaseDate = new Date("2024-07-25");
private onboardingUiReleaseDate = new Date("2024-12-10");
private vaultUiOnboardingState: GlobalState<boolean> = this.stateProvider.getGlobal(
GLOBAL_VAULT_UI_ONBOARDING,

View File

@@ -30,7 +30,7 @@
<button type="button" bitMenuItem (click)="restore(cipher)">
{{ "restore" | i18n }}
</button>
<button type="button" bitMenuItem (click)="delete(cipher)">
<button type="button" bitMenuItem *appCanDeleteCipher="cipher" (click)="delete(cipher)">
{{ "deleteForever" | i18n }}
</button>
</bit-menu>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { ChangeDetectionStrategy, Component, Input } from "@angular/core";
import { Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -17,7 +17,7 @@ import {
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { CanDeleteCipherDirective, PasswordRepromptService } from "@bitwarden/vault";
@Component({
selector: "app-trash-list-items-container",
@@ -29,10 +29,12 @@ import { PasswordRepromptService } from "@bitwarden/vault";
JslibModule,
SectionComponent,
SectionHeaderComponent,
CanDeleteCipherDirective,
MenuModule,
IconButtonModule,
TypographyModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TrashListItemsContainerComponent {
/**

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CalloutModule, NoItemsModule } from "@bitwarden/components";
@@ -27,6 +27,7 @@ import { TrashListItemsContainerComponent } from "./trash-list-items-container/t
CalloutModule,
NoItemsModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TrashComponent {
protected deletedCiphers$ = this.vaultPopupItemsService.deletedCiphers$;

View File

@@ -21,7 +21,6 @@ import { LoginExport } from "@bitwarden/common/models/export/login.export";
import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
@@ -58,7 +57,6 @@ export class GetCommand extends DownloadCommand {
private auditService: AuditService,
private keyService: KeyService,
encryptService: EncryptService,
private stateService: StateService,
private searchService: SearchService,
private apiService: ApiService,
private organizationService: OrganizationService,

View File

@@ -58,7 +58,6 @@ export class OssServeConfigurator {
this.serviceContainer.auditService,
this.serviceContainer.keyService,
this.serviceContainer.encryptService,
this.serviceContainer.stateService,
this.serviceContainer.searchService,
this.serviceContainer.apiService,
this.serviceContainer.organizationService,

View File

@@ -33,7 +33,7 @@ export class GenerateCommand {
includeNumber: normalizedOptions.includeNumber,
minNumber: normalizedOptions.minNumber,
minSpecial: normalizedOptions.minSpecial,
ambiguous: normalizedOptions.ambiguous,
ambiguous: !normalizedOptions.ambiguous,
};
const enforcedOptions = (await this.stateService.getIsAuthenticated())

View File

@@ -144,7 +144,6 @@ export class SendProgram extends BaseProgram {
this.serviceContainer.auditService,
this.serviceContainer.keyService,
this.serviceContainer.encryptService,
this.serviceContainer.stateService,
this.serviceContainer.searchService,
this.serviceContainer.apiService,
this.serviceContainer.organizationService,

View File

@@ -179,7 +179,6 @@ export class VaultProgram extends BaseProgram {
this.serviceContainer.auditService,
this.serviceContainer.keyService,
this.serviceContainer.encryptService,
this.serviceContainer.stateService,
this.serviceContainer.searchService,
this.serviceContainer.apiService,
this.serviceContainer.organizationService,

File diff suppressed because it is too large Load Diff

View File

@@ -10,23 +10,22 @@ default = ["sys"]
manual_test = []
sys = [
"dep:widestring",
"dep:windows",
"dep:core-foundation",
"dep:security-framework",
"dep:security-framework-sys",
"dep:gio",
"dep:libsecret",
"dep:zbus",
"dep:zbus_polkit",
"dep:widestring",
"dep:windows",
"dep:core-foundation",
"dep:security-framework",
"dep:security-framework-sys",
"dep:zbus",
"dep:zbus_polkit",
]
[dependencies]
aes = "=0.8.4"
anyhow = "=1.0.93"
arboard = { version = "=3.4.1", default-features = false, features = [
"wayland-data-control",
"wayland-data-control",
] }
argon2 = { version = "=0.5.3", features = ["zeroize"] }
async-stream = "=0.3.6"
base64 = "=0.22.1"
byteorder = "=1.5.0"
@@ -45,10 +44,10 @@ scopeguard = "=1.2.0"
sha2 = "=0.10.8"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false, features = [
"encryption",
"ed25519",
"rsa",
"getrandom",
"encryption",
"ed25519",
"rsa",
"getrandom",
] }
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "b4e7f2fedbe3df8c35545feb000176d3e7b2bc32" }
tokio = { version = "=1.41.1", features = ["io-util", "sync", "macros", "net"] }
@@ -83,9 +82,10 @@ keytar = "=0.1.6"
core-foundation = { version = "=0.10.0", optional = true }
security-framework = { version = "=3.0.0", optional = true }
security-framework-sys = { version = "=2.12.0", optional = true }
desktop_objc = { path = "../objc" }
[target.'cfg(target_os = "linux")'.dependencies]
gio = { version = "=0.19.5", optional = true }
libsecret = { version = "=0.5.0", optional = true }
oo7 = "=0.3.3"
zbus = { version = "=4.4.0", optional = true }
zbus_polkit = { version = "=4.0.0", optional = true }

View File

@@ -0,0 +1,5 @@
use anyhow::Result;
pub async fn run_command(value: String) -> Result<String> {
desktop_objc::run_command(value).await
}

View File

@@ -0,0 +1,5 @@
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
mod autofill;
pub use autofill::*;

View File

@@ -0,0 +1,5 @@
use anyhow::Result;
pub async fn run_command(value: String) -> Result<String> {
todo!("Unix does not support autofill");
}

View File

@@ -0,0 +1,5 @@
use anyhow::Result;
pub async fn run_command(value: String) -> Result<String> {
todo!("Windows does not support autofill");
}

View File

@@ -18,7 +18,7 @@ impl super::BiometricTrait for Biometric {
bail!("platform not supported");
}
fn get_biometric_secret(
async fn get_biometric_secret(
_service: &str,
_account: &str,
_key_material: Option<KeyMaterial>,
@@ -26,7 +26,7 @@ impl super::BiometricTrait for Biometric {
bail!("platform not supported");
}
fn set_biometric_secret(
async fn set_biometric_secret(
_service: &str,
_account: &str,
_secret: &str,

View File

@@ -22,20 +22,19 @@ pub struct OsDerivedKey {
pub iv_b64: String,
}
#[allow(async_fn_in_trait)]
pub trait BiometricTrait {
#[allow(async_fn_in_trait)]
async fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool>;
#[allow(async_fn_in_trait)]
async fn available() -> Result<bool>;
fn derive_key_material(secret: Option<&str>) -> Result<OsDerivedKey>;
fn set_biometric_secret(
async fn set_biometric_secret(
service: &str,
account: &str,
secret: &str,
key_material: Option<KeyMaterial>,
iv_b64: &str,
) -> Result<String>;
fn get_biometric_secret(
async fn get_biometric_secret(
service: &str,
account: &str,
key_material: Option<KeyMaterial>,

View File

@@ -73,7 +73,7 @@ impl super::BiometricTrait for Biometric {
Ok(OsDerivedKey { key_b64, iv_b64 })
}
fn set_biometric_secret(
async fn set_biometric_secret(
service: &str,
account: &str,
secret: &str,
@@ -85,11 +85,11 @@ impl super::BiometricTrait for Biometric {
))?;
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
crate::password::set_password(service, account, &encrypted_secret)?;
crate::password::set_password(service, account, &encrypted_secret).await?;
Ok(encrypted_secret)
}
fn get_biometric_secret(
async fn get_biometric_secret(
service: &str,
account: &str,
key_material: Option<KeyMaterial>,
@@ -98,7 +98,7 @@ impl super::BiometricTrait for Biometric {
"Key material is required for polkit protected keys"
))?;
let encrypted_secret = crate::password::get_password(service, account)?;
let encrypted_secret = crate::password::get_password(service, account).await?;
let secret = CipherString::from_str(&encrypted_secret)?;
return Ok(decrypt(&secret, &key_material)?);
}

View File

@@ -121,7 +121,7 @@ impl super::BiometricTrait for Biometric {
Ok(OsDerivedKey { key_b64, iv_b64 })
}
fn set_biometric_secret(
async fn set_biometric_secret(
service: &str,
account: &str,
secret: &str,
@@ -133,11 +133,11 @@ impl super::BiometricTrait for Biometric {
))?;
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
crate::password::set_password(service, account, &encrypted_secret)?;
crate::password::set_password(service, account, &encrypted_secret).await?;
Ok(encrypted_secret)
}
fn get_biometric_secret(
async fn get_biometric_secret(
service: &str,
account: &str,
key_material: Option<KeyMaterial>,
@@ -146,7 +146,7 @@ impl super::BiometricTrait for Biometric {
"Key material is required for Windows Hello protected keys"
))?;
let encrypted_secret = crate::password::get_password(service, account)?;
let encrypted_secret = crate::password::get_password(service, account).await?;
match CipherString::from_str(&encrypted_secret) {
Ok(secret) => {
// If the secret is a CipherString, it is encrypted and we need to decrypt it.
@@ -292,9 +292,9 @@ mod tests {
assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret")
}
#[test]
fn get_biometric_secret_requires_key() {
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None);
#[tokio::test]
async fn get_biometric_secret_requires_key() {
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await;
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
@@ -302,29 +302,25 @@ mod tests {
);
}
#[test]
fn get_biometric_secret_handles_unencrypted_secret() {
scopeguard::defer! {
crate::password::delete_password("test", "test").unwrap();
}
#[tokio::test]
async fn get_biometric_secret_handles_unencrypted_secret() {
let test = "test";
let secret = "password";
let key_material = KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
crate::password::set_password(test, test, secret).unwrap();
crate::password::set_password(test, test, secret).await.unwrap();
let result =
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
.await
.unwrap();
crate::password::delete_password("test", "test").await.unwrap();
assert_eq!(result, secret);
}
#[test]
fn get_biometric_secret_handles_encrypted_secret() {
scopeguard::defer! {
crate::password::delete_password("test", "test").unwrap();
}
#[tokio::test]
async fn get_biometric_secret_handles_encrypted_secret() {
let test = "test";
let secret =
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
@@ -332,17 +328,19 @@ mod tests {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
crate::password::set_password(test, test, &secret.to_string()).unwrap();
crate::password::set_password(test, test, &secret.to_string()).await.unwrap();
let result =
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
.await
.unwrap();
crate::password::delete_password("test", "test").await.unwrap();
assert_eq!(result, "secret");
}
#[test]
fn set_biometric_secret_requires_key() {
let result = <Biometric as BiometricTrait>::set_biometric_secret("", "", "", None, "");
#[tokio::test]
async fn set_biometric_secret_requires_key() {
let result = <Biometric as BiometricTrait>::set_biometric_secret("", "", "", None, "").await;
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),

View File

@@ -5,7 +5,7 @@ use aes::cipher::{
BlockEncryptMut, KeyIvInit,
};
use crate::error::{CryptoError, Result};
use crate::error::{CryptoError, KdfParamError, Result};
use super::CipherString;
@@ -37,3 +37,53 @@ pub fn encrypt_aes256(
Ok(CipherString::AesCbc256_B64 { iv, data })
}
pub fn argon2(
secret: &[u8],
salt: &[u8],
iterations: u32,
memory: u32,
parallelism: u32,
) -> Result<[u8; 32]> {
use argon2::*;
let params = Params::new(memory, iterations, parallelism, Some(32)).map_err(|e| {
KdfParamError::InvalidParams(format!("Argon2 parameters are invalid: {e}",))
})?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut hash = [0u8; 32];
argon
.hash_password_into(secret, &salt, &mut hash)
.map_err(|e| KdfParamError::InvalidParams(format!("Argon2 hashing failed: {e}",)))?;
// Argon2 is using some stack memory that is not zeroed. Eventually some function will
// overwrite the stack, but we use this trick to force the used stack to be zeroed.
#[inline(never)]
fn clear_stack() {
std::hint::black_box([0u8; 4096]);
}
clear_stack();
Ok(hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_argon2() {
let test_hash: [u8; 32] = [
112, 200, 85, 209, 100, 4, 246, 146, 117, 180, 152, 44, 103, 198, 75, 14, 166, 77, 201,
22, 62, 178, 87, 224, 95, 209, 253, 68, 166, 209, 47, 218,
];
let secret = b"supersecurepassword";
let salt = b"mail@example.com";
let iterations = 3;
let memory = 1024 * 64;
let parallelism = 4;
let hash = argon2(secret, salt, iterations, memory, parallelism).unwrap();
assert_eq!(hash, test_hash,);
}
}

View File

@@ -9,6 +9,8 @@ pub enum Error {
#[error("Cryptography Error, {0}")]
Crypto(#[from] CryptoError),
#[error("KDF Parameter Error, {0}")]
KdfParam(#[from] KdfParamError),
}
#[derive(Debug, Error)]
@@ -29,6 +31,12 @@ pub enum CryptoError {
KeyDecrypt,
}
#[derive(Debug, Error)]
pub enum KdfParamError {
#[error("Invalid KDF parameters: {0}")]
InvalidParams(String),
}
// Ensure that the error messages implement Send and Sync
#[cfg(test)]
const _: () = {

View File

@@ -1,3 +1,4 @@
pub mod autofill;
#[cfg(feature = "sys")]
pub mod biometric;
#[cfg(feature = "sys")]
@@ -8,10 +9,10 @@ pub mod ipc;
#[cfg(feature = "sys")]
pub mod password;
#[cfg(feature = "sys")]
pub mod process_isolation;
#[cfg(feature = "sys")]
pub mod powermonitor;
#[cfg(feature = "sys")]
pub mod process_isolation;
#[cfg(feature = "sys")]
pub mod ssh_agent;
#[cfg(feature = "sys")]
pub mod epheremal_values;

View File

@@ -3,26 +3,22 @@ use security_framework::passwords::{
delete_generic_password, get_generic_password, set_generic_password,
};
pub fn get_password(service: &str, account: &str) -> Result<String> {
pub async fn get_password(service: &str, account: &str) -> Result<String> {
let result = String::from_utf8(get_generic_password(&service, &account)?)?;
Ok(result)
}
pub fn get_password_keytar(service: &str, account: &str) -> Result<String> {
get_password(service, account)
}
pub fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
let result = set_generic_password(&service, &account, password.as_bytes())?;
Ok(result)
}
pub fn delete_password(service: &str, account: &str) -> Result<()> {
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
let result = delete_generic_password(&service, &account)?;
Ok(result)
}
pub fn is_available() -> Result<bool> {
pub async fn is_available() -> Result<bool> {
Ok(true)
}
@@ -30,18 +26,17 @@ pub fn is_available() -> Result<bool> {
mod tests {
use super::*;
#[test]
fn test() {
scopeguard::defer!(delete_password("BitwardenTest", "BitwardenTest").unwrap_or({}););
set_password("BitwardenTest", "BitwardenTest", "Random").unwrap();
#[tokio::test]
async fn test() {
set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap();
assert_eq!(
"Random",
get_password("BitwardenTest", "BitwardenTest").unwrap()
get_password("BitwardenTest", "BitwardenTest").await.unwrap()
);
delete_password("BitwardenTest", "BitwardenTest").unwrap();
delete_password("BitwardenTest", "BitwardenTest").await.unwrap();
// Ensure password is deleted
match get_password("BitwardenTest", "BitwardenTest") {
match get_password("BitwardenTest", "BitwardenTest").await {
Ok(_) => panic!("Got a result"),
Err(e) => assert_eq!(
"The specified item could not be found in the keychain.",
@@ -50,9 +45,9 @@ mod tests {
}
}
#[test]
fn test_error_no_password() {
match get_password("Unknown", "Unknown") {
#[tokio::test]
async fn test_error_no_password() {
match get_password("Unknown", "Unknown").await {
Ok(_) => panic!("Got a result"),
Err(e) => assert_eq!(
"The specified item could not be found in the keychain.",

View File

@@ -1,106 +1,106 @@
use anyhow::{anyhow, Result};
use libsecret::{password_clear_sync, password_lookup_sync, password_store_sync, Schema};
use oo7::dbus::{self};
use std::collections::HashMap;
pub fn get_password(service: &str, account: &str) -> Result<String> {
let res = password_lookup_sync(
Some(&get_schema()),
build_attributes(service, account),
gio::Cancellable::NONE,
)?;
match res {
Some(s) => Ok(String::from(s)),
None => Err(anyhow!("No password found")),
}
}
pub fn get_password_keytar(service: &str, account: &str) -> Result<String> {
get_password(service, account)
}
pub fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
let result = password_store_sync(
Some(&get_schema()),
build_attributes(service, account),
Some(&libsecret::COLLECTION_DEFAULT),
&format!("{}/{}", service, account),
password,
gio::Cancellable::NONE,
)?;
Ok(result)
}
pub fn delete_password(service: &str, account: &str) -> Result<()> {
let result = password_clear_sync(
Some(&get_schema()),
build_attributes(service, account),
gio::Cancellable::NONE,
)?;
Ok(result)
}
pub fn is_available() -> Result<bool> {
let result = password_clear_sync(
Some(&get_schema()),
build_attributes("bitwardenSecretsAvailabilityTest", "test"),
gio::Cancellable::NONE,
);
match result {
Ok(_) => Ok(true),
pub async fn get_password(service: &str, account: &str) -> Result<String> {
match get_password_new(service, account).await {
Ok(res) => Ok(res),
Err(_) => {
println!("secret-service unavailable: {:?}", result);
Ok(false)
get_password_legacy(service, account).await
}
}
}
fn get_schema() -> Schema {
let mut attributes = std::collections::HashMap::new();
attributes.insert("service", libsecret::SchemaAttributeType::String);
attributes.insert("account", libsecret::SchemaAttributeType::String);
libsecret::Schema::new(
"org.freedesktop.Secret.Generic",
libsecret::SchemaFlags::NONE,
attributes,
)
async fn get_password_new(service: &str, account: &str) -> Result<String> {
let keyring = oo7::Keyring::new().await?;
let attributes = HashMap::from([("service", service), ("account", account)]);
let results = keyring.search_items(&attributes).await?;
let res = results.get(0);
match res {
Some(res) => {
let secret = res.secret().await?;
Ok(String::from_utf8(secret.to_vec())?)
},
None => Err(anyhow!("no result"))
}
}
fn build_attributes<'a>(service: &'a str, account: &'a str) -> HashMap<&'a str, &'a str> {
let mut attributes = HashMap::new();
attributes.insert("service", service);
attributes.insert("account", account);
// forces to read via secret service; remvove after 2025.03
async fn get_password_legacy(service: &str, account: &str) -> Result<String> {
println!("falling back to get legacy {} {}", service, account);
let svc = dbus::Service::new().await?;
let collection = svc.default_collection().await?;
let keyring = oo7::Keyring::DBus(collection);
let attributes = HashMap::from([("service", service), ("account", account)]);
let results = keyring.search_items(&attributes).await?;
let res = results.get(0);
match res {
Some(res) => {
let secret = res.secret().await?;
println!("deleting legacy secret service entry {} {}", service, account);
keyring.delete(&attributes).await?;
let secret_string = String::from_utf8(secret.to_vec())?;
set_password(service, account, &secret_string).await?;
Ok(secret_string)
},
None => Err(anyhow!("no result"))
}
}
attributes
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
let keyring = oo7::Keyring::new().await?;
let attributes = HashMap::from([("service", service), ("account", account)]);
keyring.create_item("org.freedesktop.Secret.Generic", &attributes, password, true).await?;
Ok(())
}
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
let keyring = oo7::Keyring::new().await?;
let attributes = HashMap::from([("service", service), ("account", account)]);
keyring.delete(&attributes).await?;
Ok(())
}
pub async fn is_available() -> Result<bool> {
match oo7::Keyring::new().await {
Ok(_) => Ok(true),
_ => Ok(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test() {
scopeguard::defer!(delete_password("BitwardenTest", "BitwardenTest").unwrap_or({}););
set_password("BitwardenTest", "BitwardenTest", "Random").unwrap();
#[tokio::test]
async fn test() {
set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap();
assert_eq!(
"Random",
get_password("BitwardenTest", "BitwardenTest").unwrap()
get_password("BitwardenTest", "BitwardenTest").await.unwrap()
);
delete_password("BitwardenTest", "BitwardenTest").unwrap();
delete_password("BitwardenTest", "BitwardenTest").await.unwrap();
// Ensure password is deleted
match get_password("BitwardenTest", "BitwardenTest") {
Ok(_) => panic!("Got a result"),
Err(e) => assert_eq!("No password found", e.to_string()),
match get_password("BitwardenTest", "BitwardenTest").await {
Ok(_) => {
panic!("Got a result")
}
Err(e) => assert_eq!(
"no result",
e.to_string()
),
}
}
#[test]
fn test_error_no_password() {
match get_password("BitwardenTest", "BitwardenTest") {
#[tokio::test]
async fn test_error_no_password() {
match get_password("Unknown", "Unknown").await {
Ok(_) => panic!("Got a result"),
Err(e) => assert_eq!("No password found", e.to_string()),
Err(e) => assert_eq!(
"no result",
e.to_string()
),
}
}
}

View File

@@ -13,7 +13,7 @@ use windows::{
const CRED_FLAGS_NONE: u32 = 0;
pub fn get_password<'a>(service: &str, account: &str) -> Result<String> {
pub async fn get_password<'a>(service: &str, account: &str) -> Result<String> {
let target_name = U16CString::from_str(target_name(service, account))?;
let mut credential: *mut CREDENTIALW = std::ptr::null_mut();
@@ -45,39 +45,7 @@ pub fn get_password<'a>(service: &str, account: &str) -> Result<String> {
Ok(String::from(password))
}
// Remove this after sufficient releases
pub fn get_password_keytar<'a>(service: &str, account: &str) -> Result<String> {
let target_name = U16CString::from_str(target_name(service, account))?;
let mut credential: *mut CREDENTIALW = std::ptr::null_mut();
let credential_ptr = &mut credential;
let result = unsafe {
CredReadW(
PCWSTR(target_name.as_ptr()),
CRED_TYPE_GENERIC,
CRED_FLAGS_NONE,
credential_ptr,
)
};
scopeguard::defer!({
unsafe { CredFree(credential as *mut _) };
});
result?;
let password = unsafe {
std::str::from_utf8_unchecked(std::slice::from_raw_parts(
(*credential).CredentialBlob,
(*credential).CredentialBlobSize as usize,
))
};
Ok(String::from(password))
}
pub fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
let mut target_name = U16CString::from_str(target_name(service, account))?;
let mut user_name = U16CString::from_str(account)?;
let last_written = FILETIME {
@@ -108,7 +76,7 @@ pub fn set_password(service: &str, account: &str, password: &str) -> Result<()>
Ok(())
}
pub fn delete_password(service: &str, account: &str) -> Result<()> {
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
let target_name = U16CString::from_str(target_name(service, account))?;
unsafe {
@@ -122,7 +90,7 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> {
Ok(())
}
pub fn is_available() -> Result<bool> {
pub async fn is_available() -> Result<bool> {
Ok(true)
}
@@ -142,36 +110,25 @@ fn convert_error(e: windows::core::Error) -> String {
mod tests {
use super::*;
#[test]
fn test() {
scopeguard::defer!(delete_password("BitwardenTest", "BitwardenTest").unwrap_or({}););
set_password("BitwardenTest", "BitwardenTest", "Random").unwrap();
#[tokio::test]
async fn test() {
set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap();
assert_eq!(
"Random",
get_password("BitwardenTest", "BitwardenTest").unwrap()
get_password("BitwardenTest", "BitwardenTest").await.unwrap()
);
delete_password("BitwardenTest", "BitwardenTest").unwrap();
delete_password("BitwardenTest", "BitwardenTest").await.unwrap();
// Ensure password is deleted
match get_password("BitwardenTest", "BitwardenTest") {
match get_password("BitwardenTest", "BitwardenTest").await {
Ok(_) => panic!("Got a result"),
Err(e) => assert_eq!("Password not found.", e.to_string()),
}
}
#[test]
fn test_get_password_keytar() {
scopeguard::defer!(delete_password("BitwardenTest", "BitwardenTest").unwrap_or({}););
keytar::set_password("BitwardenTest", "BitwardenTest", "HelloFromKeytar").unwrap();
assert_eq!(
"HelloFromKeytar",
get_password_keytar("BitwardenTest", "BitwardenTest").unwrap()
);
}
#[test]
fn test_error_no_password() {
match get_password("BitwardenTest", "BitwardenTest") {
#[tokio::test]
async fn test_error_no_password() {
match get_password("BitwardenTest", "BitwardenTest").await {
Ok(_) => panic!("Got a result"),
Err(e) => assert_eq!("Password not found.", e.to_string()),
}

View File

@@ -19,7 +19,7 @@ hex = "=0.4.3"
anyhow = "=1.0.93"
desktop_core = { path = "../core" }
napi = { version = "=2.16.13", features = ["async"] }
napi-derive = "=2.16.12"
napi-derive = "=2.16.13"
tokio = { version = "=1.41.1" }
tokio-util = "=0.7.12"
tokio-stream = "=0.1.15"

View File

@@ -6,8 +6,6 @@
export declare namespace passwords {
/** Fetch the stored password from the keychain. */
export function getPassword(service: string, account: string): Promise<string>
/** Fetch the stored password from the keychain that was stored with Keytar. */
export function getPasswordKeytar(service: string, account: string): Promise<string>
/** Save the password to the keychain. Adds an entry if none exists otherwise updates the existing entry. */
export function setPassword(service: string, account: string, password: string): Promise<void>
/** Delete the stored password from the keychain. */
@@ -132,3 +130,9 @@ export declare namespace epheremal_values {
remove(key: string): void
}
}
export declare namespace autofill {
export function runCommand(value: string): Promise<string>
}
export declare namespace crypto {
export function argon2(secret: Buffer, salt: Buffer, iterations: number, memory: number, parallelism: number): Promise<Buffer>
}

View File

@@ -9,13 +9,7 @@ pub mod passwords {
#[napi]
pub async fn get_password(service: String, account: String) -> napi::Result<String> {
desktop_core::password::get_password(&service, &account)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Fetch the stored password from the keychain that was stored with Keytar.
#[napi]
pub async fn get_password_keytar(service: String, account: String) -> napi::Result<String> {
desktop_core::password::get_password_keytar(&service, &account)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
@@ -27,6 +21,7 @@ pub mod passwords {
password: String,
) -> napi::Result<()> {
desktop_core::password::set_password(&service, &account, &password)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
@@ -34,13 +29,16 @@ pub mod passwords {
#[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().map_err(|e| napi::Error::from_reason(e.to_string()))
desktop_core::password::is_available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
@@ -81,6 +79,7 @@ pub mod biometrics {
key_material.map(|m| m.into()),
&iv_b64,
)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
@@ -92,6 +91,7 @@ pub mod biometrics {
) -> napi::Result<String> {
let result =
Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into()))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()));
result
}
@@ -249,13 +249,17 @@ pub mod sshagent {
pub async fn serve(
callback: ThreadsafeFunction<(String, bool), CalleeHandled>,
) -> napi::Result<SshAgentState> {
let (auth_request_tx, mut auth_request_rx) = tokio::sync::mpsc::channel::<(u32, (String, bool))>(32);
let (auth_response_tx, auth_response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(32);
let (auth_request_tx, mut auth_request_rx) =
tokio::sync::mpsc::channel::<(u32, (String, bool))>(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));
tokio::spawn(async move {
let _ = auth_response_rx;
while let Some((request_id, (cipher_uuid, is_list_request))) = auth_request_rx.recv().await {
while let Some((request_id, (cipher_uuid, is_list_request))) =
auth_request_rx.recv().await
{
let cloned_request_id = request_id.clone();
let cloned_cipher_uuid = cipher_uuid.clone();
let cloned_response_tx_arc = auth_response_tx_arc.clone();
@@ -265,23 +269,33 @@ pub mod sshagent {
let cipher_uuid = cloned_cipher_uuid;
let auth_response_tx_arc = cloned_response_tx_arc;
let callback = cloned_callback;
let promise_result: Result<Promise<bool>, napi::Error> =
callback.call_async(Ok((cipher_uuid, is_list_request))).await;
let promise_result: Result<Promise<bool>, napi::Error> = callback
.call_async(Ok((cipher_uuid, is_list_request)))
.await;
match promise_result {
Ok(promise_result) => match promise_result.await {
Ok(result) => {
let _ = auth_response_tx_arc.lock().await.send((request_id, result))
let _ = auth_response_tx_arc
.lock()
.await
.send((request_id, result))
.expect("should be able to send auth response to agent");
}
Err(e) => {
println!("[SSH Agent Native Module] calling UI callback promise was rejected: {}", e);
let _ = auth_response_tx_arc.lock().await.send((request_id, false))
let _ = auth_response_tx_arc
.lock()
.await
.send((request_id, false))
.expect("should be able to send auth response to agent");
}
},
Err(e) => {
println!("[SSH Agent Native Module] calling UI callback could not create promise: {}", e);
let _ = auth_response_tx_arc.lock().await.send((request_id, false))
let _ = auth_response_tx_arc
.lock()
.await
.send((request_id, false))
.expect("should be able to send auth response to agent");
}
}
@@ -348,7 +362,9 @@ pub mod sshagent {
#[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()))
bitwarden_agent_state
.clear_keys()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
@@ -564,3 +580,30 @@ pub mod epheremal_values {
}
}
}
pub mod autofill {
#[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()))
}
}
#[napi]
pub mod crypto {
use napi::bindgen_prelude::Buffer;
#[napi]
pub async fn argon2(
secret: Buffer,
salt: Buffer,
iterations: u32,
memory: u32,
parallelism: u32,
) -> napi::Result<Buffer> {
desktop_core::crypto::argon2(&secret, &salt, iterations, memory, parallelism)
.map_err(|e| napi::Error::from_reason(e.to_string()))
.map(|v| v.to_vec())
.map(|v| Buffer::from(v))
}
}

View File

@@ -0,0 +1,21 @@
[package]
edition = "2021"
license = "GPL-3.0"
name = "desktop_objc"
version = "0.0.0"
publish = false
[features]
default = []
[dependencies]
anyhow = "=1.0.93"
thiserror = "=1.0.69"
tokio = "1.39.1"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "=0.9.4"
[build-dependencies]
cc = "1.0.104"
glob = "0.3.1"

View File

@@ -0,0 +1,22 @@
use glob::glob;
#[cfg(target_os = "macos")]
fn main() {
let mut builder = cc::Build::new();
// Auto compile all .m files in the src/native directory
for entry in glob("src/native/**/*.m").expect("Failed to read glob pattern") {
let path = entry.expect("Failed to read glob entry");
builder.file(path.clone());
println!("cargo::rerun-if-changed={}", path.display());
}
builder
.flag("-fobjc-arc") // Enable Auto Reference Counting (ARC)
.compile("autofill");
}
#[cfg(not(target_os = "macos"))]
fn main() {
// Crate is only supported on macOS
}

View File

@@ -0,0 +1,124 @@
#![cfg(target_os = "macos")]
use std::{
ffi::{c_char, CStr, CString},
os::raw::c_void,
};
use anyhow::{Context, Result};
#[repr(C)]
pub struct ObjCString {
value: *const c_char,
size: usize,
}
#[repr(C)]
pub struct CommandContext {
tx: Option<tokio::sync::oneshot::Sender<String>>,
}
impl CommandContext {
pub fn new() -> (Self, tokio::sync::oneshot::Receiver<String>) {
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
(CommandContext { tx: Some(tx) }, rx)
}
pub fn send(&mut self, value: String) -> Result<()> {
let tx = self.tx.take().context(
"Failed to take Sender from CommandContext. Has this context already returned once?",
)?;
tx.send(value).map_err(|_| {
anyhow::anyhow!("Failed to send ObjCString from CommandContext to Rust code")
})?;
Ok(())
}
pub fn as_ptr(&mut self) -> *mut c_void {
self as *mut Self as *mut c_void
}
}
impl TryFrom<ObjCString> for String {
type Error = anyhow::Error;
fn try_from(value: ObjCString) -> Result<Self> {
let c_str = unsafe { CStr::from_ptr(value.value) };
let str = c_str
.to_str()
.context("Failed to convert ObjC output string to &str for use in Rust")?;
Ok(str.to_owned())
}
}
impl Drop for ObjCString {
fn drop(&mut self) {
unsafe {
objc::freeObjCString(self);
}
}
}
mod objc {
use std::os::raw::c_void;
use super::*;
extern "C" {
pub fn runCommand(context: *mut c_void, value: *const c_char);
pub fn freeObjCString(value: &ObjCString);
}
/// This function is called from the ObjC code to return the output of the command
#[no_mangle]
pub extern "C" fn commandReturn(context: &mut CommandContext, value: ObjCString) -> bool {
let value: String = match value.try_into() {
Ok(value) => value,
Err(e) => {
println!(
"Error: Failed to convert ObjCString to Rust string during commandReturn: {}",
e
);
return false;
}
};
match context.send(value) {
Ok(_) => 0,
Err(e) => {
println!(
"Error: Failed to return ObjCString from ObjC code to Rust code: {}",
e
);
return false;
}
};
return true;
}
}
pub async fn run_command(input: String) -> Result<String> {
// Convert input to type that can be passed to ObjC code
let c_input = CString::new(input)
.context("Failed to convert Rust input string to a CString for use in call to ObjC code")?;
let (mut context, rx) = CommandContext::new();
// Call ObjC code
unsafe { objc::runCommand(context.as_ptr(), c_input.as_ptr()) };
// Convert output from ObjC code to Rust string
let objc_output = rx.await?.try_into()?;
// Convert output from ObjC code to Rust string
// let objc_output = output.try_into()?;
Ok(objc_output)
}

View File

@@ -0,0 +1,2 @@
CompileFlags:
Add: [-fobjc-arc]

View File

@@ -0,0 +1,8 @@
#ifndef STATUS_H
#define STATUS_H
#import <Foundation/Foundation.h>
void status(void *context, NSDictionary *params);
#endif

View File

@@ -0,0 +1,57 @@
#import <Foundation/Foundation.h>
#import <AuthenticationServices/ASCredentialIdentityStore.h>
#import <AuthenticationServices/ASCredentialIdentityStoreState.h>
#import "../../interop.h"
#import "status.h"
void storeState(void (^callback)(ASCredentialIdentityStoreState*)) {
if (@available(macos 11, *)) {
ASCredentialIdentityStore *store = [ASCredentialIdentityStore sharedStore];
[store getCredentialIdentityStoreStateWithCompletion:^(ASCredentialIdentityStoreState * _Nonnull state) {
callback(state);
}];
} else {
callback(nil);
}
}
BOOL fido2Supported() {
if (@available(macos 14, *)) {
return YES;
} else {
return NO;
}
}
BOOL passwordSupported() {
if (@available(macos 11, *)) {
return YES;
} else {
return NO;
}
}
void status(void* context, __attribute__((unused)) NSDictionary *params) {
storeState(^(ASCredentialIdentityStoreState *state) {
BOOL enabled = NO;
BOOL supportsIncremental = NO;
if (state != nil) {
enabled = state.isEnabled;
supportsIncremental = state.supportsIncrementalUpdates;
}
_return(context,
_success(@{
@"support": @{
@"fido2": @(fido2Supported()),
@"password": @(passwordSupported()),
@"incrementalUpdates": @(supportsIncremental),
},
@"state": @{
@"enabled": @(enabled),
}
})
);
});
}

View File

@@ -0,0 +1,8 @@
#ifndef SYNC_H
#define SYNC_H
#import <Foundation/Foundation.h>
void runSync(void *context, NSDictionary *params);
#endif

View File

@@ -0,0 +1,59 @@
#import <Foundation/Foundation.h>
#import <AuthenticationServices/ASCredentialIdentityStore.h>
#import <AuthenticationServices/ASCredentialIdentityStoreState.h>
#import <AuthenticationServices/ASCredentialServiceIdentifier.h>
#import <AuthenticationServices/ASPasswordCredentialIdentity.h>
#import <AuthenticationServices/ASPasskeyCredentialIdentity.h>
#import "../../utils.h"
#import "../../interop.h"
#import "sync.h"
// 'run' is added to the name because it clashes with internal macOS function
void runSync(void* context, NSDictionary *params) {
NSArray *credentials = params[@"credentials"];
// Map credentials to ASPasswordCredential objects
NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count];
for (NSDictionary *credential in credentials) {
NSString *type = credential[@"type"];
if ([type isEqualToString:@"password"]) {
NSString *cipherId = credential[@"cipherId"];
NSString *uri = credential[@"uri"];
NSString *username = credential[@"username"];
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc]
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
[mappedCredentials addObject:credential];
}
if ([type isEqualToString:@"fido2"]) {
NSString *cipherId = credential[@"cipherId"];
NSString *rpId = credential[@"rpId"];
NSString *userName = credential[@"userName"];
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
ASPasskeyCredentialIdentity *credential = [[ASPasskeyCredentialIdentity alloc]
initWithRelyingPartyIdentifier:rpId
userName:userName
credentialID:credentialId
userHandle:userHandle
recordIdentifier:cipherId];
[mappedCredentials addObject:credential];
}
}
[ASCredentialIdentityStore.sharedStore replaceCredentialIdentityEntries:mappedCredentials
completion:^(__attribute__((unused)) BOOL success, NSError * _Nullable error) {
if (error) {
return _return(context, _error_er(error));
}
_return(context, _success(@{@"added": @([mappedCredentials count])}));
}];
}

View File

@@ -0,0 +1,8 @@
#ifndef RUN_AUTOFILL_COMMAND_H
#define RUN_AUTOFILL_COMMAND_H
#import <Foundation/Foundation.h>
void runAutofillCommand(void* context, NSDictionary *input);
#endif

View File

@@ -0,0 +1,20 @@
#import <Foundation/Foundation.h>
#import "commands/sync.h"
#import "commands/status.h"
#import "../interop.h"
#import "../utils.h"
#import "run_autofill_command.h"
void runAutofillCommand(void* context, NSDictionary *input) {
NSString *command = input[@"command"];
NSDictionary *params = input[@"params"];
if ([command isEqual:@"status"]) {
return status(context, params);
} else if ([command isEqual:@"sync"]) {
return runSync(context, params);
}
_return(context, _error([NSString stringWithFormat:@"Unknown command: %@", command]));
}

View File

@@ -0,0 +1,47 @@
#ifndef INTEROP_H
#define INTEROP_H
#import <Foundation/Foundation.h>
// Tips for developing Objective-C code:
// - Use the `NSLog` function to log messages to the system log
// - Example:
// NSLog(@"An example log: %@", someVariable);
// - Use the `@try` and `@catch` directives to catch exceptions
#if !__has_feature(objc_arc)
// Auto Reference Counting makes memory management easier for Objective-C objects
// Regular C objects still need to be managed manually
#error ARC must be enabled!
#endif
/// [Shared with Rust]
/// Simple struct to hold a C-string and its length
/// This is used to return strings created in Objective-C to Rust
/// so that Rust can free the memory when it's done with the string
struct ObjCString
{
char *value;
size_t size;
};
/// [Defined in Rust]
/// External function callable from Objective-C to return a string to Rust
extern bool commandReturn(void *context, struct ObjCString output);
/// [Callable from Rust]
/// Frees the memory allocated for an ObjCString
void freeObjCString(struct ObjCString *value);
// --- Helper functions to convert between Objective-C and Rust types ---
NSString *_success(NSDictionary *value);
NSString *_error(NSString *error);
NSString *_error_er(NSError *error);
NSString *_error_ex(NSException *error);
void _return(void *context, NSString *output);
struct ObjCString nsStringToObjCString(NSString *string);
NSString *cStringToNSString(char *string);
#endif

View File

@@ -0,0 +1,71 @@
#import "interop.h"
#import "utils.h"
/// [Callable from Rust]
/// Frees the memory allocated for an ObjCString
void freeObjCString(struct ObjCString *value) {
free(value->value);
}
// --- Helper functions to convert between Objective-C and Rust types ---
NSString *_success(NSDictionary *value) {
NSDictionary *wrapper = @{@"type": @"success", @"value": value};
NSError *jsonError = nil;
NSString *toReturn = serializeJson(wrapper, jsonError);
if (jsonError) {
// Manually format message since there seems to be an issue with the JSON serialization
return [NSString stringWithFormat:@"{\"type\": \"error\", \"error\": \"Error occurred while serializing error: %@\"}", jsonError];
}
return toReturn;
}
NSString *_error(NSString *error) {
NSDictionary *errorDictionary = @{@"type": @"error", @"error": error};
NSError *jsonError = nil;
NSString *toReturn = serializeJson(errorDictionary, jsonError);
if (jsonError) {
// Manually format message since there seems to be an issue with the JSON serialization
return [NSString stringWithFormat:@"{\"type\": \"error\", \"error\": \"Error occurred while serializing error: %@\"}", jsonError];
}
return toReturn;
}
NSString *_error_er(NSError *error) {
return _error([error localizedDescription]);
}
NSString *_error_ex(NSException *error) {
return _error([NSString stringWithFormat:@"%@ (%@): %@", error.name, error.reason, [error callStackSymbols]]);
}
void _return(void* context, NSString *output) {
if (!commandReturn(context, nsStringToObjCString(output))) {
NSLog(@"Error: Failed to return command output");
// NOTE: This will most likely crash the application
@throw [NSException exceptionWithName:@"CommandReturnError" reason:@"Failed to return command output" userInfo:nil];
}
}
/// Converts an NSString to an ObjCString struct
struct ObjCString nsStringToObjCString(NSString* string) {
size_t size = [string lengthOfBytesUsingEncoding:NSUTF8StringEncoding] + 1;
char *value = malloc(size);
[string getCString:value maxLength:size encoding:NSUTF8StringEncoding];
struct ObjCString objCString;
objCString.value = value;
objCString.size = size;
return objCString;
}
/// Converts a C-string to an NSString
NSString* cStringToNSString(char* string) {
return [[NSString alloc] initWithUTF8String:string];
}

View File

@@ -0,0 +1,39 @@
#import <Foundation/Foundation.h>
#import "autofill/run_autofill_command.h"
#import "interop.h"
#import "utils.h"
void pickAndRunCommand(void* context, NSDictionary *input) {
NSString *namespace = input[@"namespace"];
if ([namespace isEqual:@"autofill"]) {
return runAutofillCommand(context, input);
}
_return(context, _error([NSString stringWithFormat:@"Unknown namespace: %@", namespace]));
}
/// [Callable from Rust]
/// Runs a command with the given input JSON
/// This function is called from Rust and is the entry point for running Objective-C code.
/// It takes a JSON string as input, deserializes it, runs the command, and serializes the output.
/// It also catches any exceptions that occur during the command execution.
void runCommand(void *context, char* inputJson) {
@autoreleasepool {
@try {
NSString *inputString = cStringToNSString(inputJson);
NSError *error = nil;
NSDictionary *input = parseJson(inputString, error);
if (error) {
NSLog(@"Error occured while deserializing input params: %@", error);
return _return(context, _error([NSString stringWithFormat:@"Error occured while deserializing input params: %@", error]));
}
pickAndRunCommand(context, input);
} @catch (NSException *e) {
NSLog(@"Error occurred while running Objective-C command: %@", e);
_return(context, _error([NSString stringWithFormat:@"Error occurred while running Objective-C command: %@", e]));
}
}
}

View File

@@ -0,0 +1,11 @@
#ifndef UTILS_H
#define UTILS_H
#import <Foundation/Foundation.h>
NSDictionary *parseJson(NSString *jsonString, NSError *error);
NSString *serializeJson(NSDictionary *dictionary, NSError *error);
NSData *decodeBase64URL(NSString *base64URLString);
#endif

View File

@@ -0,0 +1,28 @@
#import "utils.h"
NSDictionary *parseJson(NSString *jsonString, NSError *error) {
NSData *data = [jsonString dataUsingEncoding:NSUTF8StringEncoding];
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
if (error) {
return nil;
}
return json;
}
NSString *serializeJson(NSDictionary *dictionary, NSError *error) {
NSData *data = [NSJSONSerialization dataWithJSONObject:dictionary options:0 error:&error];
if (error) {
return nil;
}
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}
NSData *decodeBase64URL(NSString *base64URLString) {
NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"];
base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"];
NSData *nsdataFromBase64String = [[NSData alloc]
initWithBase64EncodedString:base64String options:0];
return nsdataFromBase64String;
}

View File

@@ -18,12 +18,7 @@
"**/*",
"!**/node_modules/@bitwarden/desktop-napi/**/*",
"**/node_modules/@bitwarden/desktop-napi/index.js",
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node",
"!**/node_modules/argon2/**/*",
"**/node_modules/argon2/argon2.cjs",
"**/node_modules/argon2/package.json",
"**/node_modules/argon2/build/Release/argon2.node"
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
],
"electronVersion": "32.1.1",
"generateUpdatesFilesForAllChannels": true,
@@ -229,7 +224,7 @@
},
"deb": {
"artifactName": "${productName}-${version}-${arch}.${ext}",
"depends": ["libnotify4", "libxtst6", "libnss3", "libsecret-1-0", "libxss1"]
"depends": ["libnotify4", "libxtst6", "libnss3", "libxss1"]
},
"appImage": {
"artifactName": "${productName}-${version}-${arch}.${ext}"

View File

@@ -19,17 +19,18 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
If using the credential would require showing custom UI for authenticating the user, cancel
the request with error code ASExtensionError.userInteractionRequired.
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
let databaseIsUnlocked = true
if (databaseIsUnlocked) {
let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234")
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
} else {
self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code:ASExtensionError.userInteractionRequired.rawValue))
}
}
*/
*/
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
// let databaseIsUnlocked = true
// if (databaseIsUnlocked) {
let passwordCredential = ASPasswordCredential(user: credentialIdentity.user, password: "example1234")
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
// } else {
// self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code:ASExtensionError.userInteractionRequired.rawValue))
// }
}
/*
Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with
ASExtensionError.userInteractionRequired. In this case, the system may present your extension's

View File

@@ -48,6 +48,7 @@
"dist:mac": "npm run build && npm run pack:mac",
"dist:mac:mas": "npm run build && npm run pack:mac:mas",
"dist:mac:masdev": "npm run build && npm run pack:mac:masdev",
"dist:mac:masdev:with-extension": "npm run build && npm run pack:mac:masdev:with-extension",
"dist:win": "npm run build && npm run pack:win",
"dist:win:ci": "npm run build && npm run pack:win:ci",
"publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always",

View File

@@ -28,8 +28,9 @@ async function buildMacOs() {
"-alltargets",
"-configuration",
"Release",
"-xcconfig",
paths.macOsConfig,
// Uncomment when signing is fixed
// "-xcconfig",
// paths.macOsConfig,
]);
stdOutProc(proc);
await new Promise((resolve, reject) =>

View File

@@ -5,6 +5,7 @@ import {
DesktopDefaultOverlayPosition,
EnvironmentSelectorComponent,
} from "@bitwarden/angular/auth/components/environment-selector.component";
import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component";
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
import {
authGuard,
@@ -35,6 +36,7 @@ import {
VaultIcon,
LoginDecryptionOptionsComponent,
DevicesIcon,
TwoFactorTimeoutIcon,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -96,6 +98,22 @@ const routes: Routes = [
],
},
),
{
path: "2fa-timeout",
component: AnonLayoutWrapperComponent,
children: [
{
path: "",
component: TwoFactorTimeoutComponent,
},
],
data: {
pageIcon: TwoFactorTimeoutIcon,
pageTitle: {
key: "authenticationTimeout",
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{ path: "register", component: RegisterComponent },
{
path: "vault",

View File

@@ -20,6 +20,7 @@ import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/va
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
import { SshAgentService } from "../../platform/services/ssh-agent.service";
import { VersionService } from "../../platform/services/version.service";
@@ -45,6 +46,7 @@ export class InitService {
private accountService: AccountService,
private versionService: VersionService,
private sshAgentService: SshAgentService,
private autofillService: DesktopAutofillService,
@Inject(DOCUMENT) private document: Document,
) {}
@@ -82,6 +84,8 @@ export class InitService {
const containerService = new ContainerService(this.keyService, this.encryptService);
containerService.attachToGlobal(this.win);
await this.autofillService.init();
};
}
}

View File

@@ -19,6 +19,13 @@ export class RendererCryptoFunctionService
memory: number,
parallelism: number,
): Promise<Uint8Array> {
if (typeof password === "string") {
password = new TextEncoder().encode(password);
}
if (typeof salt === "string") {
salt = new TextEncoder().encode(salt);
}
return await ipc.platform.crypto.argon2(password, salt, iterations, memory, parallelism);
}
}

View File

@@ -51,6 +51,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -95,6 +96,7 @@ import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-l
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
import { DesktopPinService } from "../../auth/services/desktop-pin.service";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service";
import { flagEnabled } from "../../platform/flags";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@@ -307,6 +309,10 @@ const safeProviders: SafeProvider[] = [
provide: DesktopAutofillSettingsService,
deps: [StateProvider],
}),
safeProvider({
provide: DesktopAutofillService,
deps: [LogService, CipherServiceAbstraction, ConfigService],
}),
safeProvider({
provide: NativeMessagingManifestService,
useClass: NativeMessagingManifestService,

View File

@@ -6,6 +6,7 @@
<button
type="button"
bitLink
linkType="primary"
bit-item-content
aria-haspopup="true"
(click)="openHistoryDialog()"

View File

@@ -17,15 +17,7 @@ import {
standalone: true,
selector: "credential-generator",
templateUrl: "credential-generator.component.html",
imports: [
DialogModule,
ButtonModule,
JslibModule,
GeneratorModule,
ItemModule,
ButtonModule,
LinkModule,
],
imports: [DialogModule, ButtonModule, JslibModule, GeneratorModule, ItemModule, LinkModule],
})
export class CredentialGeneratorComponent {
constructor(private dialogService: DialogService) {}

View File

@@ -0,0 +1,9 @@
import { ipcRenderer } from "electron";
import { Command } from "../platform/main/autofill/command";
import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main";
export default {
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
ipcRenderer.invoke("autofill.runCommand", params),
};

View File

@@ -0,0 +1,121 @@
import { Injectable, OnDestroy } from "@angular/core";
import { EMPTY, Subject, distinctUntilChanged, mergeMap, switchMap, takeUntil } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { NativeAutofillStatusCommand } from "../../platform/main/autofill/status.command";
import {
NativeAutofillFido2Credential,
NativeAutofillPasswordCredential,
NativeAutofillSyncCommand,
} from "../../platform/main/autofill/sync.command";
@Injectable()
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
constructor(
private logService: LogService,
private cipherService: CipherService,
private configService: ConfigService,
) {}
async init() {
this.configService
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
.pipe(
distinctUntilChanged(),
switchMap((enabled) => {
if (!enabled) {
return EMPTY;
}
return this.cipherService.cipherViews$;
}),
// TODO: This will unset all the autofill credentials on the OS
// when the account locks. We should instead explicilty clear the credentials
// when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead.
mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))),
takeUntil(this.destroy$),
)
.subscribe();
}
/** Give metadata about all available credentials in the users vault */
async sync(cipherViews: CipherView[]) {
const status = await this.status();
if (status.type === "error") {
return this.logService.error("Error getting autofill status", status.error);
}
if (!status.value.state.enabled) {
// Autofill is disabled
return;
}
let fido2Credentials: NativeAutofillFido2Credential[];
let passwordCredentials: NativeAutofillPasswordCredential[];
if (status.value.support.password) {
passwordCredentials = cipherViews
.filter(
(cipher) =>
cipher.type === CipherType.Login &&
cipher.login.uris?.length > 0 &&
cipher.login.uris.some((uri) => uri.match !== UriMatchStrategy.Never) &&
cipher.login.uris.some((uri) => !Utils.isNullOrWhitespace(uri.uri)) &&
!Utils.isNullOrWhitespace(cipher.login.username),
)
.map((cipher) => ({
type: "password",
cipherId: cipher.id,
uri: cipher.login.uris.find((uri) => uri.match !== UriMatchStrategy.Never).uri,
username: cipher.login.username,
}));
}
if (status.value.support.fido2) {
fido2Credentials = (await getCredentialsForAutofill(cipherViews)).map((credential) => ({
type: "fido2",
...credential,
}));
}
const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({
namespace: "autofill",
command: "sync",
params: {
credentials: [...fido2Credentials, ...passwordCredentials],
},
});
if (syncResult.type === "error") {
return this.logService.error("Error syncing autofill credentials", syncResult.error);
}
this.logService.debug(`Synced ${syncResult.value.added} autofill credentials`);
}
/** Get autofill status from OS */
private status() {
// TODO: Investigate why this type needs to be explicitly set
return ipc.autofill.runCommand<NativeAutofillStatusCommand>({
namespace: "autofill",
command: "status",
params: {},
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -919,6 +919,12 @@
"baseUrl": {
"message": "Server URL"
},
"authenticationTimeout": {
"message": "Authentication timeout"
},
"authenticationSessionTimedOut": {
"message": "The authentication session timed out. Please restart the login process."
},
"selfHostBaseUrl": {
"message": "Self-host server URL",
"description": "Label for field requesting a self-hosted integration service URL"

View File

@@ -35,6 +35,7 @@ import { PowerMonitorMain } from "./main/power-monitor.main";
import { TrayMain } from "./main/tray.main";
import { UpdaterMain } from "./main/updater.main";
import { WindowMain } from "./main/window.main";
import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main";
import { ClipboardMain } from "./platform/main/clipboard.main";
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service";
@@ -72,6 +73,7 @@ export class Main {
biometricsService: DesktopBiometricsService;
nativeMessagingMain: NativeMessagingMain;
clipboardMain: ClipboardMain;
nativeAutofillMain: NativeAutofillMain;
desktopAutofillSettingsService: DesktopAutofillSettingsService;
versionMain: VersionMain;
sshAgentService: MainSshAgentService;
@@ -256,6 +258,9 @@ export class Main {
new EphemeralValueStorageService();
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
this.nativeAutofillMain = new NativeAutofillMain(this.logService);
void this.nativeAutofillMain.init();
}
bootstrap() {

View File

@@ -9,8 +9,7 @@
"version": "2024.12.0",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi",
"argon2": "0.41.1"
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
}
},
"../desktop_native/napi": {

View File

@@ -12,7 +12,6 @@
"url": "git+https://github.com/bitwarden/clients.git"
},
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi",
"argon2": "0.41.1"
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
}
}

View File

@@ -0,0 +1,23 @@
import { NativeAutofillStatusCommand } from "./status.command";
import { NativeAutofillSyncCommand } from "./sync.command";
export type CommandDefinition = {
namespace: string;
name: string;
input: Record<string, unknown>;
output: Record<string, unknown>;
};
export type CommandOutput<SuccessOutput> =
| {
type: "error";
error: string;
}
| { type: "success"; value: SuccessOutput };
export type IpcCommandInvoker<C extends CommandDefinition> = (
params: C["input"],
) => Promise<CommandOutput<C["output"]>>;
/** A list of all available commands */
export type Command = NativeAutofillSyncCommand | NativeAutofillStatusCommand;

View File

@@ -0,0 +1,53 @@
import { ipcMain } from "electron";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { autofill } from "@bitwarden/desktop-napi";
import { CommandDefinition } from "./command";
export type RunCommandParams<C extends CommandDefinition> = {
namespace: C["namespace"];
command: C["name"];
params: C["input"];
};
export type RunCommandResult<C extends CommandDefinition> = C["output"];
export class NativeAutofillMain {
constructor(private logService: LogService) {}
async init() {
ipcMain.handle(
"autofill.runCommand",
<C extends CommandDefinition>(
_event: any,
params: RunCommandParams<C>,
): Promise<RunCommandResult<C>> => {
return this.runCommand(params);
},
);
}
private async runCommand<C extends CommandDefinition>(
command: RunCommandParams<C>,
): Promise<RunCommandResult<C>> {
try {
const result = await autofill.runCommand(JSON.stringify(command));
const parsed = JSON.parse(result) as RunCommandResult<C>;
if (parsed.type === "error") {
this.logService.error(`Error running autofill command '${command.command}':`, parsed.error);
}
return parsed;
} catch (e) {
this.logService.error(`Error running autofill command '${command.command}':`, e);
if (e instanceof Error) {
return { type: "error", error: e.stack ?? String(e) } as RunCommandResult<C>;
}
return { type: "error", error: String(e) } as RunCommandResult<C>;
}
}
}

View File

@@ -0,0 +1,20 @@
import { CommandDefinition, CommandOutput } from "./command";
export interface NativeAutofillStatusCommand extends CommandDefinition {
name: "status";
input: NativeAutofillStatusParams;
output: NativeAutofillStatusResult;
}
export type NativeAutofillStatusParams = Record<string, never>;
export type NativeAutofillStatusResult = CommandOutput<{
support: {
fido2: boolean;
password: boolean;
incrementalUpdates: boolean;
};
state: {
enabled: boolean;
};
}>;

View File

@@ -0,0 +1,37 @@
import { CommandDefinition, CommandOutput } from "./command";
export interface NativeAutofillSyncCommand extends CommandDefinition {
name: "sync";
input: NativeAutofillSyncParams;
output: NativeAutofillSyncResult;
}
export type NativeAutofillSyncParams = {
credentials: NativeAutofillCredential[];
};
export type NativeAutofillCredential =
| NativeAutofillFido2Credential
| NativeAutofillPasswordCredential;
export type NativeAutofillFido2Credential = {
type: "fido2";
cipherId: string;
rpId: string;
userName: string;
/** Should be Base64URL-encoded binary data */
credentialId: string;
/** Should be Base64URL-encoded binary data */
userHandle: string;
};
export type NativeAutofillPasswordCredential = {
type: "password";
cipherId: string;
uri: string;
username: string;
};
export type NativeAutofillSyncResult = CommandOutput<{
added: number;
}>;

View File

@@ -1,6 +1,7 @@
import { ipcMain } from "electron";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { crypto } from "@bitwarden/desktop-napi";
import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-function.service";
export class MainCryptoFunctionService
@@ -13,16 +14,16 @@ export class MainCryptoFunctionService
async (
event,
opts: {
password: string | Uint8Array;
salt: string | Uint8Array;
password: Uint8Array;
salt: Uint8Array;
iterations: number;
memory: number;
parallelism: number;
},
) => {
return await this.argon2(
opts.password,
opts.salt,
return await crypto.argon2(
Buffer.from(opts.password),
Buffer.from(opts.salt),
opts.iterations,
opts.memory,
opts.parallelism,

View File

@@ -99,8 +99,8 @@ const nativeMessaging = {
const crypto = {
argon2: (
password: string | Uint8Array,
salt: string | Uint8Array,
password: Uint8Array,
salt: Uint8Array,
iterations: number,
memory: number,
parallelism: number,

View File

@@ -198,7 +198,10 @@ export class SshAgentService implements OnDestroy {
}
const sshCiphers = ciphers.filter(
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
(cipher) =>
cipher.type === CipherType.SshKey &&
!cipher.isDeleted &&
cipher.organizationId === null,
);
const keys = sshCiphers.map((cipher) => {
return {

View File

@@ -1,6 +1,7 @@
import { contextBridge } from "electron";
import auth from "./auth/preload";
import autofill from "./autofill/preload";
import keyManagement from "./key-management/preload";
import platform from "./platform/preload";
@@ -17,6 +18,7 @@ import platform from "./platform/preload";
// Each team owns a subspace of the `ipc` global variable in the renderer.
export const ipc = {
auth,
autofill,
platform,
keyManagement,
};

View File

@@ -82,6 +82,7 @@
<li
class="filter-option"
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SshKey }"
*ngIf="isSshKeysEnabled"
>
<span class="filter-buttons">
<button

View File

@@ -1,9 +1,21 @@
import { Component } from "@angular/core";
import { Component, OnInit } from "@angular/core";
import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/type-filter.component";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@Component({
selector: "app-type-filter",
templateUrl: "type-filter.component.html",
})
export class TypeFilterComponent extends BaseTypeFilterComponent {}
export class TypeFilterComponent extends BaseTypeFilterComponent implements OnInit {
isSshKeysEnabled = false;
constructor(private configService: ConfigService) {
super();
}
async ngOnInit(): Promise<void> {
this.isSshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
}
}

View File

@@ -81,8 +81,6 @@ const main = {
externals: {
"electron-reload": "commonjs2 electron-reload",
"@bitwarden/desktop-napi": "commonjs2 @bitwarden/desktop-napi",
argon2: "commonjs2 argon2",
},
};

View File

@@ -59,8 +59,14 @@ describe("Is Enterprise Org Guard", () => {
{
path: "organizations/:organizationId/enterpriseOrgsOnly",
component: IsEnterpriseOrganizationComponent,
canActivate: [isEnterpriseOrgGuard()],
canActivate: [isEnterpriseOrgGuard(true)],
},
{
path: "organizations/:organizationId/enterpriseOrgsOnlyNoError",
component: IsEnterpriseOrganizationComponent,
canActivate: [isEnterpriseOrgGuard(false)],
},
{
path: "organizations/:organizationId/billing/subscription",
component: OrganizationUpgradeScreenComponent,
@@ -115,6 +121,24 @@ describe("Is Enterprise Org Guard", () => {
);
});
it.each([
ProductTierType.Free,
ProductTierType.Families,
ProductTierType.Teams,
ProductTierType.TeamsStarter,
])("does not proceed with the navigation for productTierType '%s'", async (productTierType) => {
const org = orgFactory({
type: OrganizationUserType.User,
productTierType: productTierType,
});
organizationService.get.calledWith(org.id).mockResolvedValue(org);
await routerHarness.navigateByUrl(`organizations/${org.id}/enterpriseOrgsOnlyNoError`);
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(
routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "",
).not.toBe("This component can only be accessed by a enterprise organization!");
});
it("proceeds with navigation if the organization in question is a enterprise organization", async () => {
const org = orgFactory({ productTierType: ProductTierType.Enterprise });
organizationService.get.calledWith(org.id).mockResolvedValue(org);

View File

@@ -17,7 +17,7 @@ import { DialogService } from "@bitwarden/components";
* if they have access to upgrade the organization. If the organization is
* enterprise routing proceeds."
*/
export function isEnterpriseOrgGuard(): CanActivateFn {
export function isEnterpriseOrgGuard(showError: boolean = true): CanActivateFn {
return async (route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
const router = inject(Router);
const organizationService = inject(OrganizationService);
@@ -29,7 +29,7 @@ export function isEnterpriseOrgGuard(): CanActivateFn {
return router.createUrlTree(["/"]);
}
if (org.productTierType != ProductTierType.Enterprise) {
if (org.productTierType != ProductTierType.Enterprise && showError) {
// Users without billing permission can't access billing
if (!org.canEditSubscription) {
await dialogService.openSimpleDialog({

View File

@@ -0,0 +1,66 @@
<app-header> </app-header>
<bit-tab-group [(selectedIndex)]="tabIndex">
<bit-tab [label]="'singleSignOn' | i18n">
<section class="tw-mb-9">
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
<p bitTypography="body1">
{{ "ssoDescStart" | i18n }}
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{ "singleSignOn" | i18n }}</a>
{{ "ssoDescEnd" | i18n }}
</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
></app-integration-grid>
</section>
</bit-tab>
<bit-tab [label]="'userProvisioning' | i18n">
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "scimIntegration" | i18n }}
</h2>
<p bitTypography="body1">
{{ "scimIntegrationDescStart" | i18n }}
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
{{ "scimIntegrationDescEnd" | i18n }}
</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
></app-integration-grid>
</section>
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "bwdc" | i18n }}
</h2>
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
></app-integration-grid>
</section>
</bit-tab>
<bit-tab [label]="'eventManagement' | i18n">
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "eventManagement" | i18n }}
</h2>
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
></app-integration-grid>
</section>
</bit-tab>
<bit-tab [label]="'deviceManagement' | i18n">
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "deviceManagement" | i18n }}
</h2>
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
></app-integration-grid>
</section>
</bit-tab>
</bit-tab-group>

View File

@@ -0,0 +1,207 @@
import { Component } from "@angular/core";
import { IntegrationType } from "@bitwarden/common/enums";
import { HeaderModule } from "../../../layouts/header/header.module";
import { FilterIntegrationsPipe, IntegrationGridComponent, Integration } from "../../../shared/";
import { SharedModule } from "../../../shared/shared.module";
import { SharedOrganizationModule } from "../shared";
@Component({
selector: "ac-integrations",
templateUrl: "./integrations.component.html",
standalone: true,
imports: [
SharedModule,
SharedOrganizationModule,
IntegrationGridComponent,
HeaderModule,
FilterIntegrationsPipe,
],
})
export class AdminConsoleIntegrationsComponent {
integrationsList: Integration[] = [];
tabIndex: number;
constructor() {
this.integrationsList = [
{
name: "AD FS",
linkURL: "https://bitwarden.com/help/saml-adfs/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.SSO,
},
{
name: "Auth0",
linkURL: "https://bitwarden.com/help/saml-auth0/",
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "AWS",
linkURL: "https://bitwarden.com/help/saml-aws/",
image: "../../../../../../../images/integrations/aws-color.svg",
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/saml-azure/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SSO,
},
{
name: "Duo",
linkURL: "https://bitwarden.com/help/saml-duo/",
image: "../../../../../../../images/integrations/logo-duo-color.svg",
type: IntegrationType.SSO,
},
{
name: "Google",
linkURL: "https://bitwarden.com/help/saml-google/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "KeyCloak",
linkURL: "https://bitwarden.com/help/saml-keycloak/",
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
type: IntegrationType.SSO,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/saml-okta/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/saml-onelogin/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "PingFederate",
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "Ping Identity",
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Active Directory",
linkURL: "https://bitwarden.com/help/ldap-directory/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.BWDC,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Google Workspace",
linkURL: "https://bitwarden.com/help/workspace-directory/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-directory/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-directory/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "Splunk",
linkURL: "https://bitwarden.com/help/splunk-siem/",
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Sentinel",
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Rapid7",
linkURL: "https://bitwarden.com/help/rapid7-siem/",
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Elastic",
linkURL: "https://bitwarden.com/help/elastic-siem/",
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Panther",
linkURL: "https://bitwarden.com/help/panther-siem/",
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Intune",
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
type: IntegrationType.DEVICE,
},
];
}
get IntegrationType(): typeof IntegrationType {
return IntegrationType;
}
}

View File

@@ -4,7 +4,7 @@
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
<bit-nav-group
icon="bwi-filter"
*ngIf="isAccessIntelligenceFeatureEnabled"
*ngIf="organization.useRiskInsights"
[text]="'accessIntelligence' | i18n"
>
<bit-nav-item
@@ -60,6 +60,12 @@
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</ng-container>
</bit-nav-group>
<bit-nav-item
icon="bwi-providers"
[text]="'integrations' | i18n"
route="integrations"
*ngIf="integrationPageEnabled$ | async"
></bit-nav-item>
<bit-nav-group
icon="bwi-cog"
[text]="'settings' | i18n"

View File

@@ -18,6 +18,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -47,12 +48,15 @@ export class OrganizationLayoutComponent implements OnInit {
protected orgFilter = (org: Organization) => canAccessOrgAdmin(org);
protected integrationPageEnabled$: Observable<boolean>;
organization$: Observable<Organization>;
canAccessExport$: Observable<boolean>;
showPaymentAndHistory$: Observable<boolean>;
hideNewOrgButton$: Observable<boolean>;
organizationIsUnmanaged$: Observable<boolean>;
isAccessIntelligenceFeatureEnabled = false;
enterpriseOrganization$: Observable<boolean>;
constructor(
private route: ActivatedRoute,
@@ -66,10 +70,6 @@ export class OrganizationLayoutComponent implements OnInit {
async ngOnInit() {
document.body.classList.remove("layout_frontend");
this.isAccessIntelligenceFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.AccessIntelligence,
);
this.organization$ = this.route.params.pipe(
map((p) => p.organizationId),
switchMap((id) => this.organizationService.organizations$.pipe(getById(id))),
@@ -104,6 +104,16 @@ export class OrganizationLayoutComponent implements OnInit {
provider.providerStatus !== ProviderStatusType.Billable,
),
);
this.integrationPageEnabled$ = combineLatest(
this.organization$,
this.configService.getFeatureFlag$(FeatureFlag.PM14505AdminConsoleIntegrationPage),
).pipe(
map(
([org, featureFlagEnabled]) =>
org.productTierType === ProductTierType.Enterprise && featureFlagEnabled,
),
);
}
canShowVaultTab(organization: Organization): boolean {

View File

@@ -8,7 +8,7 @@
</bit-callout>
<ng-container *ngIf="!done">
<bit-callout type="warning" *ngIf="users.length > 0 && !error">
<p bitTypography="body1">{{ "deleteOrganizationUserWarning" | i18n }}</p>
<p bitTypography="body1">{{ "deleteManyOrganizationUsersWarningDesc" | i18n }}</p>
</bit-callout>
<bit-table>
<ng-container header>

View File

@@ -1,4 +1,4 @@
<bit-dialog dialogSize="large" [title]="'removeUsers' | i18n">
<bit-dialog dialogSize="large" [title]="'removeMembers' | i18n">
<ng-container bitDialogContent>
<bit-callout type="danger" *ngIf="users.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
@@ -79,7 +79,7 @@
[disabled]="loading"
[bitAction]="submit"
>
{{ "removeUsers" | i18n }}
{{ "removeMembers" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}

View File

@@ -1,7 +1,14 @@
<bit-dialog>
<bit-dialog
dialogSize="large"
*ngIf="{ enabled: accountDeprovisioningEnabled$ | async } as accountDeprovisioning"
>
<ng-container bitDialogTitle>
<h1>{{ bulkTitle }}</h1>
<h1 *ngIf="accountDeprovisioning.enabled; else nonMemberTitle">{{ bulkMemberTitle }}</h1>
<ng-template #nonMemberTitle>
<h1>{{ bulkTitle }}</h1>
</ng-template>
</ng-container>
<div bitDialogContent>
<bit-callout type="danger" *ngIf="users.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
@@ -11,15 +18,81 @@
{{ error }}
</bit-callout>
<ng-container *ngIf="!done">
<bit-callout type="warning" *ngIf="users.length > 0 && !error && isRevoking">
<p>{{ "revokeUsersWarning" | i18n }}</p>
<p *ngIf="this.showNoMasterPasswordWarning">
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
</p>
</bit-callout>
<bit-callout
type="danger"
*ngIf="nonCompliantMembers && accountDeprovisioning.enabled"
title="{{ 'nonCompliantMembersTitle' | i18n }}"
>
{{ "nonCompliantMembersError" | i18n }}
</bit-callout>
<bit-table>
<ng-container *ngIf="!done">
<ng-container *ngIf="accountDeprovisioning.enabled">
<div *ngIf="users.length > 0 && !error && isRevoking">
<p>{{ "revokeMembersWarning" | i18n }}</p>
<ul>
<li>
{{ "claimedAccountRevoke" | i18n }}
</li>
<li>
{{ "unclaimedAccountRevoke" | i18n }}
</li>
</ul>
<p>
{{ "restoreMembersInstructions" | i18n }}
</p>
<p *ngIf="this.showNoMasterPasswordWarning">
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
</p>
</div>
</ng-container>
<ng-container *ngIf="!accountDeprovisioning.enabled">
<bit-callout type="warning" *ngIf="users.length > 0 && !error && isRevoking">
<p>{{ "revokeUsersWarning" | i18n }}</p>
</bit-callout>
</ng-container>
<bit-table *ngIf="accountDeprovisioning.enabled">
<ng-container header>
<tr>
<th bitCell class="tw-w-1/2">{{ "member" | i18n }}</th>
<th bitCell *ngIf="isRevoking">{{ "details" | i18n }}</th>
<th bitCell *ngIf="this.showNoMasterPasswordWarning">{{ "details" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let user of users">
<td bitCell width="30">
<div class="tw-flex tw-items-center">
<div class="tw-flex tw-items-center tw-mr-6">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</div>
<div>
{{ user.email }}
<small class="tw-block tw-text-muted" *ngIf="user.name">{{ user.name }}</small>
</div>
</div>
</td>
<td *ngIf="isRevoking" bitCell width="30">
{{
user.managedByOrganization ? ("claimedAccount" | i18n) : ("unclaimedAccount" | i18n)
}}
</td>
<td bitCell *ngIf="this.showNoMasterPasswordWarning">
<span class="tw-block tw-lowercase tw-text-muted">
<ng-container *ngIf="user.hasMasterPassword === true"> - </ng-container>
<ng-container *ngIf="user.hasMasterPassword === false">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
{{ "noMasterPassword" | i18n }}
</ng-container>
</span>
</td>
</tr>
</ng-template>
</bit-table>
<bit-table *ngIf="!accountDeprovisioning.enabled">
<ng-container header>
<tr>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
@@ -50,21 +123,55 @@
</ng-container>
<ng-container *ngIf="done">
<bit-table>
<bit-table *ngIf="accountDeprovisioning.enabled">
<ng-container header>
<tr>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th>{{ "status" | i18n }}</th>
<th bitCell class="tw-w-1/2">{{ "member" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let user of users">
<td bitCell width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
<div class="tw-flex tw-items-center">
<div class="tw-flex tw-items-center tw-mr-6">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</div>
<div>
{{ user.email }}
<small class="tw-block tw-text-muted" *ngIf="user.name">{{ user.name }}</small>
</div>
</div>
</td>
<td bitCell>
{{ user.email }}
<small class="tw-block tw-text-muted" *ngIf="user.name">{{ user.name }}</small>
<td bitCell *ngIf="statuses.has(user.id)">
{{ statuses.get(user.id) }}
</td>
<td bitCell *ngIf="!statuses.has(user.id)">
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</ng-template>
</bit-table>
<bit-table *ngIf="!accountDeprovisioning.enabled">
<ng-container header>
<tr>
<th bitCell class="tw-w-1/2">{{ "member" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let user of users">
<td bitCell width="30">
<div class="tw-flex tw-items-center">
<div class="tw-flex tw-items-center tw-mr-6">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</div>
<div>
{{ user.email }}
<small class="tw-block tw-text-muted" *ngIf="user.name">{{ user.name }}</small>
</div>
</div>
</td>
<td bitCell *ngIf="statuses.has(user.id)">
{{ statuses.get(user.id) }}
@@ -79,7 +186,7 @@
</div>
<ng-container bitDialogFooter>
<button type="button" bitButton *ngIf="!done && users.length > 0" [bitAction]="submit">
{{ bulkTitle }}
{{ accountDeprovisioning.enabled ? bulkMemberTitle : bulkTitle }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}

View File

@@ -1,8 +1,11 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { Observable } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
@@ -29,10 +32,13 @@ export class BulkRestoreRevokeComponent {
done = false;
error: string;
showNoMasterPasswordWarning = false;
nonCompliantMembers: boolean = false;
accountDeprovisioningEnabled$: Observable<boolean>;
constructor(
protected i18nService: I18nService,
private organizationUserApiService: OrganizationUserApiService,
private configService: ConfigService,
@Inject(DIALOG_DATA) protected data: BulkRestoreDialogParams,
) {
this.isRevoking = data.isRevoking;
@@ -41,6 +47,9 @@ export class BulkRestoreRevokeComponent {
this.showNoMasterPasswordWarning = this.users.some(
(u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false,
);
this.accountDeprovisioningEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.AccountDeprovisioning,
);
}
get bulkTitle() {
@@ -48,14 +57,26 @@ export class BulkRestoreRevokeComponent {
return this.i18nService.t(titleKey);
}
get bulkMemberTitle() {
const titleKey = this.isRevoking ? "revokeMembers" : "restoreMembers";
return this.i18nService.t(titleKey);
}
submit = async () => {
try {
const response = await this.performBulkUserAction();
const bulkMessage = this.isRevoking ? "bulkRevokedMessage" : "bulkRestoredMessage";
response.data.forEach((entry) => {
const error = entry.error !== "" ? entry.error : this.i18nService.t(bulkMessage);
response.data.forEach(async (entry) => {
const error =
entry.error !== ""
? this.i18nService.t("cannotRestoreAccessError")
: this.i18nService.t(bulkMessage);
this.statuses.set(entry.id, error);
if (entry.error !== "") {
this.nonCompliantMembers = true;
}
});
this.done = true;
} catch (e) {

View File

@@ -21,6 +21,7 @@ export interface BulkUserDetails {
email: string;
status: OrganizationUserStatusType | ProviderUserStatusType;
hasMasterPassword?: boolean;
managedByOrganization?: boolean;
}
type BulkStatusEntry = {

View File

@@ -579,7 +579,10 @@ export class MemberDialogComponent implements OnDestroy {
key: "deleteOrganizationUser",
placeholders: [this.params.name],
},
content: { key: "deleteOrganizationUserWarning" },
content: {
key: "deleteOrganizationUserWarningDesc",
placeholders: [this.params.name],
},
type: "warning",
acceptButtonText: { key: "delete" },
cancelButtonText: { key: "cancel" },

Some files were not shown because too many files have changed in this diff Show More