1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 00:03:56 +00:00

[PM-16227] Move import to sdk and enable it in browser/web (#12479)

* Move import to sdk and enable it in browser/web

* Add uncomitted files

* Update package lock

* Fix prettier formatting

* Fix build

* Rewrite import logic

* Update ssh import logic for cipher form component

* Fix build on browser

* Break early in retry logic

* Fix build

* Fix build

* Fix build errors

* Update paste icons and throw error on wrong import

* Fix tests

* Fix build for cli

* Undo change to jest config

* Undo change to feature flag enum

* Remove unneeded lifetime

* Fix browser build

* Refactor control flow

* Fix i18n key and improve import behavior

* Remove for loop limit

* Clean up tests

* Remove unused code

* Update libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts

Co-authored-by: SmithThe4th <gsmith@bitwarden.com>

* Move import logic to service and add tests

* Fix linting

* Remove erroneous includes

* Attempt to fix storybook

* Fix storybook, explicitly implement ssh-import-prompt service abstraction

* Fix eslint

* Update libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts

Co-authored-by:  Audrey  <ajensen@bitwarden.com>

* Fix services module

* Remove ssh import sdk init code

* Add tests for errors

* Fix import

* Fix import

* Fix pkcs8 encrypted key not parsing

* Fix import button showing on web

---------

Co-authored-by: SmithThe4th <gsmith@bitwarden.com>
Co-authored-by:  Audrey  <ajensen@bitwarden.com>
This commit is contained in:
Bernd Schoolmann
2025-03-10 18:41:47 +01:00
committed by GitHub
parent 85a5aea897
commit 01f6fd7ee3
35 changed files with 428 additions and 621 deletions

View File

@@ -24,6 +24,7 @@ import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -39,6 +40,8 @@ import {
// eslint-disable-next-line no-restricted-imports
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests";
import { SshImportPromptService } from "../services/ssh-import-prompt.service";
import { CipherFormService } from "./abstractions/cipher-form.service";
import { TotpCaptureService } from "./abstractions/totp-capture.service";
import { CipherFormModule } from "./cipher-form.module";
@@ -146,6 +149,12 @@ export default {
enabled$: new BehaviorSubject(true),
},
},
{
provide: SshImportPromptService,
useValue: {
importSshKeyFromClipboard: () => Promise.resolve(new SshKeyData()),
},
},
{
provide: CipherFormGenerationService,
useValue: {

View File

@@ -15,6 +15,14 @@
data-testid="toggle-privateKey-visibility"
bitPasswordInputToggle
></button>
<button
type="button"
bitIconButton="bwi-paste"
bitSuffix
data-testid="import-privateKey"
*ngIf="showImport"
(click)="importSshKeyFromClipboard()"
></button>
</bit-form-field>
<bit-form-field>

View File

@@ -7,7 +7,8 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ClientType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
@@ -22,6 +23,7 @@ import {
} from "@bitwarden/components";
import { generate_ssh_key } from "@bitwarden/sdk-internal";
import { SshImportPromptService } from "../../../services/ssh-import-prompt.service";
import { CipherFormContainer } from "../../cipher-form-container";
@Component({
@@ -60,11 +62,14 @@ export class SshKeySectionComponent implements OnInit {
keyFingerprint: [""],
});
showImport = false;
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private sdkService: SdkService,
private sshImportPromptService: SshImportPromptService,
private platformUtilsService: PlatformUtilsService,
) {
this.cipherFormContainer.registerChildForm("sshKeyDetails", this.sshKeyForm);
this.sshKeyForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
@@ -87,6 +92,11 @@ export class SshKeySectionComponent implements OnInit {
}
this.sshKeyForm.disable();
// Web does not support clipboard access
if (this.platformUtilsService.getClientType() !== ClientType.Web) {
this.showImport = true;
}
}
/** Set form initial form values from the current cipher */
@@ -100,6 +110,17 @@ export class SshKeySectionComponent implements OnInit {
});
}
async importSshKeyFromClipboard() {
const key = await this.sshImportPromptService.importSshKeyFromClipboard();
if (key != null) {
this.sshKeyForm.setValue({
privateKey: key.privateKey,
publicKey: key.publicKey,
keyFingerprint: key.keyFingerprint,
});
}
}
private async generateSshKey() {
await firstValueFrom(this.sdkService.client$);
const sshKey = generate_ssh_key("Ed25519");

View File

@@ -25,8 +25,10 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon
export * from "./components/carousel";
export * as VaultIcons from "./icons";
export * from "./tasks";
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
export * from "./abstractions/change-login-password.service";
export * from "./services/default-change-login-password.service";

View File

@@ -0,0 +1,109 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api";
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
import { DialogService, ToastService } from "@bitwarden/components";
import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui";
import { import_ssh_key, SshKeyImportError, SshKeyView } from "@bitwarden/sdk-internal";
import { SshImportPromptService } from "./ssh-import-prompt.service";
/**
* Used to import ssh keys and prompt for their password.
*/
@Injectable()
export class DefaultSshImportPromptService implements SshImportPromptService {
constructor(
private dialogService: DialogService,
private toastService: ToastService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {}
async importSshKeyFromClipboard(): Promise<SshKeyData | null> {
const key = await this.platformUtilsService.readFromClipboard();
let isPasswordProtectedSshKey = false;
let parsedKey: SshKeyView | null = null;
try {
parsedKey = import_ssh_key(key);
} catch (e) {
const error = e as SshKeyImportError;
if (error.variant === "PasswordRequired" || error.variant === "WrongPassword") {
isPasswordProtectedSshKey = true;
} else {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)),
});
return null;
}
}
if (isPasswordProtectedSshKey) {
for (;;) {
const password = await this.getSshKeyPassword();
if (password === "" || password == null) {
return null;
}
try {
parsedKey = import_ssh_key(key, password);
break;
} catch (e) {
const error = e as SshKeyImportError;
if (error.variant !== "WrongPassword") {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)),
});
return null;
}
}
}
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("sshKeyImported"),
});
return new SshKeyData(
new SshKeyApi({
privateKey: parsedKey!.privateKey,
publicKey: parsedKey!.publicKey,
keyFingerprint: parsedKey!.fingerprint,
}),
);
}
private sshImportErrorVariantToI18nKey(variant: string): string {
switch (variant) {
case "ParsingError":
return "invalidSshKey";
case "UnsupportedKeyType":
return "sshKeyTypeUnsupported";
case "PasswordRequired":
case "WrongPassword":
return "sshKeyWrongPassword";
default:
return "errorOccurred";
}
}
private async getSshKeyPassword(): Promise<string | undefined> {
const dialog = this.dialogService.open<string>(SshKeyPasswordPromptComponent, {
ariaModal: true,
});
return await firstValueFrom(dialog.closed);
}
}

View File

@@ -0,0 +1,111 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api";
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
import { DialogService, ToastService } from "@bitwarden/components";
import * as sdkInternal from "@bitwarden/sdk-internal";
import { DefaultSshImportPromptService } from "./default-ssh-import-prompt.service";
jest.mock("@bitwarden/sdk-internal");
const exampleSshKey = {
privateKey: "private_key",
publicKey: "public_key",
fingerprint: "key_fingerprint",
} as sdkInternal.SshKeyView;
const exampleSshKeyData = new SshKeyData(
new SshKeyApi({
publicKey: exampleSshKey.publicKey,
privateKey: exampleSshKey.privateKey,
keyFingerprint: exampleSshKey.fingerprint,
}),
);
describe("SshImportPromptService", () => {
let sshImportPromptService: DefaultSshImportPromptService;
let dialogService: MockProxy<DialogService>;
let toastService: MockProxy<ToastService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
dialogService = mock<DialogService>();
toastService = mock<ToastService>();
platformUtilsService = mock<PlatformUtilsService>();
i18nService = mock<I18nService>();
sshImportPromptService = new DefaultSshImportPromptService(
dialogService,
toastService,
platformUtilsService,
i18nService,
);
jest.clearAllMocks();
});
describe("importSshKeyFromClipboard()", () => {
it("imports unencrypted ssh key", async () => {
jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(exampleSshKey);
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData);
});
it("requests password for encrypted ssh key", async () => {
jest
.spyOn(sdkInternal, "import_ssh_key")
.mockImplementationOnce(() => {
throw { variant: "PasswordRequired" };
})
.mockImplementationOnce(() => exampleSshKey);
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any);
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData);
expect(dialogService.open).toHaveBeenCalled();
});
it("cancels when no password was provided", async () => {
jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => {
throw { variant: "PasswordRequired" };
});
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("") } as any);
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
expect(dialogService.open).toHaveBeenCalled();
});
it("passes through error on no password", async () => {
jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => {
throw { variant: "UnsupportedKeyType" };
});
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported");
});
it("passes through error with password", async () => {
jest
.spyOn(sdkInternal, "import_ssh_key")
.mockClear()
.mockImplementationOnce(() => {
throw { variant: "PasswordRequired" };
})
.mockImplementationOnce(() => {
throw { variant: "UnsupportedKeyType" };
});
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any);
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported");
});
});
});

View File

@@ -0,0 +1,5 @@
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
export abstract class SshImportPromptService {
abstract importSshKeyFromClipboard: () => Promise<SshKeyData | null>;
}