mirror of
https://github.com/bitwarden/browser
synced 2026-02-03 02:03:53 +00:00
Merge branch 'main' into dirt/pm-27619/assign-tasks-dialog
This commit is contained in:
@@ -31,6 +31,9 @@
|
||||
"useSingleSignOn": {
|
||||
"message": "Use single sign-on"
|
||||
},
|
||||
"yourOrganizationRequiresSingleSignOn": {
|
||||
"message": "Your organization requires single sign-on."
|
||||
},
|
||||
"welcomeBack": {
|
||||
"message": "Welcome back"
|
||||
},
|
||||
|
||||
@@ -48,11 +48,13 @@
|
||||
</div>
|
||||
</bit-callout>
|
||||
<div class="tw-flex tw-justify-center tw-flex-col tw-gap-3 tw-mt-6">
|
||||
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
|
||||
{{ "autofillAndAddWebsite" | i18n }}
|
||||
</button>
|
||||
@if (!viewOnly) {
|
||||
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
|
||||
{{ "autofillAndAddWebsite" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" bitButton buttonType="secondary" (click)="autofillOnly()">
|
||||
{{ "autofillWithoutAdding" | i18n }}
|
||||
{{ (viewOnly ? "autofill" : "autofillWithoutAdding") | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,6 +25,37 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"],
|
||||
};
|
||||
|
||||
async function createFreshFixture(options?: {
|
||||
params?: AutofillConfirmationDialogParams;
|
||||
viewOnly?: boolean;
|
||||
}) {
|
||||
const p = options?.params ?? params;
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutofillConfirmationDialogComponent],
|
||||
providers: [
|
||||
provideNoopAnimations(),
|
||||
{ provide: DIALOG_DATA, useValue: p },
|
||||
{ provide: DialogRef, useValue: dialogRef },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: DialogService, useValue: {} },
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
const freshFixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
|
||||
const freshInstance = freshFixture.componentInstance;
|
||||
|
||||
// If needed, set viewOnly BEFORE first detectChanges so initial render reflects it.
|
||||
if (typeof options?.viewOnly !== "undefined") {
|
||||
freshInstance.viewOnly = options.viewOnly;
|
||||
}
|
||||
|
||||
freshFixture.detectChanges();
|
||||
return { fixture: freshFixture, component: freshInstance };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => {
|
||||
if (typeof value !== "string" || !value) {
|
||||
@@ -117,15 +148,7 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
savedUrls: [],
|
||||
};
|
||||
|
||||
const newFixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
|
||||
const newInstance = newFixture.componentInstance;
|
||||
|
||||
(newInstance as any).params = newParams;
|
||||
const fresh = new AutofillConfirmationDialogComponent(
|
||||
newParams as any,
|
||||
dialogRef,
|
||||
) as AutofillConfirmationDialogComponent;
|
||||
|
||||
const { component: fresh } = await createFreshFixture({ params: newParams });
|
||||
expect(fresh.savedUrls).toEqual([]);
|
||||
expect(fresh.currentUrl).toBe("bitwarden.com");
|
||||
});
|
||||
@@ -189,4 +212,33 @@ describe("AutofillConfirmationDialogComponent", () => {
|
||||
expect(btn).toBeFalsy();
|
||||
expect(component.savedUrlsExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it("shows autofillWithoutAdding text on autofill button when viewOnly is false", () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillWithoutAdding")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show autofillWithoutAdding text on autofill button when viewOnly is true", async () => {
|
||||
const { fixture: vf } = await createFreshFixture({ viewOnly: true });
|
||||
|
||||
const text = vf.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillWithoutAdding")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows autofill and save button when viewOnly is false", () => {
|
||||
component.viewOnly = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillAndAddWebsite")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show autofill and save button when viewOnly is true", async () => {
|
||||
const { fixture: vf } = await createFreshFixture({ viewOnly: true });
|
||||
|
||||
const text = vf.nativeElement.textContent as string;
|
||||
expect(text.includes("autofillAndAddWebsite")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
export interface AutofillConfirmationDialogParams {
|
||||
savedUrls?: string[];
|
||||
currentUrl: string;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const AutofillConfirmationDialogResult = Object.freeze({
|
||||
@@ -50,12 +51,14 @@ export class AutofillConfirmationDialogComponent {
|
||||
currentUrl: string = "";
|
||||
savedUrls: string[] = [];
|
||||
savedUrlsExpanded = false;
|
||||
viewOnly: boolean = false;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams,
|
||||
private dialogRef: DialogRef,
|
||||
) {
|
||||
this.currentUrl = Utils.getHostname(params.currentUrl);
|
||||
this.viewOnly = params.viewOnly ?? false;
|
||||
this.savedUrls =
|
||||
params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
@@ -57,6 +56,10 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
autofillAllowed$: new BehaviorSubject(true),
|
||||
};
|
||||
|
||||
const passwordRepromptService = {
|
||||
passwordRepromptCheck: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const uriMatchStrategy$ = new BehaviorSubject<UriMatchStrategySetting>(UriMatchStrategy.Domain);
|
||||
|
||||
const domainSettingsService = {
|
||||
@@ -110,7 +113,7 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
{ provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } },
|
||||
{ provide: ToastService, useValue: { showToast: () => {} } },
|
||||
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
|
||||
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
|
||||
{ provide: PasswordRepromptService, useValue: passwordRepromptService },
|
||||
{
|
||||
provide: DomainSettingsService,
|
||||
useValue: domainSettingsService,
|
||||
@@ -140,102 +143,128 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
return openSpy;
|
||||
}
|
||||
|
||||
it("calls doAutofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => {
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
describe("doAutofill", () => {
|
||||
it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => {
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
|
||||
await component.doAutofill();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(cipherService.getFullCipherView).toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
|
||||
expect(autofillSvc.doAutofill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "cipher-1" }),
|
||||
false,
|
||||
);
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(cipherService.getFullCipherView).toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
|
||||
expect(autofillSvc.doAutofill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "cipher-1" }),
|
||||
false,
|
||||
);
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => {
|
||||
featureFlag$.next(true);
|
||||
hasSearchText$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
|
||||
const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
|
||||
it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => {
|
||||
featureFlag$.next(true);
|
||||
hasSearchText$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
|
||||
const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
|
||||
|
||||
await component.doAutofill();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
const args = openSpy.mock.calls[0][1];
|
||||
expect(args.data.currentUrl).toBe("https://page.example.com/path");
|
||||
expect(args.data.savedUrls).toEqual(["https://one.example.com", "https://two.example.com/a"]);
|
||||
});
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
const args = openSpy.mock.calls[0][1];
|
||||
expect(args.data.currentUrl).toBe("https://page.example.com/path");
|
||||
expect(args.data.savedUrls).toEqual(["https://one.example.com", "https://two.example.com/a"]);
|
||||
});
|
||||
|
||||
it("does nothing when the user cancels the autofill confirmation dialog", async () => {
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
|
||||
it("does nothing when the user cancels the autofill confirmation dialog", async () => {
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
|
||||
|
||||
await component.doAutofill();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("autofills the item without adding the URL when the user selects 'AutofilledOnly'", async () => {
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
it("calls the autofill service to autofill when the user selects 'AutofilledOnly'", async () => {
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
|
||||
await component.doAutofill();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("autofills the item and adds the URL when the user selects 'AutofillAndUrlAdded'", async () => {
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
|
||||
it("calls the autofill service to doAutofillAndSave when the user selects 'AutofillAndUrlAdded'", async () => {
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
|
||||
|
||||
await component.doAutofill();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1);
|
||||
expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1);
|
||||
expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only shows the exact match dialog when the uri match strategy is Exact and no URIs match", async () => {
|
||||
featureFlag$.next(true);
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Exact);
|
||||
hasSearchText$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
|
||||
it("shows the exact match dialog when the uri match strategy is Exact and no URIs match", async () => {
|
||||
featureFlag$.next(true);
|
||||
uriMatchStrategy$.next(UriMatchStrategy.Exact);
|
||||
hasSearchText$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
|
||||
|
||||
await component.doAutofill();
|
||||
await component.doAutofill();
|
||||
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.objectContaining({ key: "cannotAutofill" }),
|
||||
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
|
||||
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.objectContaining({ key: "cannotAutofill" }),
|
||||
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => {
|
||||
// Enable both feature flag and search text → makes showAutofillConfirmation$ true
|
||||
featureFlag$.next(true);
|
||||
hasSearchText$.next(true);
|
||||
it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => {
|
||||
// Enable both feature flag and search text → makes showAutofillConfirmation$ true
|
||||
featureFlag$.next(true);
|
||||
hasSearchText$.next(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const fillAndSaveButton = fixture.nativeElement.querySelector(
|
||||
"button[bitMenuItem]:not([disabled])",
|
||||
);
|
||||
const fillAndSaveButton = fixture.nativeElement.querySelector(
|
||||
"button[bitMenuItem]:not([disabled])",
|
||||
);
|
||||
|
||||
const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? "";
|
||||
expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false);
|
||||
const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? "";
|
||||
expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false);
|
||||
});
|
||||
|
||||
it("call the passwordService to passwordRepromptCheck if their cipher has password reprompt enabled", async () => {
|
||||
baseCipher.reprompt = 2; // Master Password reprompt enabled
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
|
||||
});
|
||||
|
||||
it("does nothing if the user fails master password reprompt", async () => {
|
||||
baseCipher.reprompt = 2; // Master Password reprompt enabled
|
||||
featureFlag$.next(true);
|
||||
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); // Reprompt fails
|
||||
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
|
||||
|
||||
await component.doAutofill();
|
||||
|
||||
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
|
||||
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,6 +202,10 @@ export class ItemMoreOptionsComponent {
|
||||
async doAutofill() {
|
||||
const cipher = await this.cipherService.getFullCipherView(this.cipher);
|
||||
|
||||
if (!(await this.passwordRepromptService.passwordRepromptCheck(this.cipher))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
|
||||
|
||||
if (!showAutofillConfirmation) {
|
||||
@@ -236,6 +240,7 @@ export class ItemMoreOptionsComponent {
|
||||
data: {
|
||||
currentUrl: currentTab?.url || "",
|
||||
savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [],
|
||||
viewOnly: !this.cipher.edit,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
31
apps/desktop/desktop_native/Cargo.lock
generated
31
apps/desktop/desktop_native/Cargo.lock
generated
@@ -589,18 +589,24 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"cbc",
|
||||
"chacha20poly1305",
|
||||
"clap",
|
||||
"dirs",
|
||||
"hex",
|
||||
"homedir",
|
||||
"oo7",
|
||||
"pbkdf2",
|
||||
"rand 0.9.1",
|
||||
"rusqlite",
|
||||
"scopeguard",
|
||||
"security-framework",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
"winapi",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"verifysign",
|
||||
"windows 0.61.1",
|
||||
]
|
||||
|
||||
@@ -3824,6 +3830,18 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "verifysign"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ebfe12e38930c3b851aea35e93fab1a6c29279cad7e8e273f29a21678fee8c0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -4186,6 +4204,15 @@ dependencies = [
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
|
||||
@@ -45,6 +45,25 @@ function buildProxyBin(target, release = true) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildImporterBinaries(target, release = true) {
|
||||
// These binaries are only built for Windows, so we can skip them on other platforms
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const bin = "bitwarden_chromium_import_helper";
|
||||
const targetArg = target ? `--target ${target}` : "";
|
||||
const releaseArg = release ? "--release" : "";
|
||||
child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg} --features windows-binary`, {stdio: 'inherit', cwd: path.join(__dirname, "chromium_importer")});
|
||||
|
||||
if (target) {
|
||||
// Copy the resulting binary to the dist folder
|
||||
const targetFolder = release ? "release" : "debug";
|
||||
const nodeArch = rustTargetsMap[target].nodeArch;
|
||||
fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`));
|
||||
}
|
||||
}
|
||||
|
||||
function buildProcessIsolation() {
|
||||
if (process.platform !== "linux") {
|
||||
return;
|
||||
@@ -67,6 +86,7 @@ if (!crossPlatform && !target) {
|
||||
console.log(`Building native modules in ${mode} mode for the native architecture`);
|
||||
buildNapiModule(false, mode === "release");
|
||||
buildProxyBin(false, mode === "release");
|
||||
buildImporterBinaries(false, mode === "release");
|
||||
buildProcessIsolation();
|
||||
return;
|
||||
}
|
||||
@@ -76,6 +96,7 @@ if (target) {
|
||||
installTarget(target);
|
||||
buildNapiModule(target, mode === "release");
|
||||
buildProxyBin(target, mode === "release");
|
||||
buildImporterBinaries(false, mode === "release");
|
||||
buildProcessIsolation();
|
||||
return;
|
||||
}
|
||||
@@ -94,5 +115,6 @@ platformTargets.forEach(([target, _]) => {
|
||||
installTarget(target);
|
||||
buildNapiModule(target);
|
||||
buildProxyBin(target);
|
||||
buildImporterBinaries(target);
|
||||
buildProcessIsolation();
|
||||
});
|
||||
|
||||
@@ -12,25 +12,52 @@ anyhow = { workspace = true }
|
||||
async-trait = "=0.1.88"
|
||||
base64 = { workspace = true }
|
||||
cbc = { workspace = true, features = ["alloc"] }
|
||||
dirs = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
homedir = { workspace = true }
|
||||
pbkdf2 = "=0.12.2"
|
||||
rand = { workspace = true }
|
||||
rusqlite = { version = "=0.37.0", features = ["bundled"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha1 = "=0.10.6"
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
winapi = { version = "=0.3.9", features = ["dpapi", "memoryapi"] }
|
||||
windows = { workspace = true, features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Services", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
chacha20poly1305 = { workspace = true }
|
||||
clap = { version = "=4.5.40", features = ["derive"] }
|
||||
scopeguard = { workspace = true }
|
||||
sysinfo = { workspace = true, optional = true }
|
||||
verifysign = "=0.2.4"
|
||||
windows = { workspace = true, features = [
|
||||
"Wdk_System_SystemServices",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Services",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
oo7 = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
windows-binary = ["dep:sysinfo"]
|
||||
|
||||
[[bin]]
|
||||
name = "bitwarden_chromium_import_helper"
|
||||
path = "src/bin/bitwarden_chromium_import_helper.rs"
|
||||
required-features = ["windows-binary"]
|
||||
|
||||
@@ -9,155 +9,126 @@ get access to the passwords.
|
||||
|
||||
### Overview
|
||||
|
||||
The Windows Application Bound Encryption (ABE) consists of three main components that work together:
|
||||
The Windows **Application Bound Encryption (ABE)** subsystem consists of two main components that work together:
|
||||
|
||||
- **client library** -- Library that is part of the desktop client application
|
||||
- **admin.exe** -- Service launcher running as ADMINISTRATOR
|
||||
- **service.exe** -- Background Windows service running as SYSTEM
|
||||
- **client library** — a library that is part of the desktop client application
|
||||
- **bitwarden_chromium_import_helper.exe** — a password decryptor running as **ADMINISTRATOR** and later as **SYSTEM**
|
||||
|
||||
_(The names of the binaries will be changed for the released product.)_
|
||||
_(The name of the binary will be changed in the released product.)_
|
||||
|
||||
### The goal
|
||||
See the last section for a concise summary of the entire process.
|
||||
|
||||
The goal of this subsystem is to decrypt the master encryption key with which the login information
|
||||
is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and
|
||||
Edge (untested yet) that are using the ABE/v20 encryption scheme for some of the local profiles.
|
||||
### Goal
|
||||
|
||||
The general idea of this encryption scheme is that Chrome generates a unique random encryption key,
|
||||
then encrypts it at the user level with a fixed key. It then sends it to the Windows Data Protection
|
||||
API at the user level, and then, using an installed service, encrypts it with the Windows Data
|
||||
Protection API at the system level on top of that. This triply encrypted key is later stored in the
|
||||
`Local State` file.
|
||||
The goal of this subsystem is to decrypt the master encryption key used to encrypt login information on the local
|
||||
Windows system. This applies to the most recent versions of Chrome, Brave, and (untested) Edge that use the ABE/v20
|
||||
encryption scheme for some local profiles.
|
||||
|
||||
The next paragraphs describe what is done at each level to decrypt the key.
|
||||
The general idea of this encryption scheme is as follows:
|
||||
|
||||
### 1. Client library
|
||||
1. Chrome generates a unique random encryption key.
|
||||
2. This key is first encrypted at the **user level** with a fixed key.
|
||||
3. It is then encrypted at the **user level** again using the Windows **Data Protection API (DPAPI)**.
|
||||
4. Finally, it is sent to a special service that encrypts it with DPAPI at the **system level**.
|
||||
|
||||
This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows
|
||||
(see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges
|
||||
by presenting the user with the UAC screen. See the `abe::decrypt_with_admin_and_service` invocation
|
||||
in `windows.rs`.
|
||||
This triply encrypted key is stored in the `Local State` file.
|
||||
|
||||
This function takes three arguments:
|
||||
The following sections describe how the key is decrypted at each level.
|
||||
|
||||
1. Absolute path to `admin.exe`
|
||||
2. Absolute path to `service.exe`
|
||||
3. Base64 string of the ABE key extracted from the browser's local state
|
||||
### 1. Client Library
|
||||
|
||||
It's not possible to install the service from the user-level executable. So first, we have to
|
||||
elevate the privileges and run `admin.exe` as ADMINISTRATOR. This is done by calling `ShellExecute`
|
||||
with the `runas` verb. Since it's not trivial to read the standard output from an application
|
||||
launched in this way, a named pipe server is created at the user level, which waits for the response
|
||||
from `admin.exe` after it has been launched.
|
||||
This is a Rust module that is part of the Chromium importer. It compiles and runs only on Windows (see `abe.rs` and
|
||||
`abe_config.rs`). Its main task is to launch `bitwarden_chromium_import_helper.exe` with elevated privileges, presenting
|
||||
the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `windows.rs`.
|
||||
|
||||
The name of the service executable and the data to be decrypted are passed via the command line to
|
||||
`admin.exe` like this:
|
||||
This function takes two arguments:
|
||||
|
||||
1. Absolute path to `bitwarden_chromium_import_helper.exe`
|
||||
2. Base64 string of the ABE key extracted from the browser's local state
|
||||
|
||||
First, `bitwarden_chromium_import_helper.exe` is launched by calling a variant of `ShellExecute` with the `runas` verb.
|
||||
This displays the UAC screen. If the user accepts, `bitwarden_chromium_import_helper.exe` starts with **ADMINISTRATOR**
|
||||
privileges.
|
||||
|
||||
> **The user must approve the UAC prompt or the process is aborted.**
|
||||
|
||||
Because it is not possible to read the standard output of an application launched in this way, a named pipe server is
|
||||
created at the user level before `bitwarden_chromium_import_helper.exe` is launched. This pipe is used to send the
|
||||
decryption result from `bitwarden_chromium_import_helper.exe` back to the client.
|
||||
|
||||
The data to be decrypted are passed via the command line to `bitwarden_chromium_import_helper.exe` like this:
|
||||
|
||||
```bat
|
||||
admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..."
|
||||
bitwarden_chromium_import_helper.exe --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..."
|
||||
```
|
||||
|
||||
**At this point, the user must permit the action to be performed on the UAC screen.**
|
||||
### 2. Admin Executable
|
||||
|
||||
### 2. Admin executable
|
||||
Although the process starts with **ADMINISTRATOR** privileges, its ultimate goal is to elevate to **SYSTEM**. To achieve
|
||||
this, it uses a technique to impersonate a system-level process.
|
||||
|
||||
This executable receives the full path of `service.exe` and the data to be decrypted.
|
||||
First, `bitwarden_chromium_import_helper.exe` ensures that the `SE_DEBUG_PRIVILEGE` privilege is enabled by calling
|
||||
`RtlAdjustPrivilege`. This allows it to enumerate running system-level processes.
|
||||
|
||||
First, it installs the service to run as SYSTEM and waits for it to start running. The service
|
||||
creates a named pipe server that the admin-level executable communicates with (see the `service.exe`
|
||||
description further down).
|
||||
Next, it finds an instance of `services.exe` or `winlogon.exe`, which are known to run at the **SYSTEM** level. Once a
|
||||
system process is found, its token is duplicated by calling `DuplicateToken`.
|
||||
|
||||
It sends the base64 string to the pipe server in a raw message and waits for the answer. The answer
|
||||
could be a success or a failure. In case of success, it's a base64 string decrypted at the system
|
||||
level. In case of failure, it's an error message prefixed with an `!`. In either case, the response
|
||||
is sent to the named pipe server created by the user. The user responds with `ok` (ignored).
|
||||
With the duplicated token, `ImpersonateLoggedOnUser` is called to impersonate a system-level process.
|
||||
|
||||
After that, the executable stops and uninstalls the service and then exits.
|
||||
> **At this point `bitwarden_chromium_import_helper.exe` is running as SYSTEM.**
|
||||
|
||||
### 3. System service
|
||||
The received encryption key can now be decrypted using DPAPI at the system level.
|
||||
|
||||
The service starts and creates a named pipe server for communication between `admin.exe` and the
|
||||
system service. Please note that it is not possible to communicate between the user and the system
|
||||
service directly via a named pipe. Thus, this three-layered approach is necessary.
|
||||
The decrypted result is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to
|
||||
the pipe and writes the result.
|
||||
|
||||
Once the service is started, it waits for the incoming message via the named pipe. The expected
|
||||
message is a base64 string to be decrypted. The data is decrypted via the Windows Data Protection
|
||||
API `CryptUnprotectData` and sent back in response to this incoming message in base64 encoding. In
|
||||
case of an error, the error message is sent back prefixed with an `!`.
|
||||
The response can indicate success or failure:
|
||||
|
||||
The service keeps running and servicing more requests if there are any, until it's stopped and
|
||||
removed from the system. Even though we send only one request, the service is designed to handle as
|
||||
many clients with as many messages as needed and could be installed on the system permanently if
|
||||
necessary.
|
||||
- On success: a Base64-encoded string.
|
||||
- On failure: an error message prefixed with `!`.
|
||||
|
||||
### 4. Back to client library
|
||||
In either case, the response is sent to the named pipe server created by the client. The client responds with `ok`
|
||||
(ignored).
|
||||
|
||||
The decrypted base64-encoded string comes back from the admin executable to the named pipe server at
|
||||
the user level. At this point, it has been decrypted only once at the system level.
|
||||
Finally, `bitwarden_chromium_import_helper.exe` exits.
|
||||
|
||||
In the next step, the string is decrypted at the user level with the same Windows Data Protection
|
||||
API.
|
||||
### 3. Back to the Client Library
|
||||
|
||||
And as the third step, it's decrypted with a hard-coded key found in the `elevation_service.exe`
|
||||
from the Chrome installation. Based on the version of the encrypted string (encoded in the string
|
||||
itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The details can be found in
|
||||
`windows.rs`.
|
||||
The decrypted Base64-encoded string is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at
|
||||
the user level. At this point it has been decrypted only once—at the system level.
|
||||
|
||||
After all of these steps, we have the master key which can be used to decrypt the password
|
||||
information stored in the local database.
|
||||
Next, the string is decrypted at the **user level** with DPAPI.
|
||||
|
||||
### Summary
|
||||
Finally, for Google Chrome (but not Brave), it is decrypted again with a hard-coded key found in `elevation_service.exe`
|
||||
from the Chrome installation. Based on the version of the encrypted string (encoded within the string itself), this step
|
||||
uses either **AES-256-GCM** or **ChaCha20-Poly1305**. See `windows.rs` for details.
|
||||
|
||||
The Windows ABE decryption process involves a three-tier architecture with named pipe communication:
|
||||
After these steps, the master key is available and can be used to decrypt the password information stored in the
|
||||
browser’s local database.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client Library (User)
|
||||
participant Admin as admin.exe (Administrator)
|
||||
participant Service as service.exe (System)
|
||||
### TL;DR Steps
|
||||
|
||||
Client->>Client: Create named pipe server
|
||||
Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user
|
||||
1. **Client side:**
|
||||
|
||||
Client->>Admin: Launch with UAC elevation
|
||||
Note over Client,Admin: --service-exe c:\path\to\service.exe
|
||||
Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE...
|
||||
1. Extract the encrypted key from Chrome’s settings.
|
||||
2. Create a named pipe server.
|
||||
3. Launch `bitwarden_chromium_import_helper.exe` with **ADMINISTRATOR** privileges, passing the key to be decrypted
|
||||
via CLI arguments.
|
||||
4. Wait for the response from `bitwarden_chromium_import_helper.exe`.
|
||||
|
||||
Client->>Client: Wait for response
|
||||
2. **Admin side:**
|
||||
|
||||
Admin->>Service: Install & start service
|
||||
Note over Admin,Service: c:\path\to\service.exe
|
||||
1. Start.
|
||||
2. Ensure `SE_DEBUG_PRIVILEGE` is enabled (not strictly necessary in tests).
|
||||
3. Impersonate a system process such as `services.exe` or `winlogon.exe`.
|
||||
4. Decrypt the key using DPAPI at the **SYSTEM** level.
|
||||
5. Send the result or error back via the named pipe.
|
||||
6. Exit.
|
||||
|
||||
Service->>Service: Create named pipe server
|
||||
Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin
|
||||
|
||||
Service->>Service: Wait for message
|
||||
|
||||
Admin->>Service: Send encrypted data via admin-service pipe
|
||||
Note over Admin,Service: QVBQQgEAAADQjJ3fARXRE...
|
||||
|
||||
Admin->>Admin: Wait for response
|
||||
|
||||
Service->>Service: Decrypt with system-level DPAPI
|
||||
|
||||
Service->>Admin: Return decrypted data via admin-service pipe
|
||||
Note over Service,Admin: EjRWeXN0ZW0gU2VydmljZQ...
|
||||
|
||||
Admin->>Client: Send result via named user-admin pipe
|
||||
Note over Client,Admin: EjRWeXN0ZW0gU2VydmljZQ...
|
||||
|
||||
Client->>Admin: Send ACK to admin
|
||||
Note over Client,Admin: ok
|
||||
|
||||
Admin->>Service: Stop & uninstall service
|
||||
Service-->>Admin: Exit
|
||||
|
||||
Admin-->>Client: Exit
|
||||
|
||||
Client->>Client: Decrypt with user-level DPAPI
|
||||
|
||||
Client->>Client: Decrypt with hardcoded key
|
||||
Note over Client: AES-256-GCM or ChaCha20Poly1305
|
||||
|
||||
Client->>Client: Done
|
||||
```
|
||||
3. **Back on the client side:**
|
||||
1. Receive the encryption key.
|
||||
2. Shutdown the pipe server.
|
||||
3. Decrypt it with DPAPI at the **USER** level.
|
||||
4. (For Chrome only) Decrypt again with the hard-coded key.
|
||||
5. Obtain the fully decrypted master key.
|
||||
6. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc.
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
// Hide everything inside a platform specific module to avoid clippy errors on other platforms
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_binary {
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use clap::Parser;
|
||||
use scopeguard::defer;
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
os::windows::{ffi::OsStringExt as _, io::AsRawHandle},
|
||||
path::{Path, PathBuf},
|
||||
ptr,
|
||||
time::Duration,
|
||||
};
|
||||
use sysinfo::System;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::windows::named_pipe::{ClientOptions, NamedPipeClient},
|
||||
time,
|
||||
};
|
||||
use tracing::{debug, error, level_filters::LevelFilter};
|
||||
use tracing_subscriber::{
|
||||
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
|
||||
};
|
||||
use verifysign::CodeSignVerifier;
|
||||
use windows::{
|
||||
core::BOOL,
|
||||
Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE,
|
||||
Win32::{
|
||||
Foundation::{
|
||||
CloseHandle, LocalFree, ERROR_PIPE_BUSY, HANDLE, HLOCAL, NTSTATUS, STATUS_SUCCESS,
|
||||
},
|
||||
Security::{
|
||||
self,
|
||||
Cryptography::{CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB},
|
||||
DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE,
|
||||
TOKEN_QUERY,
|
||||
},
|
||||
System::{
|
||||
Pipes::GetNamedPipeServerProcessId,
|
||||
Threading::{
|
||||
OpenProcess, OpenProcessToken, QueryFullProcessImageNameW, PROCESS_NAME_WIN32,
|
||||
PROCESS_QUERY_INFORMATION, PROCESS_VM_READ,
|
||||
},
|
||||
},
|
||||
UI::Shell::IsUserAnAdmin,
|
||||
},
|
||||
};
|
||||
|
||||
use chromium_importer::chromium::ADMIN_TO_USER_PIPE_NAME;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bitwarden_chromium_import_helper")]
|
||||
#[command(about = "Admin tool for ABE service management")]
|
||||
struct Args {
|
||||
/// Base64 encoded encrypted data to process
|
||||
#[arg(long, help = "Base64 encoded encrypted data string")]
|
||||
encrypted: String,
|
||||
}
|
||||
|
||||
// Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost.
|
||||
// This is intended for development time only. All the logging is wrapped in `dbg_log!`` macro that compiles to
|
||||
// no-op when logging is disabled. This is needed to avoid any sensitive data being logged in production. Normally
|
||||
// all the logging code is present in the release build and could be enabled via RUST_LOG environment variable.
|
||||
// We don't want that!
|
||||
const ENABLE_DEVELOPER_LOGGING: bool = false;
|
||||
const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; // This is an example filename, replace it with you own
|
||||
|
||||
// This should be enabled for production
|
||||
const ENABLE_SERVER_SIGNATURE_VALIDATION: bool = false;
|
||||
const EXPECTED_SERVER_SIGNATURE_SHA256_THUMBPRINT: &str =
|
||||
"9f6680c4720dbf66d1cb8ed6e328f58e42523badc60d138c7a04e63af14ea40d";
|
||||
|
||||
// List of SYSTEM process names to try to impersonate
|
||||
const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"];
|
||||
|
||||
// Macro wrapper around debug! that compiles to no-op when ENABLE_DEVELOPER_LOGGING is false
|
||||
macro_rules! dbg_log {
|
||||
($($arg:tt)*) => {
|
||||
if ENABLE_DEVELOPER_LOGGING {
|
||||
debug!($($arg)*);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn open_pipe_client(pipe_name: &'static str) -> Result<NamedPipeClient> {
|
||||
let max_attempts = 5;
|
||||
for _ in 0..max_attempts {
|
||||
match ClientOptions::new().open(pipe_name) {
|
||||
Ok(client) => {
|
||||
dbg_log!("Successfully connected to the pipe!");
|
||||
return Ok(client);
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => {
|
||||
dbg_log!("Pipe is busy, retrying in 50ms...");
|
||||
}
|
||||
Err(e) => {
|
||||
dbg_log!("Failed to connect to pipe: {}", &e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Failed to connect to pipe after {} attempts",
|
||||
max_attempts
|
||||
))
|
||||
}
|
||||
|
||||
async fn send_message_with_client(
|
||||
client: &mut NamedPipeClient,
|
||||
message: &str,
|
||||
) -> Result<String> {
|
||||
client.write_all(message.as_bytes()).await?;
|
||||
|
||||
// Try to receive a response for this message
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
match client.read(&mut buffer).await {
|
||||
Ok(0) => Err(anyhow!(
|
||||
"Server closed the connection (0 bytes read) on message"
|
||||
)),
|
||||
Ok(bytes_received) => {
|
||||
let response = String::from_utf8_lossy(&buffer[..bytes_received]);
|
||||
Ok(response.to_string())
|
||||
}
|
||||
Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result<u32> {
|
||||
let handle = HANDLE(client.as_raw_handle() as _);
|
||||
let mut pid: u32 = 0;
|
||||
unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?;
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
fn resolve_process_executable_path(pid: u32) -> Result<PathBuf> {
|
||||
dbg_log!("Resolving process executable path for PID {}", pid);
|
||||
|
||||
// Open the process handle
|
||||
let hprocess =
|
||||
unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid) }?;
|
||||
dbg_log!("Opened process handle for PID {}", pid);
|
||||
|
||||
// Close when no longer needed
|
||||
defer! {
|
||||
dbg_log!("Closing process handle for PID {}", pid);
|
||||
unsafe {
|
||||
_ = CloseHandle(hprocess);
|
||||
}
|
||||
};
|
||||
|
||||
let mut exe_name = vec![0u16; 32 * 1024];
|
||||
let mut exe_name_length = exe_name.len() as u32;
|
||||
unsafe {
|
||||
QueryFullProcessImageNameW(
|
||||
hprocess,
|
||||
PROCESS_NAME_WIN32,
|
||||
windows::core::PWSTR(exe_name.as_mut_ptr()),
|
||||
&mut exe_name_length,
|
||||
)
|
||||
}?;
|
||||
dbg_log!(
|
||||
"QueryFullProcessImageNameW returned {} bytes",
|
||||
exe_name_length
|
||||
);
|
||||
|
||||
exe_name.truncate(exe_name_length as usize);
|
||||
Ok(PathBuf::from(OsString::from_wide(&exe_name)))
|
||||
}
|
||||
|
||||
async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) {
|
||||
_ = send_to_user(client, &format!("!{}", error_message)).await
|
||||
}
|
||||
|
||||
async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> {
|
||||
let _ = send_message_with_client(client, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_admin() -> bool {
|
||||
unsafe { IsUserAnAdmin().as_bool() }
|
||||
}
|
||||
|
||||
fn decrypt_data_base64(data_base64: &str, expect_appb: bool) -> Result<String> {
|
||||
dbg_log!("Decrypting data base64: {}", data_base64);
|
||||
|
||||
let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| {
|
||||
dbg_log!("Failed to decode base64: {} APPB: {}", e, expect_appb);
|
||||
e
|
||||
})?;
|
||||
|
||||
let decrypted = decrypt_data(&data, expect_appb)?;
|
||||
let decrypted_base64 = general_purpose::STANDARD.encode(decrypted);
|
||||
|
||||
Ok(decrypted_base64)
|
||||
}
|
||||
|
||||
fn decrypt_data(data: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
|
||||
if expect_appb && !data.starts_with(b"APPB") {
|
||||
dbg_log!("Decoded data does not start with 'APPB'");
|
||||
return Err(anyhow!("Decoded data does not start with 'APPB'"));
|
||||
}
|
||||
|
||||
let data = if expect_appb { &data[4..] } else { data };
|
||||
|
||||
let in_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: data.len() as u32,
|
||||
pbData: data.as_ptr() as *mut u8,
|
||||
};
|
||||
|
||||
let mut out_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: 0,
|
||||
pbData: ptr::null_mut(),
|
||||
};
|
||||
|
||||
let result = unsafe {
|
||||
CryptUnprotectData(
|
||||
&in_blob,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut out_blob,
|
||||
)
|
||||
};
|
||||
|
||||
if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 {
|
||||
let decrypted = unsafe {
|
||||
std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec()
|
||||
};
|
||||
|
||||
// Free the memory allocated by CryptUnprotectData
|
||||
unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) };
|
||||
|
||||
Ok(decrypted)
|
||||
} else {
|
||||
dbg_log!("CryptUnprotectData failed");
|
||||
Err(anyhow!("CryptUnprotectData failed"))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Impersonate a SYSTEM process
|
||||
//
|
||||
|
||||
fn start_impersonating() -> Result<HANDLE> {
|
||||
// Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes
|
||||
enable_debug_privilege()?;
|
||||
|
||||
// Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several.
|
||||
let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?;
|
||||
|
||||
// Impersonate the SYSTEM process
|
||||
unsafe {
|
||||
ImpersonateLoggedOnUser(token)?;
|
||||
};
|
||||
dbg_log!("Impersonating system process '{}' (PID: {})", name, pid);
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn stop_impersonating(token: HANDLE) -> Result<()> {
|
||||
unsafe {
|
||||
RevertToSelf()?;
|
||||
CloseHandle(token)?;
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_system_process_with_token(
|
||||
pids: Vec<(u32, &'static str)>,
|
||||
) -> Result<(HANDLE, u32, &'static str)> {
|
||||
for (pid, name) in pids {
|
||||
match get_system_token_from_pid(pid) {
|
||||
Err(_) => {
|
||||
dbg_log!(
|
||||
"Failed to open process handle '{}' (PID: {}), skipping",
|
||||
name,
|
||||
pid
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(system_handle) => {
|
||||
return Ok((system_handle, pid, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("Failed to get system token from any process"))
|
||||
}
|
||||
|
||||
fn get_system_token_from_pid(pid: u32) -> Result<HANDLE> {
|
||||
let handle = get_process_handle(pid)?;
|
||||
let token = get_system_token(handle)?;
|
||||
unsafe {
|
||||
CloseHandle(handle)?;
|
||||
};
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn get_system_token(handle: HANDLE) -> Result<HANDLE> {
|
||||
let token_handle = unsafe {
|
||||
let mut token_handle = HANDLE::default();
|
||||
OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?;
|
||||
token_handle
|
||||
};
|
||||
|
||||
let duplicate_token = unsafe {
|
||||
let mut duplicate_token = HANDLE::default();
|
||||
DuplicateToken(
|
||||
token_handle,
|
||||
Security::SECURITY_IMPERSONATION_LEVEL(2),
|
||||
&mut duplicate_token,
|
||||
)?;
|
||||
CloseHandle(token_handle)?;
|
||||
duplicate_token
|
||||
};
|
||||
|
||||
Ok(duplicate_token)
|
||||
}
|
||||
|
||||
fn get_system_pid_list() -> Vec<(u32, &'static str)> {
|
||||
let sys = System::new_all();
|
||||
SYSTEM_PROCESS_NAMES
|
||||
.iter()
|
||||
.flat_map(|&name| {
|
||||
sys.processes_by_exact_name(name.as_ref())
|
||||
.map(move |process| (process.pid().as_u32(), name))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_process_handle(pid: u32) -> Result<HANDLE> {
|
||||
let hprocess =
|
||||
unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid) }?;
|
||||
Ok(hprocess)
|
||||
}
|
||||
|
||||
#[link(name = "ntdll")]
|
||||
unsafe extern "system" {
|
||||
unsafe fn RtlAdjustPrivilege(
|
||||
privilege: i32,
|
||||
enable: BOOL,
|
||||
current_thread: BOOL,
|
||||
previous_value: *mut BOOL,
|
||||
) -> NTSTATUS;
|
||||
}
|
||||
|
||||
fn enable_debug_privilege() -> Result<()> {
|
||||
let mut previous_value = BOOL(0);
|
||||
let status = unsafe {
|
||||
dbg_log!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege");
|
||||
RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value)
|
||||
};
|
||||
|
||||
match status {
|
||||
STATUS_SUCCESS => {
|
||||
dbg_log!(
|
||||
"SE_DEBUG_PRIVILEGE set to 1, was {} before",
|
||||
previous_value.as_bool()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
dbg_log!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0);
|
||||
Err(anyhow!("Failed to adjust privilege"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Pipe
|
||||
//
|
||||
|
||||
async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result<NamedPipeClient> {
|
||||
let client = open_pipe_client(pipe_name).await?;
|
||||
|
||||
if ENABLE_SERVER_SIGNATURE_VALIDATION {
|
||||
let server_pid = get_named_pipe_server_pid(&client)?;
|
||||
dbg_log!("Connected to pipe server PID {}", server_pid);
|
||||
|
||||
// Validate the server end process signature
|
||||
let exe_path = resolve_process_executable_path(server_pid)?;
|
||||
|
||||
dbg_log!("Pipe server executable path: {}", exe_path.display());
|
||||
|
||||
let verifier = CodeSignVerifier::for_file(exe_path.as_path()).map_err(|e| {
|
||||
anyhow!("verifysign init failed for {}: {:?}", exe_path.display(), e)
|
||||
})?;
|
||||
|
||||
let signature = verifier.verify().map_err(|e| {
|
||||
anyhow!(
|
||||
"verifysign verify failed for {}: {:?}",
|
||||
exe_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
dbg_log!("Pipe server executable path: {}", exe_path.display());
|
||||
|
||||
// Dump signature fields for debugging/inspection
|
||||
dbg_log!("Signature fields:");
|
||||
dbg_log!(" Subject Name: {:?}", signature.subject_name());
|
||||
dbg_log!(" Issuer Name: {:?}", signature.issuer_name());
|
||||
dbg_log!(" SHA1 Thumbprint: {:?}", signature.sha1_thumbprint());
|
||||
dbg_log!(" SHA256 Thumbprint: {:?}", signature.sha256_thumbprint());
|
||||
dbg_log!(" Serial Number: {:?}", signature.serial());
|
||||
|
||||
if signature.sha256_thumbprint() != EXPECTED_SERVER_SIGNATURE_SHA256_THUMBPRINT {
|
||||
return Err(anyhow!("Pipe server signature is not valid"));
|
||||
}
|
||||
|
||||
dbg_log!("Pipe server signature verified for PID {}", server_pid);
|
||||
}
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn run() -> Result<String> {
|
||||
dbg_log!("Starting bitwarden_chromium_import_helper.exe");
|
||||
|
||||
let args = Args::try_parse()?;
|
||||
|
||||
if !is_admin() {
|
||||
return Err(anyhow!("Expected to run with admin privileges"));
|
||||
}
|
||||
|
||||
dbg_log!("Running as ADMINISTRATOR");
|
||||
|
||||
// Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine
|
||||
let system_decrypted_base64 = {
|
||||
let system_token = start_impersonating()?;
|
||||
defer! {
|
||||
dbg_log!("Stopping impersonation");
|
||||
_ = stop_impersonating(system_token);
|
||||
}
|
||||
let system_decrypted_base64 = decrypt_data_base64(&args.encrypted, true)?;
|
||||
dbg_log!("Decrypted data with system");
|
||||
system_decrypted_base64
|
||||
};
|
||||
|
||||
// This is just to check that we're decrypting Chrome keys and not something else sent to us by a malicious actor.
|
||||
// Now that we're back from SYSTEM, we need to decrypt one more time just to verify.
|
||||
// Chrome keys are double encrypted: once at SYSTEM level and once at USER level.
|
||||
// When the decryption fails, it means that we're decrypting something unexpected.
|
||||
// We don't send this result back since the library will decrypt again at USER level.
|
||||
|
||||
_ = decrypt_data_base64(&system_decrypted_base64, false).map_err(|e| {
|
||||
dbg_log!("User level decryption check failed: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
dbg_log!("User level decryption check passed");
|
||||
|
||||
Ok(system_decrypted_base64)
|
||||
}
|
||||
|
||||
fn init_logging(log_path: &Path, file_level: LevelFilter) {
|
||||
// We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW.
|
||||
match std::fs::File::create(log_path) {
|
||||
Ok(file) => {
|
||||
let file_filter = EnvFilter::builder()
|
||||
.with_default_directive(file_level.into())
|
||||
.from_env_lossy();
|
||||
|
||||
let file_layer = fmt::layer()
|
||||
.with_writer(file)
|
||||
.with_ansi(false)
|
||||
.with_filter(file_filter);
|
||||
|
||||
tracing_subscriber::registry().with(file_layer).init();
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, ?log_path, "Could not create log file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn main() {
|
||||
if ENABLE_DEVELOPER_LOGGING {
|
||||
init_logging(LOG_FILENAME.as_ref(), LevelFilter::DEBUG);
|
||||
}
|
||||
|
||||
let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to open pipe {} to send result/error: {}",
|
||||
ADMIN_TO_USER_PIPE_NAME, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match run() {
|
||||
Ok(system_decrypted_base64) => {
|
||||
dbg_log!("Sending response back to user");
|
||||
let _ = send_to_user(&mut client, &system_decrypted_base64).await;
|
||||
}
|
||||
Err(e) => {
|
||||
dbg_log!("Error: {}", e);
|
||||
send_error_to_user(&mut client, &format!("{}", e)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
#[cfg(target_os = "windows")]
|
||||
windows_binary::main().await;
|
||||
}
|
||||
@@ -3,12 +3,15 @@ use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use dirs;
|
||||
use hex::decode;
|
||||
use homedir::my_home;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
mod platform;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use platform::ADMIN_TO_USER_PIPE_NAME;
|
||||
|
||||
pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS;
|
||||
|
||||
//
|
||||
@@ -52,7 +55,6 @@ pub trait InstalledBrowserRetriever {
|
||||
pub struct DefaultInstalledBrowserRetriever {}
|
||||
|
||||
impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
|
||||
// TODO: Make thus async
|
||||
fn get_installed_browsers() -> Result<Vec<String>> {
|
||||
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
|
||||
|
||||
@@ -67,7 +69,6 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Make thus async
|
||||
pub fn get_available_profiles(browser_name: &String) -> Result<Vec<ProfileInfo>> {
|
||||
let (_, local_state) = load_local_state_for_browser(browser_name)?;
|
||||
Ok(get_profile_info(&local_state))
|
||||
@@ -123,8 +124,7 @@ pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock<
|
||||
});
|
||||
|
||||
fn get_browser_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
|
||||
let dir = my_home()
|
||||
.map_err(|_| anyhow!("Home directory not found"))?
|
||||
let dir = dirs::home_dir()
|
||||
.ok_or_else(|| anyhow!("Home directory not found"))?
|
||||
.join(config.data_dir);
|
||||
Ok(dir)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Platform-specific code
|
||||
#[cfg_attr(target_os = "linux", path = "linux.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows/mod.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
mod native;
|
||||
|
||||
pub(crate) use native::*;
|
||||
// Windows exposes public const
|
||||
#[allow(unused_imports)]
|
||||
pub use native::*;
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
use aes_gcm::aead::Aead;
|
||||
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use winapi::shared::minwindef::{BOOL, BYTE, DWORD};
|
||||
use winapi::um::{dpapi::CryptUnprotectData, wincrypt::DATA_BLOB};
|
||||
use windows::Win32::Foundation::{LocalFree, HLOCAL};
|
||||
|
||||
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
|
||||
|
||||
use crate::util;
|
||||
|
||||
//
|
||||
// Public API
|
||||
//
|
||||
|
||||
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
|
||||
BrowserConfig {
|
||||
name: "Brave",
|
||||
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chrome",
|
||||
data_dir: "AppData/Local/Google/Chrome/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chromium",
|
||||
data_dir: "AppData/Local/Chromium/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Microsoft Edge",
|
||||
data_dir: "AppData/Local/Microsoft/Edge/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Opera",
|
||||
data_dir: "AppData/Roaming/Opera Software/Opera Stable",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Vivaldi",
|
||||
data_dir: "AppData/Local/Vivaldi/User Data",
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn get_crypto_service(
|
||||
_browser_name: &str,
|
||||
local_state: &LocalState,
|
||||
) -> Result<Box<dyn CryptoService>> {
|
||||
Ok(Box::new(WindowsCryptoService::new(local_state)))
|
||||
}
|
||||
|
||||
//
|
||||
// CryptoService
|
||||
//
|
||||
struct WindowsCryptoService {
|
||||
master_key: Option<Vec<u8>>,
|
||||
encrypted_key: Option<String>,
|
||||
}
|
||||
|
||||
impl WindowsCryptoService {
|
||||
pub(crate) fn new(local_state: &LocalState) -> Self {
|
||||
Self {
|
||||
master_key: None,
|
||||
encrypted_key: local_state
|
||||
.os_crypt
|
||||
.as_ref()
|
||||
.and_then(|c| c.encrypted_key.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CryptoService for WindowsCryptoService {
|
||||
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
|
||||
if encrypted.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
// On Windows only v10 and v20 are supported at the moment
|
||||
let (version, no_prefix) =
|
||||
util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?;
|
||||
|
||||
// v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag]
|
||||
const IV_SIZE: usize = 12;
|
||||
const TAG_SIZE: usize = 16;
|
||||
const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE;
|
||||
|
||||
if no_prefix.len() < MIN_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Corrupted entry: expected at least {} bytes, got {} bytes",
|
||||
MIN_LENGTH,
|
||||
no_prefix.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Allow empty passwords
|
||||
if no_prefix.len() == MIN_LENGTH {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
if self.master_key.is_none() {
|
||||
self.master_key = Some(self.get_master_key(version)?);
|
||||
}
|
||||
|
||||
let key = self
|
||||
.master_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
|
||||
let key = Key::<Aes256Gcm>::from_slice(key);
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]);
|
||||
|
||||
let decrypted_bytes = cipher
|
||||
.decrypt(nonce, no_prefix[IV_SIZE..].as_ref())
|
||||
.map_err(|e| anyhow!("Decryption failed: {}", e))?;
|
||||
|
||||
let plaintext = String::from_utf8(decrypted_bytes)
|
||||
.map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowsCryptoService {
|
||||
fn get_master_key(&mut self, version: &str) -> Result<Vec<u8>> {
|
||||
match version {
|
||||
"v10" => self.get_master_key_v10(),
|
||||
_ => Err(anyhow!("Unsupported version: {}", version)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_master_key_v10(&mut self) -> Result<Vec<u8>> {
|
||||
if self.encrypted_key.is_none() {
|
||||
return Err(anyhow!(
|
||||
"Encrypted master key is not found in the local browser state"
|
||||
));
|
||||
}
|
||||
|
||||
let key = self
|
||||
.encrypted_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
|
||||
let key_bytes = BASE64_STANDARD
|
||||
.decode(key)
|
||||
.map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?;
|
||||
|
||||
if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" {
|
||||
return Err(anyhow!("Encrypted master key is not encrypted with DPAPI"));
|
||||
}
|
||||
|
||||
let key = unprotect_data_win(&key_bytes[5..])
|
||||
.map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
fn unprotect_data_win(data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut data_in = DATA_BLOB {
|
||||
cbData: data.len() as DWORD,
|
||||
pbData: data.as_ptr() as *mut BYTE,
|
||||
};
|
||||
|
||||
let mut data_out = DATA_BLOB {
|
||||
cbData: 0,
|
||||
pbData: std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let result: BOOL = unsafe {
|
||||
// BOOL from winapi (i32)
|
||||
CryptUnprotectData(
|
||||
&mut data_in,
|
||||
std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16)
|
||||
std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB
|
||||
std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void)
|
||||
std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT
|
||||
0, // dwFlags: DWORD
|
||||
&mut data_out,
|
||||
)
|
||||
};
|
||||
|
||||
if result == 0 {
|
||||
return Err(anyhow!("CryptUnprotectData failed"));
|
||||
}
|
||||
|
||||
if data_out.pbData.is_null() || data_out.cbData == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let output_slice =
|
||||
unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) };
|
||||
|
||||
unsafe {
|
||||
if !data_out.pbData.is_null() {
|
||||
LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output_slice.to_vec())
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use super::abe_config;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
||||
use tokio::{
|
||||
io::{self, AsyncReadExt, AsyncWriteExt},
|
||||
net::windows::named_pipe::{NamedPipeServer, ServerOptions},
|
||||
sync::mpsc::channel,
|
||||
task::JoinHandle,
|
||||
time::{timeout, Duration},
|
||||
};
|
||||
use tracing::debug;
|
||||
use windows::{
|
||||
core::PCWSTR,
|
||||
Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_HIDE},
|
||||
};
|
||||
|
||||
const WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
fn start_tokio_named_pipe_server<F>(
|
||||
pipe_name: &'static str,
|
||||
process_message: F,
|
||||
) -> Result<JoinHandle<Result<(), io::Error>>>
|
||||
where
|
||||
F: Fn(&str) -> String + Send + Sync + Clone + 'static,
|
||||
{
|
||||
debug!("Starting Tokio named pipe server on: {}", pipe_name);
|
||||
|
||||
// The first server needs to be constructed early so that clients can be correctly
|
||||
// connected. Otherwise calling .wait will cause the client to error.
|
||||
// Here we also make use of `first_pipe_instance`, which will ensure that
|
||||
// there are no other servers up and running already.
|
||||
let mut server = ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.create(pipe_name)?;
|
||||
|
||||
debug!("Named pipe server created and listening...");
|
||||
|
||||
// Spawn the server loop.
|
||||
let server_task = tokio::spawn(async move {
|
||||
loop {
|
||||
// Wait for a client to connect.
|
||||
match server.connect().await {
|
||||
Ok(_) => {
|
||||
debug!("Client connected to named pipe");
|
||||
let connected_client = server;
|
||||
|
||||
// Construct the next server to be connected before sending the one
|
||||
// we already have off to a task. This ensures that the server
|
||||
// isn't closed (after it's done in the task) before a new one is
|
||||
// available. Otherwise the client might error with
|
||||
// `io::ErrorKind::NotFound`.
|
||||
server = ServerOptions::new().create(pipe_name)?;
|
||||
|
||||
// Handle the connected client in a separate task
|
||||
let process_message_clone = process_message.clone();
|
||||
let _client_task = tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(connected_client, process_message_clone).await
|
||||
{
|
||||
debug!("Error handling client: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to connect to client: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(server_task)
|
||||
}
|
||||
|
||||
async fn handle_client<F>(mut client: NamedPipeServer, process_message: F) -> Result<()>
|
||||
where
|
||||
F: Fn(&str) -> String,
|
||||
{
|
||||
debug!("Handling new client connection");
|
||||
|
||||
loop {
|
||||
// Read a message from the client
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
match client.read(&mut buffer).await {
|
||||
Ok(0) => {
|
||||
debug!("Client disconnected (0 bytes read)");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(bytes_read) => {
|
||||
let message = String::from_utf8_lossy(&buffer[..bytes_read]);
|
||||
let preview = message.chars().take(16).collect::<String>();
|
||||
debug!(
|
||||
"Received from client: '{}...' ({} bytes)",
|
||||
preview, bytes_read,
|
||||
);
|
||||
|
||||
let response = process_message(&message);
|
||||
match client.write_all(response.as_bytes()).await {
|
||||
Ok(_) => {
|
||||
debug!("Sent response to client ({} bytes)", response.len());
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow!("Failed to send response to client: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow!("Failed to read from client: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_with_admin_exe(admin_exe: &str, encrypted: &str) -> Result<String> {
|
||||
let (tx, mut rx) = channel::<String>(1);
|
||||
|
||||
debug!(
|
||||
"Starting named pipe server at '{}'...",
|
||||
abe_config::ADMIN_TO_USER_PIPE_NAME
|
||||
);
|
||||
|
||||
let server = match start_tokio_named_pipe_server(
|
||||
abe_config::ADMIN_TO_USER_PIPE_NAME,
|
||||
move |message: &str| {
|
||||
let _ = tx.try_send(message.to_string());
|
||||
"ok".to_string()
|
||||
},
|
||||
) {
|
||||
Ok(server) => server,
|
||||
Err(e) => return Err(anyhow!("Failed to start named pipe server: {}", e)),
|
||||
};
|
||||
|
||||
debug!("Launching '{}' as ADMINISTRATOR...", admin_exe);
|
||||
decrypt_with_admin_exe_internal(admin_exe, encrypted);
|
||||
|
||||
debug!("Waiting for message from {}...", admin_exe);
|
||||
let message = match timeout(
|
||||
Duration::from_secs(WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS),
|
||||
rx.recv(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(msg)) => msg,
|
||||
Ok(None) => return Err(anyhow!("Channel closed without message from {}", admin_exe)),
|
||||
Err(_) => return Err(anyhow!("Timeout waiting for message from {}", admin_exe)),
|
||||
};
|
||||
|
||||
debug!("Shutting down the pipe server...");
|
||||
server.abort();
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
fn decrypt_with_admin_exe_internal(admin_exe: &str, encrypted: &str) {
|
||||
// Convert strings to wide strings for Windows API
|
||||
let exe_wide = OsStr::new(admin_exe)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<u16>>();
|
||||
let runas_wide = OsStr::new("runas")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<u16>>();
|
||||
let parameters = OsStr::new(&format!(r#"--encrypted "{}""#, encrypted))
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<u16>>();
|
||||
|
||||
unsafe {
|
||||
ShellExecuteW(
|
||||
None,
|
||||
PCWSTR(runas_wide.as_ptr()),
|
||||
PCWSTR(exe_wide.as_ptr()),
|
||||
PCWSTR(parameters.as_ptr()),
|
||||
None,
|
||||
SW_HIDE,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub const ADMIN_TO_USER_PIPE_NAME: &str =
|
||||
r"\\.\pipe\bitwarden-to-bitwarden-chromium-importer-helper";
|
||||
@@ -0,0 +1,424 @@
|
||||
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use chacha20poly1305::ChaCha20Poly1305;
|
||||
use std::path::{Path, PathBuf};
|
||||
use windows::Win32::{
|
||||
Foundation::{LocalFree, HLOCAL},
|
||||
Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB},
|
||||
};
|
||||
|
||||
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
|
||||
use crate::util;
|
||||
mod abe;
|
||||
mod abe_config;
|
||||
|
||||
pub use abe_config::ADMIN_TO_USER_PIPE_NAME;
|
||||
|
||||
//
|
||||
// Public API
|
||||
//
|
||||
|
||||
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
|
||||
BrowserConfig {
|
||||
name: "Brave",
|
||||
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chrome",
|
||||
data_dir: "AppData/Local/Google/Chrome/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chromium",
|
||||
data_dir: "AppData/Local/Chromium/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Microsoft Edge",
|
||||
data_dir: "AppData/Local/Microsoft/Edge/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Opera",
|
||||
data_dir: "AppData/Roaming/Opera Software/Opera Stable",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Vivaldi",
|
||||
data_dir: "AppData/Local/Vivaldi/User Data",
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn get_crypto_service(
|
||||
_browser_name: &str,
|
||||
local_state: &LocalState,
|
||||
) -> Result<Box<dyn CryptoService>> {
|
||||
Ok(Box::new(WindowsCryptoService::new(local_state)))
|
||||
}
|
||||
|
||||
//
|
||||
// Private
|
||||
//
|
||||
|
||||
const ADMIN_EXE_FILENAME: &str = "bitwarden_chromium_import_helper.exe";
|
||||
|
||||
//
|
||||
// CryptoService
|
||||
//
|
||||
struct WindowsCryptoService {
|
||||
master_key: Option<Vec<u8>>,
|
||||
encrypted_key: Option<String>,
|
||||
app_bound_encrypted_key: Option<String>,
|
||||
}
|
||||
|
||||
impl WindowsCryptoService {
|
||||
pub(crate) fn new(local_state: &LocalState) -> Self {
|
||||
Self {
|
||||
master_key: None,
|
||||
encrypted_key: local_state
|
||||
.os_crypt
|
||||
.as_ref()
|
||||
.and_then(|c| c.encrypted_key.clone()),
|
||||
app_bound_encrypted_key: local_state
|
||||
.os_crypt
|
||||
.as_ref()
|
||||
.and_then(|c| c.app_bound_encrypted_key.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CryptoService for WindowsCryptoService {
|
||||
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
|
||||
if encrypted.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
// On Windows only v10 and v20 are supported at the moment
|
||||
let (version, no_prefix) =
|
||||
util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?;
|
||||
|
||||
// v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag]
|
||||
const IV_SIZE: usize = 12;
|
||||
const TAG_SIZE: usize = 16;
|
||||
const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE;
|
||||
|
||||
if no_prefix.len() < MIN_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Corrupted entry: expected at least {} bytes, got {} bytes",
|
||||
MIN_LENGTH,
|
||||
no_prefix.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Allow empty passwords
|
||||
if no_prefix.len() == MIN_LENGTH {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
if self.master_key.is_none() {
|
||||
self.master_key = Some(self.get_master_key(version).await?);
|
||||
}
|
||||
|
||||
let key = self
|
||||
.master_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
|
||||
let key = Key::<Aes256Gcm>::from_slice(key);
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]);
|
||||
|
||||
let decrypted_bytes = cipher
|
||||
.decrypt(nonce, no_prefix[IV_SIZE..].as_ref())
|
||||
.map_err(|e| anyhow!("Decryption failed: {}", e))?;
|
||||
|
||||
let plaintext = String::from_utf8(decrypted_bytes)
|
||||
.map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowsCryptoService {
|
||||
async fn get_master_key(&mut self, version: &str) -> Result<Vec<u8>> {
|
||||
match version {
|
||||
"v10" => self.get_master_key_v10(),
|
||||
"v20" => self.get_master_key_v20().await,
|
||||
_ => Err(anyhow!("Unsupported version: {}", version)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_master_key_v10(&mut self) -> Result<Vec<u8>> {
|
||||
if self.encrypted_key.is_none() {
|
||||
return Err(anyhow!(
|
||||
"Encrypted master key is not found in the local browser state"
|
||||
));
|
||||
}
|
||||
|
||||
let key = self
|
||||
.encrypted_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
|
||||
let key_bytes = BASE64_STANDARD
|
||||
.decode(key)
|
||||
.map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?;
|
||||
|
||||
if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" {
|
||||
return Err(anyhow!("Encrypted master key is not encrypted with DPAPI"));
|
||||
}
|
||||
|
||||
let key = unprotect_data_win(&key_bytes[5..])
|
||||
.map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
async fn get_master_key_v20(&mut self) -> Result<Vec<u8>> {
|
||||
if self.app_bound_encrypted_key.is_none() {
|
||||
return Err(anyhow!(
|
||||
"Encrypted master key is not found in the local browser state"
|
||||
));
|
||||
}
|
||||
|
||||
let admin_exe_path = get_admin_exe_path()?;
|
||||
let admin_exe_str = admin_exe_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Failed to convert {} path to string", ADMIN_EXE_FILENAME))?;
|
||||
|
||||
let key_base64 = abe::decrypt_with_admin_exe(
|
||||
admin_exe_str,
|
||||
self.app_bound_encrypted_key
|
||||
.as_ref()
|
||||
.expect("app_bound_encrypted_key should not be None"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(error_message) = key_base64.strip_prefix('!') {
|
||||
return Err(anyhow!(
|
||||
"Failed to decrypt the master key: {}",
|
||||
error_message
|
||||
));
|
||||
}
|
||||
|
||||
let key_bytes = BASE64_STANDARD.decode(&key_base64)?;
|
||||
let key = unprotect_data_win(&key_bytes)?;
|
||||
|
||||
Self::decode_abe_key_blob(key.as_slice())
|
||||
}
|
||||
|
||||
fn decode_abe_key_blob(blob_data: &[u8]) -> Result<Vec<u8>> {
|
||||
let header_len = u32::from_le_bytes(blob_data[0..4].try_into()?) as usize;
|
||||
// Ignore the header
|
||||
|
||||
let content_len_offset = 4 + header_len;
|
||||
let content_len =
|
||||
u32::from_le_bytes(blob_data[content_len_offset..content_len_offset + 4].try_into()?)
|
||||
as usize;
|
||||
|
||||
if content_len < 1 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: content length is less than 1"
|
||||
));
|
||||
}
|
||||
|
||||
let content_offset = content_len_offset + 4;
|
||||
let content = &blob_data[content_offset..content_offset + content_len];
|
||||
|
||||
// When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge
|
||||
if content_len == 32 {
|
||||
return Ok(content.to_vec());
|
||||
}
|
||||
|
||||
let version = content[0];
|
||||
let key_blob = &content[1..];
|
||||
match version {
|
||||
// Google Chrome v1 key encrypted with a hardcoded AES key
|
||||
1_u8 => Self::decrypt_abe_key_blob_chrome_aes(key_blob),
|
||||
// Google Chrome v2 key encrypted with a hardcoded ChaCha20 key
|
||||
2_u8 => Self::decrypt_abe_key_blob_chrome_chacha20(key_blob),
|
||||
// Google Chrome v3 key encrypted with CNG APIs
|
||||
3_u8 => Self::decrypt_abe_key_blob_chrome_cng(key_blob),
|
||||
v => Err(anyhow!("Unsupported ABE key blob version: {}", v)),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: DRY up with decrypt_abe_key_blob_chrome_chacha20
|
||||
fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
if blob.len() < 60 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
|
||||
blob.len()
|
||||
));
|
||||
}
|
||||
|
||||
let iv: [u8; 12] = blob[0..12].try_into()?;
|
||||
let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?;
|
||||
|
||||
const GOOGLE_AES_KEY: &[u8] = &[
|
||||
0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93,
|
||||
0x66, 0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D,
|
||||
0xA0, 0x28, 0x47, 0x87,
|
||||
];
|
||||
let aes_key = Key::<Aes256Gcm>::from_slice(GOOGLE_AES_KEY);
|
||||
let cipher = Aes256Gcm::new(aes_key);
|
||||
|
||||
let decrypted = cipher
|
||||
.decrypt((&iv).into(), ciphertext.as_ref())
|
||||
.map_err(|e| anyhow!("Failed to decrypt v20 key with Google AES key: {}", e))?;
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
if blob.len() < 60 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
|
||||
blob.len()
|
||||
));
|
||||
}
|
||||
|
||||
let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY);
|
||||
let cipher = ChaCha20Poly1305::new(chacha20_key);
|
||||
|
||||
const GOOGLE_CHACHA20_KEY: &[u8] = &[
|
||||
0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25,
|
||||
0x80, 0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D,
|
||||
0x08, 0x72, 0x96, 0x60,
|
||||
];
|
||||
|
||||
let iv: [u8; 12] = blob[0..12].try_into()?;
|
||||
let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?;
|
||||
|
||||
let decrypted = cipher
|
||||
.decrypt((&iv).into(), ciphertext.as_ref())
|
||||
.map_err(|e| anyhow!("Failed to decrypt v20 key with Google ChaCha20 key: {}", e))?;
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result<Vec<u8>> {
|
||||
if blob.len() < 92 {
|
||||
return Err(anyhow!(
|
||||
"Corrupted ABE key blob: expected at least 92 bytes, got {} bytes",
|
||||
blob.len()
|
||||
));
|
||||
}
|
||||
|
||||
let _encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?;
|
||||
let _iv: [u8; 12] = blob[32..32 + 12].try_into()?;
|
||||
let _ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?;
|
||||
|
||||
// TODO: Decrypt the AES key using CNG APIs
|
||||
// TODO: Implement this in the future once we run into a browser that uses this scheme
|
||||
|
||||
// There's no way to test this at the moment. This encryption scheme is not used in any of the browsers I've tested.
|
||||
Err(anyhow!("Google ABE CNG flavor is not supported yet"))
|
||||
}
|
||||
}
|
||||
|
||||
fn unprotect_data_win(data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let data_in = CRYPT_INTEGER_BLOB {
|
||||
cbData: data.len() as u32,
|
||||
pbData: data.as_ptr() as *mut u8,
|
||||
};
|
||||
|
||||
let mut data_out = CRYPT_INTEGER_BLOB {
|
||||
cbData: 0,
|
||||
pbData: std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let result = unsafe {
|
||||
CryptUnprotectData(
|
||||
&data_in,
|
||||
None, // ppszDataDescr: Option<*mut PWSTR>
|
||||
None, // pOptionalEntropy: Option<*const CRYPT_INTEGER_BLOB>
|
||||
None, // pvReserved: Option<*const std::ffi::c_void>
|
||||
None, // pPromptStruct: Option<*const CRYPTPROTECT_PROMPTSTRUCT>
|
||||
0, // dwFlags: u32
|
||||
&mut data_out,
|
||||
)
|
||||
};
|
||||
|
||||
if result.is_err() {
|
||||
return Err(anyhow!("CryptUnprotectData failed"));
|
||||
}
|
||||
|
||||
if data_out.pbData.is_null() || data_out.cbData == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let output_slice =
|
||||
unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) };
|
||||
|
||||
unsafe {
|
||||
if !data_out.pbData.is_null() {
|
||||
LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output_slice.to_vec())
|
||||
}
|
||||
|
||||
fn get_admin_exe_path() -> Result<PathBuf> {
|
||||
let current_exe_full_path = std::env::current_exe()
|
||||
.map_err(|e| anyhow!("Failed to get current executable path: {}", e))?;
|
||||
|
||||
let exe_name = current_exe_full_path
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("Failed to get file name from current executable path"))?;
|
||||
|
||||
let admin_exe_full_path = if exe_name.eq_ignore_ascii_case("electron.exe") {
|
||||
get_debug_admin_exe_path()?
|
||||
} else {
|
||||
get_dist_admin_exe_path(¤t_exe_full_path)?
|
||||
};
|
||||
|
||||
// check if bitwarden_chromium_import_helper.exe exists
|
||||
if !admin_exe_full_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"{} not found at path: {:?}",
|
||||
ADMIN_EXE_FILENAME,
|
||||
admin_exe_full_path
|
||||
));
|
||||
}
|
||||
|
||||
Ok(admin_exe_full_path)
|
||||
}
|
||||
|
||||
fn get_dist_admin_exe_path(current_exe_full_path: &Path) -> Result<PathBuf> {
|
||||
let admin_exe = current_exe_full_path
|
||||
.parent()
|
||||
.map(|p| p.join(ADMIN_EXE_FILENAME))
|
||||
.ok_or_else(|| anyhow!("Failed to get parent directory of current executable"))?;
|
||||
|
||||
Ok(admin_exe)
|
||||
}
|
||||
|
||||
// Try to find bitwarden_chromium_import_helper.exe in debug build folders. This might not cover all the cases.
|
||||
// Tested on `npm run electron` from apps/desktop and apps/desktop/desktop_native.
|
||||
fn get_debug_admin_exe_path() -> Result<PathBuf> {
|
||||
let current_dir = std::env::current_dir()?;
|
||||
let folder_name = current_dir
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("Failed to get folder name from current directory"))?;
|
||||
match folder_name.to_str() {
|
||||
Some("desktop") => Ok(get_target_admin_exe_path(
|
||||
current_dir.join("desktop_native"),
|
||||
)),
|
||||
Some("desktop_native") => Ok(get_target_admin_exe_path(current_dir)),
|
||||
_ => Err(anyhow!(
|
||||
"Cannot determine {} path from current directory: {}",
|
||||
ADMIN_EXE_FILENAME,
|
||||
current_dir.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_target_admin_exe_path(desktop_native_dir: PathBuf) -> PathBuf {
|
||||
desktop_native_dir
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join(ADMIN_EXE_FILENAME)
|
||||
}
|
||||
@@ -36,6 +36,10 @@
|
||||
{
|
||||
"from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe",
|
||||
"to": "desktop_proxy.exe"
|
||||
},
|
||||
{
|
||||
"from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe",
|
||||
"to": "bitwarden_chromium_import_helper.exe"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
{
|
||||
"from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe",
|
||||
"to": "desktop_proxy.exe"
|
||||
},
|
||||
{
|
||||
"from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe",
|
||||
"to": "bitwarden_chromium_import_helper.exe"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -771,6 +771,9 @@
|
||||
"useSingleSignOn": {
|
||||
"message": "Use single sign-on"
|
||||
},
|
||||
"yourOrganizationRequiresSingleSignOn": {
|
||||
"message": "Your organization requires single sign-on."
|
||||
},
|
||||
"submit": {
|
||||
"message": "Submit"
|
||||
},
|
||||
|
||||
@@ -670,6 +670,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
if (this.selectedPlan.PasswordManager.hasPremiumAccessOption) {
|
||||
subTotal += this.selectedPlan.PasswordManager.premiumAccessOptionPrice;
|
||||
}
|
||||
if (this.selectedPlan.PasswordManager.hasAdditionalStorageOption) {
|
||||
subTotal += this.additionalStorageTotal(this.selectedPlan);
|
||||
}
|
||||
return subTotal - this.discount;
|
||||
}
|
||||
|
||||
@@ -707,18 +710,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
if (this.organization.useSecretsManager) {
|
||||
return (
|
||||
this.passwordManagerSubtotal +
|
||||
this.additionalStorageTotal(this.selectedPlan) +
|
||||
this.secretsManagerSubtotal() +
|
||||
this.estimatedTax
|
||||
);
|
||||
return this.passwordManagerSubtotal + this.secretsManagerSubtotal() + this.estimatedTax;
|
||||
}
|
||||
return (
|
||||
this.passwordManagerSubtotal +
|
||||
this.additionalStorageTotal(this.selectedPlan) +
|
||||
this.estimatedTax
|
||||
);
|
||||
return this.passwordManagerSubtotal + this.estimatedTax;
|
||||
}
|
||||
|
||||
get teamsStarterPlanIsAvailable() {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
OrganizationSubscriptionPlan,
|
||||
OrganizationSubscriptionPurchase,
|
||||
SubscriberBillingClient,
|
||||
TaxClient,
|
||||
} from "@bitwarden/web-vault/app/billing/clients";
|
||||
@@ -478,7 +479,10 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get passwordManagerSubtotal() {
|
||||
let subTotal = this.selectedPlan.PasswordManager.basePrice;
|
||||
const basePriceAfterDiscount = this.acceptingSponsorship
|
||||
? Math.max(this.selectedPlan.PasswordManager.basePrice - this.discount, 0)
|
||||
: this.selectedPlan.PasswordManager.basePrice;
|
||||
let subTotal = basePriceAfterDiscount;
|
||||
if (
|
||||
this.selectedPlan.PasswordManager.hasAdditionalSeatsOption &&
|
||||
this.formGroup.controls.additionalSeats.value
|
||||
@@ -488,19 +492,19 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
this.formGroup.value.additionalSeats,
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.selectedPlan.PasswordManager.hasAdditionalStorageOption &&
|
||||
this.formGroup.controls.additionalStorage.value
|
||||
) {
|
||||
subTotal += this.additionalStorageTotal(this.selectedPlan);
|
||||
}
|
||||
if (
|
||||
this.selectedPlan.PasswordManager.hasPremiumAccessOption &&
|
||||
this.formGroup.controls.premiumAccessAddon.value
|
||||
) {
|
||||
subTotal += this.selectedPlan.PasswordManager.premiumAccessOptionPrice;
|
||||
}
|
||||
return subTotal - this.discount;
|
||||
if (
|
||||
this.selectedPlan.PasswordManager.hasAdditionalStorageOption &&
|
||||
this.formGroup.controls.additionalStorage.value
|
||||
) {
|
||||
subTotal += this.additionalStorageTotal(this.selectedPlan);
|
||||
}
|
||||
return subTotal;
|
||||
}
|
||||
|
||||
get secretsManagerSubtotal() {
|
||||
@@ -707,54 +711,90 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getPlanFromLegacyEnum(): OrganizationSubscriptionPlan {
|
||||
switch (this.formGroup.value.plan) {
|
||||
case PlanType.FamiliesAnnually:
|
||||
return { tier: "families", cadence: "annually" };
|
||||
case PlanType.TeamsMonthly:
|
||||
return { tier: "teams", cadence: "monthly" };
|
||||
case PlanType.TeamsAnnually:
|
||||
return { tier: "teams", cadence: "annually" };
|
||||
case PlanType.EnterpriseMonthly:
|
||||
return { tier: "enterprise", cadence: "monthly" };
|
||||
case PlanType.EnterpriseAnnually:
|
||||
return { tier: "enterprise", cadence: "annually" };
|
||||
}
|
||||
}
|
||||
|
||||
private buildTaxPreviewRequest(
|
||||
additionalStorage: number,
|
||||
sponsored: boolean,
|
||||
): OrganizationSubscriptionPurchase {
|
||||
const passwordManagerSeats = this.selectedPlan.PasswordManager.hasAdditionalSeatsOption
|
||||
? this.formGroup.value.additionalSeats
|
||||
: 1;
|
||||
|
||||
return {
|
||||
...this.getPlanFromLegacyEnum(),
|
||||
passwordManager: {
|
||||
seats: passwordManagerSeats,
|
||||
additionalStorage,
|
||||
sponsored,
|
||||
},
|
||||
secretsManager: this.formGroup.value.secretsManager.enabled
|
||||
? {
|
||||
seats: this.secretsManagerForm.value.userSeats,
|
||||
additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts,
|
||||
standalone: false,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshSalesTax(): Promise<void> {
|
||||
if (this.billingFormGroup.controls.billingAddress.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getPlanFromLegacyEnum = (): OrganizationSubscriptionPlan => {
|
||||
switch (this.formGroup.value.plan) {
|
||||
case PlanType.FamiliesAnnually:
|
||||
return { tier: "families", cadence: "annually" };
|
||||
case PlanType.TeamsMonthly:
|
||||
return { tier: "teams", cadence: "monthly" };
|
||||
case PlanType.TeamsAnnually:
|
||||
return { tier: "teams", cadence: "annually" };
|
||||
case PlanType.EnterpriseMonthly:
|
||||
return { tier: "enterprise", cadence: "monthly" };
|
||||
case PlanType.EnterpriseAnnually:
|
||||
return { tier: "enterprise", cadence: "annually" };
|
||||
}
|
||||
};
|
||||
|
||||
const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress);
|
||||
|
||||
const passwordManagerSeats =
|
||||
this.formGroup.value.productTier === ProductTierType.Families
|
||||
? 1
|
||||
: this.formGroup.value.additionalSeats;
|
||||
// should still be taxed. We mark the plan as NOT sponsored when there is additional storage
|
||||
// so the server calculates tax, but we'll adjust the calculation to only tax the storage.
|
||||
const hasPaidStorage = (this.formGroup.value.additionalStorage || 0) > 0;
|
||||
const sponsoredForTaxPreview = this.acceptingSponsorship && !hasPaidStorage;
|
||||
|
||||
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
|
||||
{
|
||||
...getPlanFromLegacyEnum(),
|
||||
passwordManager: {
|
||||
seats: passwordManagerSeats,
|
||||
additionalStorage: this.formGroup.value.additionalStorage,
|
||||
sponsored: false,
|
||||
},
|
||||
secretsManager: this.formGroup.value.secretsManager.enabled
|
||||
? {
|
||||
seats: this.secretsManagerForm.value.userSeats,
|
||||
additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts,
|
||||
standalone: false,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
billingAddress,
|
||||
);
|
||||
if (this.acceptingSponsorship && hasPaidStorage) {
|
||||
// For sponsored plans with paid storage, calculate tax only on storage
|
||||
// by comparing tax on base+storage vs tax on base only
|
||||
//TODO: Move this logic to PreviewOrganizationTaxCommand - https://bitwarden.atlassian.net/browse/PM-27585
|
||||
const [baseTaxAmounts, fullTaxAmounts] = await Promise.all([
|
||||
this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
|
||||
this.buildTaxPreviewRequest(0, false),
|
||||
billingAddress,
|
||||
),
|
||||
this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
|
||||
this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, false),
|
||||
billingAddress,
|
||||
),
|
||||
]);
|
||||
|
||||
this.estimatedTax = taxAmounts.tax;
|
||||
this.total = taxAmounts.total;
|
||||
// Tax on storage = Tax on (base + storage) - Tax on (base only)
|
||||
this.estimatedTax = fullTaxAmounts.tax - baseTaxAmounts.tax;
|
||||
} else {
|
||||
const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
|
||||
this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, sponsoredForTaxPreview),
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
this.estimatedTax = taxAmounts.tax;
|
||||
}
|
||||
|
||||
const subtotal =
|
||||
this.passwordManagerSubtotal +
|
||||
(this.planOffersSecretsManager && this.secretsManagerForm.value.enabled
|
||||
? this.secretsManagerSubtotal
|
||||
: 0);
|
||||
this.total = subtotal + this.estimatedTax;
|
||||
}
|
||||
|
||||
private async updateOrganization() {
|
||||
|
||||
@@ -50,6 +50,9 @@ export class PricingSummaryService {
|
||||
if (plan.PasswordManager?.hasPremiumAccessOption) {
|
||||
passwordManagerSubtotal += plan.PasswordManager.premiumAccessOptionPrice;
|
||||
}
|
||||
if (plan.PasswordManager?.hasAdditionalStorageOption) {
|
||||
passwordManagerSubtotal += additionalStorageTotal;
|
||||
}
|
||||
|
||||
const secretsManagerSubtotal = plan.SecretsManager
|
||||
? (plan.SecretsManager.basePrice || 0) +
|
||||
@@ -66,8 +69,8 @@ export class PricingSummaryService {
|
||||
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
|
||||
|
||||
const total = organization?.useSecretsManager
|
||||
? passwordManagerSubtotal + additionalStorageTotal + secretsManagerSubtotal + estimatedTax
|
||||
: passwordManagerSubtotal + additionalStorageTotal + estimatedTax;
|
||||
? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax
|
||||
: passwordManagerSubtotal + estimatedTax;
|
||||
|
||||
return {
|
||||
selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month",
|
||||
|
||||
@@ -1350,6 +1350,9 @@
|
||||
"useSingleSignOn": {
|
||||
"message": "Use single sign-on"
|
||||
},
|
||||
"yourOrganizationRequiresSingleSignOn": {
|
||||
"message": "Your organization requires single sign-on."
|
||||
},
|
||||
"welcomeBack": {
|
||||
"message": "Welcome back"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-con
|
||||
import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/admin-console/organizations/layouts/organization-layout.component";
|
||||
import { deepLinkGuard } from "@bitwarden/web-vault/app/auth/guards/deep-link/deep-link.guard";
|
||||
|
||||
import { SsoComponent } from "../../auth/sso/sso.component";
|
||||
import { SsoManageComponent } from "../../auth/sso/sso-manage.component";
|
||||
|
||||
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
|
||||
import { ScimComponent } from "./manage/scim.component";
|
||||
@@ -33,7 +33,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: "sso",
|
||||
component: SsoComponent,
|
||||
component: SsoManageComponent,
|
||||
canActivate: [organizationPermissionsGuard((org) => org.canManageSso)],
|
||||
data: {
|
||||
titleId: "singleSignOn",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NgModule } from "@angular/core";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
|
||||
|
||||
import { SsoComponent } from "../../auth/sso/sso.component";
|
||||
import { SsoManageComponent } from "../../auth/sso/sso-manage.component";
|
||||
|
||||
import { DomainAddEditDialogComponent } from "./manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component";
|
||||
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
|
||||
@@ -13,7 +13,7 @@ import { OrganizationsRoutingModule } from "./organizations-routing.module";
|
||||
@NgModule({
|
||||
imports: [SharedModule, OrganizationsRoutingModule, HeaderModule],
|
||||
declarations: [
|
||||
SsoComponent,
|
||||
SsoManageComponent,
|
||||
ScimComponent,
|
||||
DomainVerificationComponent,
|
||||
DomainAddEditDialogComponent,
|
||||
|
||||
@@ -133,15 +133,15 @@
|
||||
class="bwi bwi-spinner bwi-spin"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
*ngIf="keyConnectorUrl.pending"
|
||||
*ngIf="keyConnectorUrlFormCtrl.pending"
|
||||
></i>
|
||||
<span *ngIf="!keyConnectorUrl.pending">
|
||||
<span *ngIf="!keyConnectorUrlFormCtrl.pending">
|
||||
{{ "keyConnectorTest" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<bit-hint
|
||||
aria-live="polite"
|
||||
*ngIf="haveTestedKeyConnector && !keyConnectorUrl.hasError('invalidUrl')"
|
||||
*ngIf="haveTestedKeyConnector && !keyConnectorUrlFormCtrl.hasError('invalidUrl')"
|
||||
>
|
||||
<small class="tw-text-success-600">
|
||||
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import {
|
||||
AbstractControl,
|
||||
@@ -55,11 +53,11 @@ const defaultSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha2
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-org-manage-sso",
|
||||
templateUrl: "sso.component.html",
|
||||
selector: "auth-sso-manage",
|
||||
templateUrl: "sso-manage.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class SsoComponent implements OnInit, OnDestroy {
|
||||
export class SsoManageComponent implements OnInit, OnDestroy {
|
||||
readonly ssoType = SsoType;
|
||||
readonly memberDecryptionType = MemberDecryptionType;
|
||||
|
||||
@@ -117,31 +115,31 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
isInitializing = true; // concerned with UI/UX (i.e. when to show loading spinner vs form)
|
||||
isFormValidatingOrPopulating = true; // tracks when form fields are being validated/populated during load() or submit()
|
||||
|
||||
configuredKeyConnectorUrlFromServer: string | null;
|
||||
configuredKeyConnectorUrlFromServer: string | null = null;
|
||||
memberDecryptionTypeValueChangesSubscription: Subscription | null = null;
|
||||
haveTestedKeyConnector = false;
|
||||
organizationId: string;
|
||||
organization: Organization;
|
||||
organizationId: string | undefined = undefined;
|
||||
organization: Organization | undefined = undefined;
|
||||
|
||||
callbackPath: string;
|
||||
signedOutCallbackPath: string;
|
||||
spEntityId: string;
|
||||
spEntityIdStatic: string;
|
||||
spMetadataUrl: string;
|
||||
spAcsUrl: string;
|
||||
callbackPath: string | undefined = undefined;
|
||||
signedOutCallbackPath: string | undefined = undefined;
|
||||
spEntityId: string | undefined = undefined;
|
||||
spEntityIdStatic: string | undefined = undefined;
|
||||
spMetadataUrl: string | undefined = undefined;
|
||||
spAcsUrl: string | undefined = undefined;
|
||||
|
||||
showClientSecret = false;
|
||||
|
||||
protected openIdForm = this.formBuilder.group<ControlsOf<SsoConfigView["openId"]>>(
|
||||
{
|
||||
authority: new FormControl("", Validators.required),
|
||||
clientId: new FormControl("", Validators.required),
|
||||
clientSecret: new FormControl("", Validators.required),
|
||||
authority: new FormControl("", { nonNullable: true, validators: Validators.required }),
|
||||
clientId: new FormControl("", { nonNullable: true, validators: Validators.required }),
|
||||
clientSecret: new FormControl("", { nonNullable: true, validators: Validators.required }),
|
||||
metadataAddress: new FormControl(),
|
||||
redirectBehavior: new FormControl(
|
||||
OpenIdConnectRedirectBehavior.RedirectGet,
|
||||
Validators.required,
|
||||
),
|
||||
redirectBehavior: new FormControl(OpenIdConnectRedirectBehavior.RedirectGet, {
|
||||
nonNullable: true,
|
||||
validators: Validators.required,
|
||||
}),
|
||||
getClaimsFromUserInfoEndpoint: new FormControl(),
|
||||
additionalScopes: new FormControl(),
|
||||
additionalUserIdClaimTypes: new FormControl(),
|
||||
@@ -157,22 +155,32 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected samlForm = this.formBuilder.group<ControlsOf<SsoConfigView["saml"]>>(
|
||||
{
|
||||
spUniqueEntityId: new FormControl(true, { updateOn: "change" }),
|
||||
spNameIdFormat: new FormControl(Saml2NameIdFormat.NotConfigured),
|
||||
spOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm),
|
||||
spSigningBehavior: new FormControl(Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned),
|
||||
spMinIncomingSigningAlgorithm: new FormControl(defaultSigningAlgorithm),
|
||||
spUniqueEntityId: new FormControl(true, { nonNullable: true, updateOn: "change" }),
|
||||
spNameIdFormat: new FormControl(Saml2NameIdFormat.NotConfigured, { nonNullable: true }),
|
||||
spOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm, { nonNullable: true }),
|
||||
spSigningBehavior: new FormControl(Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned, {
|
||||
nonNullable: true,
|
||||
}),
|
||||
spMinIncomingSigningAlgorithm: new FormControl(defaultSigningAlgorithm, {
|
||||
nonNullable: true,
|
||||
}),
|
||||
spWantAssertionsSigned: new FormControl(),
|
||||
spValidateCertificates: new FormControl(),
|
||||
|
||||
idpEntityId: new FormControl("", Validators.required),
|
||||
idpBindingType: new FormControl(Saml2BindingType.HttpRedirect),
|
||||
idpSingleSignOnServiceUrl: new FormControl("", Validators.required),
|
||||
idpEntityId: new FormControl("", { nonNullable: true, validators: Validators.required }),
|
||||
idpBindingType: new FormControl(Saml2BindingType.HttpRedirect, { nonNullable: true }),
|
||||
idpSingleSignOnServiceUrl: new FormControl("", {
|
||||
nonNullable: true,
|
||||
validators: Validators.required,
|
||||
}),
|
||||
idpSingleLogoutServiceUrl: new FormControl(),
|
||||
idpX509PublicCert: new FormControl("", Validators.required),
|
||||
idpOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm),
|
||||
idpX509PublicCert: new FormControl("", {
|
||||
nonNullable: true,
|
||||
validators: Validators.required,
|
||||
}),
|
||||
idpOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm, { nonNullable: true }),
|
||||
idpAllowUnsolicitedAuthnResponse: new FormControl(),
|
||||
idpAllowOutboundLogoutRequests: new FormControl(true),
|
||||
idpAllowOutboundLogoutRequests: new FormControl(true, { nonNullable: true }),
|
||||
idpWantAuthnRequestsSigned: new FormControl(),
|
||||
},
|
||||
{
|
||||
@@ -181,13 +189,16 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
protected ssoConfigForm = this.formBuilder.group<ControlsOf<SsoConfigView>>({
|
||||
configType: new FormControl(SsoType.None),
|
||||
memberDecryptionType: new FormControl(MemberDecryptionType.MasterPassword),
|
||||
keyConnectorUrl: new FormControl(""),
|
||||
configType: new FormControl(SsoType.None, { nonNullable: true }),
|
||||
memberDecryptionType: new FormControl(MemberDecryptionType.MasterPassword, {
|
||||
nonNullable: true,
|
||||
}),
|
||||
keyConnectorUrl: new FormControl("", { nonNullable: true }),
|
||||
openId: this.openIdForm,
|
||||
saml: this.samlForm,
|
||||
enabled: new FormControl(false),
|
||||
enabled: new FormControl(false, { nonNullable: true }),
|
||||
ssoIdentifier: new FormControl("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.maxLength(50), Validators.required],
|
||||
}),
|
||||
});
|
||||
@@ -235,7 +246,7 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.ssoConfigForm
|
||||
.get("configType")
|
||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
?.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((newType: SsoType) => {
|
||||
if (newType === SsoType.OpenIdConnect) {
|
||||
this.openIdForm.enable();
|
||||
@@ -251,8 +262,8 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.samlForm
|
||||
.get("spSigningBehavior")
|
||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.samlForm.get("idpX509PublicCert").updateValueAndValidity());
|
||||
?.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.samlForm.get("idpX509PublicCert")?.updateValueAndValidity());
|
||||
|
||||
this.route.params
|
||||
.pipe(
|
||||
@@ -286,6 +297,10 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
this.memberDecryptionTypeValueChangesSubscription = null;
|
||||
|
||||
try {
|
||||
if (!this.organizationId) {
|
||||
throw new Error("Load: Organization ID is not set");
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService
|
||||
@@ -334,6 +349,11 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
this.readOutErrors();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.organizationId) {
|
||||
throw new Error("Submit: Organization ID is not set");
|
||||
}
|
||||
|
||||
const request = new OrganizationSsoRequest();
|
||||
request.enabled = this.enabledCtrl.value;
|
||||
// Return null instead of empty string to avoid duplicate id errors in database
|
||||
@@ -349,7 +369,6 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("ssoSettingsSaved"),
|
||||
});
|
||||
} finally {
|
||||
@@ -407,16 +426,16 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.keyConnectorUrl.markAsPending();
|
||||
this.keyConnectorUrlFormCtrl.markAsPending();
|
||||
|
||||
try {
|
||||
await this.apiService.getKeyConnectorAlive(this.keyConnectorUrl.value);
|
||||
this.keyConnectorUrl.updateValueAndValidity();
|
||||
await this.apiService.getKeyConnectorAlive(this.keyConnectorUrlFormCtrl.value);
|
||||
this.keyConnectorUrlFormCtrl.updateValueAndValidity();
|
||||
} catch {
|
||||
this.keyConnectorUrl.setErrors({
|
||||
this.keyConnectorUrlFormCtrl.setErrors({
|
||||
invalidUrl: { message: this.i18nService.t("keyConnectorTestFail") },
|
||||
});
|
||||
this.keyConnectorUrl.markAllAsTouched();
|
||||
this.keyConnectorUrlFormCtrl.markAllAsTouched();
|
||||
}
|
||||
|
||||
this.haveTestedKeyConnector = true;
|
||||
@@ -442,12 +461,12 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
get enableTestKeyConnector() {
|
||||
return (
|
||||
this.ssoConfigForm.value?.memberDecryptionType === MemberDecryptionType.KeyConnector &&
|
||||
!Utils.isNullOrWhitespace(this.keyConnectorUrl?.value)
|
||||
!Utils.isNullOrWhitespace(this.keyConnectorUrlFormCtrl?.value)
|
||||
);
|
||||
}
|
||||
|
||||
get keyConnectorUrl() {
|
||||
return this.ssoConfigForm.get("keyConnectorUrl");
|
||||
get keyConnectorUrlFormCtrl() {
|
||||
return this.ssoConfigForm.controls?.keyConnectorUrl as FormControl<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,6 +521,11 @@ export class SsoComponent implements OnInit, OnDestroy {
|
||||
organizationSsoRequest: OrganizationSsoRequest,
|
||||
): Promise<void> {
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
if (!this.organizationId) {
|
||||
throw new Error("upsertOrganizationWithSsoChanges: Organization ID is not set");
|
||||
}
|
||||
|
||||
const currentOrganization = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
@@ -44,6 +44,8 @@
|
||||
block
|
||||
buttonType="primary"
|
||||
(click)="continuePressed()"
|
||||
[bitTooltip]="ssoRequired ? ('yourOrganizationRequiresSingleSignOn' | i18n) : ''"
|
||||
[addTooltipToDescribedby]="ssoRequired"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
{{ "continue" | i18n }}
|
||||
@@ -59,6 +61,8 @@
|
||||
block
|
||||
buttonType="secondary"
|
||||
(click)="handleLoginWithPasskeyClick()"
|
||||
[bitTooltip]="ssoRequired ? ('yourOrganizationRequiresSingleSignOn' | i18n) : ''"
|
||||
[addTooltipToDescribedby]="ssoRequired"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
@@ -67,7 +71,13 @@
|
||||
</ng-container>
|
||||
|
||||
<!-- Button to Login with SSO -->
|
||||
<button type="button" bitButton block buttonType="secondary" (click)="handleSsoClick()">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
block
|
||||
[buttonType]="ssoRequired ? 'primary' : 'secondary'"
|
||||
(click)="handleSsoClick()"
|
||||
>
|
||||
<i class="bwi bwi-provider tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "useSingleSignOn" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
TooltipDirective,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { LoginComponentService, PasswordPolicies } from "./login-component.service";
|
||||
@@ -82,6 +83,7 @@ export enum LoginUiState {
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
TooltipDirective,
|
||||
],
|
||||
})
|
||||
export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -42,6 +42,7 @@ export * from "./table";
|
||||
export * from "./tabs";
|
||||
export * from "./toast";
|
||||
export * from "./toggle-group";
|
||||
export * from "./tooltip";
|
||||
export * from "./typography";
|
||||
export * from "./utils";
|
||||
export * from "./stepper";
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -56,7 +56,7 @@
|
||||
"lowdb": "1.0.0",
|
||||
"lunr": "2.3.9",
|
||||
"multer": "2.0.2",
|
||||
"ngx-toastr": "19.0.0",
|
||||
"ngx-toastr": "19.1.0",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-forge": "1.3.1",
|
||||
"oidc-client-ts": "2.4.1",
|
||||
@@ -31032,9 +31032,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ngx-toastr": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-19.0.0.tgz",
|
||||
"integrity": "sha512-6pTnktwwWD+kx342wuMOWB4+bkyX9221pAgGz3SHOJH0/MI9erLucS8PeeJDFwbUYyh75nQ6AzVtolgHxi52dQ==",
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-19.1.0.tgz",
|
||||
"integrity": "sha512-Qa7Kg7QzGKNtp1v04hu3poPKKx8BGBD/Onkhm6CdH5F0vSMdq+BdR/f8DTpZnGFksW891tAFufpiWb9UZX+3vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
"lowdb": "1.0.0",
|
||||
"lunr": "2.3.9",
|
||||
"multer": "2.0.2",
|
||||
"ngx-toastr": "19.0.0",
|
||||
"ngx-toastr": "19.1.0",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-forge": "1.3.1",
|
||||
"oidc-client-ts": "2.4.1",
|
||||
|
||||
Reference in New Issue
Block a user