1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 01:33:33 +00:00

PM-10393 SSH keys (#10825)

* [PM-10395] Add new item type ssh key (#10360)

* Implement ssh-key cipher type

* Fix linting

* Fix edit and view components for ssh-keys on desktop

* Fix tests

* Remove ssh key type references

* Remove add ssh key option

* Fix typo

* Add tests

* [PM-10399] Add ssh key import export for bitwarden json (#10529)

* Add ssh key import export for bitwarden json

* Remove key type from ssh key export

* [PM-10406] Add privatekey publickey and fingerprint to both add-edit and view co… (#11046)

* Add privatekey publickey and fingerprint to both add-edit and view components

* Remove wrong a11y title

* Fix testid

* [PM-10098] SSH Agent & SSH Key creation for Bitwarden Desktop (#10293)

* Add ssh agent, generator & import

* Move ssh agent code to bitwarden-russh crate

* Remove generator component

* Cleanup

* Cleanup

* Remove left over sshGenerator reference

* Cleanup

* Add documentation to sshkeyimportstatus

* Fix outdated variable name

* Update apps/desktop/src/platform/preload.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Rename renderersshagent

* Rename MainSshAgentService

* Improve clarity of 'id' variables being used

* Improve clarity of 'id' variables being used

* Update apps/desktop/src/vault/app/vault/add-edit.component.html

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Fix outdated cipher/messageid names

* Rename SSH to Ssh

* Make agent syncing more reactive

* Move constants to top of class

* Make sshkey cipher filtering clearer

* Add stricter equality check on ssh key unlock

* Fix build and messages

* Fix incorrect featureflag name

* Replace anonymous async function with switchmap pipe

* Fix build

* Update apps/desktop/desktop_native/napi/src/lib.rs

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Revert incorrectly renamed 'Ssh' usages to SSH

* Run cargo fmt

* Clean up ssh agent sock path logic

* Cleanup and split to platform specific files

* Small cleanup

* Pull out generator and importer into core

* Rename renderersshagentservice to sshagentservice

* Rename cipheruuid to cipher_id

* Drop ssh dependencies from napi crate

* Clean up windows build

* Small cleanup

* Small cleanup

* Cleanup

* Add rxjs pipeline for agent services

* [PM-12555] Pkcs8 sshkey import & general ssh key import tests (#11048)

* Add pkcs8 import and tests

* Add key type unsupported error

* Remove unsupported formats

* Remove code for unsupported formats

* Fix encrypted pkcs8 import

* Add ed25519 pkcs8 unencrypted test file

* SSH agent rxjs tweaks (#11148)

* feat: rewrite sshagent.signrequest as purely observable

* feat: fail the request when unlock times out

* chore: clean up, add some clarifying comments

* chore: remove unused dependency

* fix: result `undefined` crashing in NAPI -> Rust

* Allow concurrent SSH requests in rust

* Remove unwraps

* Cleanup and add init service init call

* Fix windows

* Fix timeout behavior on locked vault

---------

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Fix libc dependency being duplicated

* fix SSH casing (#11840)

* Move ssh agent behind feature flag (#11841)

* Move ssh agent behind feature flag

* Add separate flag for ssh agent

* [PM-14215] fix unsupported key type error message (#11788)

* Fix error message for import of unsupported ssh keys

* Use triple equals in add-edit component for ssh keys

---------

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
Co-authored-by: aj-bw <81774843+aj-bw@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann
2024-11-08 11:01:31 +01:00
committed by GitHub
parent 2c914def29
commit 081fe83d83
98 changed files with 3572 additions and 60 deletions

View File

@@ -419,6 +419,23 @@
"enableHardwareAccelerationDesc" | i18n
}}</small>
</div>
<div class="form-group" *ngIf="showSshAgentOption">
<div class="checkbox">
<label for="enableSshAgent">
<input
id="enableSshAgent"
type="checkbox"
aria-describedby="enableSshAgentHelp"
formControlName="enableSshAgent"
(change)="saveSshAgent()"
/>
{{ "enableSshAgent" | i18n }}
</label>
</div>
<small id="enableSshAgentHelp" class="help-block">{{
"enableSshAgentDesc" | i18n
}}</small>
</div>
<div class="form-group" *ngIf="showDuckDuckGoIntegrationOption">
<div class="checkbox">
<label for="enableDuckDuckGoBrowserIntegration">

View File

@@ -12,7 +12,9 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -53,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
showAlwaysShowDock = false;
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
showSshAgentOption = false;
isWindows: boolean;
isLinux: boolean;
@@ -107,6 +110,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
disabled: true,
}),
enableHardwareAcceleration: true,
enableSshAgent: false,
enableDuckDuckGoBrowserIntegration: false,
theme: [null as ThemeType | null],
locale: [null as string | null],
@@ -137,6 +141,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private pinService: PinServiceAbstraction,
private logService: LogService,
private nativeMessagingManifestService: NativeMessagingManifestService,
private configService: ConfigService,
) {
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
@@ -200,6 +205,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
if (activeAccount == null || activeAccount.id == null) {
return;
}
this.showSshAgentOption = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
@@ -272,6 +279,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
enableHardwareAcceleration: await firstValueFrom(
this.desktopSettingsService.hardwareAcceleration$,
),
enableSshAgent: await firstValueFrom(this.desktopSettingsService.sshAgentEnabled$),
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
locale: await firstValueFrom(this.i18nService.userSetLocale$),
};
@@ -723,6 +731,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
);
}
async saveSshAgent() {
this.logService.debug("Saving Ssh Agent settings", this.form.value.enableSshAgent);
await this.desktopSettingsService.setSshAgentEnabled(this.form.value.enableSshAgent);
}
private async generateVaultTimeoutOptions(): Promise<VaultTimeoutOption[]> {
let vaultTimeoutOptions: VaultTimeoutOption[] = [
{ name: this.i18nService.t("oneMinute"), value: 1 },

View File

@@ -22,6 +22,7 @@ import { SsoComponent } from "../auth/sso.component";
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
import { TwoFactorComponent } from "../auth/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { SshAgentService } from "../platform/services/ssh-agent.service";
import { PremiumComponent } from "../vault/app/accounts/premium.component";
import { AddEditCustomFieldsComponent } from "../vault/app/vault/add-edit-custom-fields.component";
import { AddEditComponent } from "../vault/app/vault/add-edit.component";
@@ -100,6 +101,7 @@ import { SendComponent } from "./tools/send/send.component";
ViewComponent,
ViewCustomFieldsComponent,
],
providers: [SshAgentService],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -21,6 +21,7 @@ import { UserId } from "@bitwarden/common/types/guid";
import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management";
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
import { SshAgentService } from "../../platform/services/ssh-agent.service";
import { NativeMessagingService } from "../../services/native-messaging.service";
@Injectable()
@@ -41,11 +42,13 @@ export class InitService {
private encryptService: EncryptService,
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
private accountService: AccountService,
private sshAgentService: SshAgentService,
@Inject(DOCUMENT) private document: Document,
) {}
init() {
return async () => {
await this.sshAgentService.init();
this.nativeMessagingService.init();
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process

View File

@@ -26,6 +26,9 @@
"typeSecureNote": {
"message": "Secure note"
},
"typeSshKey": {
"message": "SSH key"
},
"folders": {
"message": "Folders"
},
@@ -177,6 +180,48 @@
"address": {
"message": "Address"
},
"sshPrivateKey": {
"message": "Private key"
},
"sshPublicKey": {
"message": "Public key"
},
"sshFingerprint": {
"message": "Fingerprint"
},
"sshKeyAlgorithm": {
"message": "Key type"
},
"sshKeyAlgorithmED25519": {
"message": "ED25519"
},
"sshKeyAlgorithmRSA2048": {
"message": "RSA 2048-Bit"
},
"sshKeyAlgorithmRSA3072": {
"message": "RSA 3072-Bit"
},
"sshKeyAlgorithmRSA4096": {
"message": "RSA 4096-Bit"
},
"sshKeyGenerated": {
"message": "A new SSH key was generated"
},
"sshAgentUnlockRequired": {
"message": "Please unlock your vault to approve the SSH key request."
},
"sshAgentUnlockTimeout": {
"message": "SSH key request timed out."
},
"enableSshAgent": {
"message": "Enable SSH agent"
},
"enableSshAgentDesc": {
"message": "Enable the SSH agent to sign SSH requests right from your Bitwarden vault."
},
"enableSshAgentHelp": {
"message": "The SSH agent is a service targeted at developers that allows you to sign SSH requests directly from your Bitwarden vault."
},
"premiumRequired": {
"message": "Premium required"
},
@@ -400,6 +445,12 @@
"copyPassword": {
"message": "Copy password"
},
"regenerateSshKey": {
"message": "Regenerate SSH key"
},
"copySshPrivateKey": {
"message": "Copy SSH private key"
},
"copyPassphrase": {
"message": "Copy passphrase",
"description": "Copy passphrase to clipboard"
@@ -3225,6 +3276,36 @@
"ssoError": {
"message": "No free ports could be found for the sso login."
},
"authorize": {
"message": "Authorize"
},
"deny": {
"message": "Deny"
},
"sshkeyApprovalTitle": {
"message": "Confirm SSH key usage"
},
"sshkeyApprovalMessageInfix": {
"message": "is requesting access to"
},
"unknownApplication": {
"message": "An application"
},
"sshKeyPasswordUnsupported": {
"message": "Importing password protected SSH keys is not yet supported"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
},
"sshKeyTypeUnsupported": {
"message": "The SSH key type is not supported"
},
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
},
"sshKeyPasted": {
"message": "SSH key imported successfully"
},
"fileSavedToDevice": {
"message": "File saved to device. Manage from your device downloads."
}

View File

@@ -1,6 +1,6 @@
import * as path from "path";
import { app } from "electron";
import { app, ipcMain } from "electron";
import { Subject, firstValueFrom } from "rxjs";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
@@ -38,6 +38,7 @@ import { WindowMain } from "./main/window.main";
import { ClipboardMain } from "./platform/main/clipboard.main";
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service";
import { MainSshAgentService } from "./platform/main/main-ssh-agent.service";
import { DesktopSettingsService } from "./platform/services/desktop-settings.service";
import { ElectronLogMainService } from "./platform/services/electron-log.main.service";
import { ElectronStorageService } from "./platform/services/electron-storage.service";
@@ -71,6 +72,7 @@ export class Main {
nativeMessagingMain: NativeMessagingMain;
clipboardMain: ClipboardMain;
desktopAutofillSettingsService: DesktopAutofillSettingsService;
sshAgentService: MainSshAgentService;
constructor() {
// Set paths for portable builds
@@ -240,6 +242,13 @@ export class Main {
this.clipboardMain = new ClipboardMain();
this.clipboardMain.init();
ipcMain.handle("sshagent.init", async (event: any, message: any) => {
if (this.sshAgentService == null) {
this.sshAgentService = new MainSshAgentService(this.logService, this.messagingService);
this.sshAgentService.init();
}
});
new EphemeralValueStorageService();
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
}

View File

@@ -69,6 +69,19 @@ export class WindowMain {
this.logService.info("Render process reloaded");
});
ipcMain.on("window-focus", () => {
if (this.win != null) {
this.win.show();
this.win.focus();
}
});
ipcMain.on("window-hide", () => {
if (this.win != null) {
this.win.hide();
}
});
return new Promise<void>((resolve, reject) => {
try {
if (!isMacAppStore() && !isSnapStore()) {

View File

@@ -0,0 +1,17 @@
<form [bitSubmit]="submit" [formGroup]="approveSshRequestForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div>
<div bitDialogContent>
<b>{{params.applicationName}}</b> {{ "sshkeyApprovalMessageInfix" | i18n }}
<b>{{params.cipherName}}</b>.
</div>
<div bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
<span>{{ "authorize" | i18n }}</span>
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "deny" | i18n }}
</button>
</div>
</bit-dialog>
</form>

View File

@@ -0,0 +1,59 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
AsyncActionsModule,
ButtonModule,
DialogModule,
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
import { DialogService } from "@bitwarden/components/src/dialog";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
export interface ApproveSshRequestParams {
cipherName: string;
applicationName: string;
}
@Component({
selector: "app-approve-ssh-request",
templateUrl: "approve-ssh-request.html",
standalone: true,
imports: [
DialogModule,
CommonModule,
JslibModule,
CipherFormGeneratorComponent,
ButtonModule,
IconButtonModule,
ReactiveFormsModule,
AsyncActionsModule,
FormFieldModule,
],
})
export class ApproveSshRequestComponent {
approveSshRequestForm = this.formBuilder.group({});
constructor(
@Inject(DIALOG_DATA) protected params: ApproveSshRequestParams,
private dialogRef: DialogRef<boolean>,
private formBuilder: FormBuilder,
) {}
static open(dialogService: DialogService, cipherName: string, applicationName: string) {
return dialogService.open<boolean, ApproveSshRequestParams>(ApproveSshRequestComponent, {
data: {
cipherName,
applicationName,
},
});
}
submit = async () => {
this.dialogRef.close(true);
};
}

View File

@@ -0,0 +1,115 @@
import { ipcMain } from "electron";
import { concatMap, delay, filter, firstValueFrom, from, race, take, timer } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { sshagent } from "@bitwarden/desktop-napi";
class AgentResponse {
requestId: number;
accepted: boolean;
timestamp: Date;
}
export class MainSshAgentService {
SIGN_TIMEOUT = 60_000;
REQUEST_POLL_INTERVAL = 50;
private requestResponses: AgentResponse[] = [];
private request_id = 0;
private agentState: sshagent.SshAgentState;
constructor(
private logService: LogService,
private messagingService: MessagingService,
) {}
init() {
// handle sign request passing to UI
sshagent
.serve(async (err: Error, cipherId: string) => {
// clear all old (> SIGN_TIMEOUT) requests
this.requestResponses = this.requestResponses.filter(
(response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT),
);
this.request_id += 1;
const id_for_this_request = this.request_id;
this.messagingService.send("sshagent.signrequest", {
cipherId,
requestId: id_for_this_request,
});
const result = await firstValueFrom(
race(
from([false]).pipe(delay(this.SIGN_TIMEOUT)),
//poll for response
timer(0, this.REQUEST_POLL_INTERVAL).pipe(
concatMap(() => from(this.requestResponses)),
filter((response) => response.requestId == id_for_this_request),
take(1),
concatMap(() => from([true])),
),
),
);
if (!result) {
return false;
}
const response = this.requestResponses.find(
(response) => response.requestId == id_for_this_request,
);
this.requestResponses = this.requestResponses.filter(
(response) => response.requestId != id_for_this_request,
);
return response.accepted;
})
.then((agentState: sshagent.SshAgentState) => {
this.agentState = agentState;
this.logService.info("SSH agent started");
})
.catch((e) => {
this.logService.error("SSH agent encountered an error: ", e);
});
ipcMain.handle(
"sshagent.setkeys",
async (event: any, keys: { name: string; privateKey: string; cipherId: string }[]) => {
if (this.agentState != null) {
sshagent.setKeys(this.agentState, keys);
}
},
);
ipcMain.handle(
"sshagent.signrequestresponse",
async (event: any, { requestId, accepted }: { requestId: number; accepted: boolean }) => {
this.requestResponses.push({ requestId, accepted, timestamp: new Date() });
},
);
ipcMain.handle(
"sshagent.generatekey",
async (event: any, { keyAlgorithm }: { keyAlgorithm: string }): Promise<sshagent.SshKey> => {
return await sshagent.generateKeypair(keyAlgorithm);
},
);
ipcMain.handle(
"sshagent.importkey",
async (
event: any,
{ privateKey, password }: { privateKey: string; password?: string },
): Promise<sshagent.SshKeyImportResult> => {
return sshagent.importKey(privateKey, password);
},
);
ipcMain.handle("sshagent.lock", async (event: any) => {
if (this.agentState != null) {
sshagent.lock(this.agentState);
}
});
}
}

View File

@@ -1,3 +1,4 @@
import { sshagent as ssh } from "desktop_native/napi";
import { ipcRenderer } from "electron";
import { DeviceType } from "@bitwarden/common/enums";
@@ -40,6 +41,30 @@ const clipboard = {
write: (message: ClipboardWriteMessage) => ipcRenderer.invoke("clipboard.write", message),
};
const sshAgent = {
init: async () => {
await ipcRenderer.invoke("sshagent.init");
},
setKeys: (keys: { name: string; privateKey: string; cipherId: string }[]): Promise<void> =>
ipcRenderer.invoke("sshagent.setkeys", keys),
signRequestResponse: async (requestId: number, accepted: boolean) => {
await ipcRenderer.invoke("sshagent.signrequestresponse", { requestId, accepted });
},
generateKey: async (keyAlgorithm: string): Promise<ssh.SshKey> => {
return await ipcRenderer.invoke("sshagent.generatekey", { keyAlgorithm });
},
lock: async () => {
return await ipcRenderer.invoke("sshagent.lock");
},
importKey: async (key: string, password: string): Promise<ssh.SshKeyImportResult> => {
const res = await ipcRenderer.invoke("sshagent.importkey", {
privateKey: key,
password: password,
});
return res;
},
};
const powermonitor = {
isLockMonitorAvailable: (): Promise<boolean> =>
ipcRenderer.invoke("powermonitor.isLockMonitorAvailable"),
@@ -106,6 +131,8 @@ export default {
isSnapStore: isSnapStore(),
isAppImage: isAppImage(),
reloadProcess: () => ipcRenderer.send("reload-process"),
focusWindow: () => ipcRenderer.send("window-focus"),
hideWindow: () => ipcRenderer.send("window-hide"),
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),
@@ -150,6 +177,7 @@ export default {
storage,
passwords,
clipboard,
sshAgent,
powermonitor,
nativeMessaging,
crypto,

View File

@@ -66,6 +66,10 @@ const BROWSER_INTEGRATION_FINGERPRINT_ENABLED = new KeyDefinition<boolean>(
},
);
const SSH_AGENT_ENABLED = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "sshAgentEnabled", {
deserializer: (b) => b,
});
const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "minimizeOnCopy", {
deserializer: (b) => b,
clearOn: [], // User setting, no need to clear
@@ -139,6 +143,10 @@ export class DesktopSettingsService {
browserIntegrationFingerprintEnabled$ =
this.browserIntegrationFingerprintEnabledState.state$.pipe(map(Boolean));
private readonly sshAgentEnabledState = this.stateProvider.getGlobal(SSH_AGENT_ENABLED);
sshAgentEnabled$ = this.sshAgentEnabledState.state$.pipe(map(Boolean));
private readonly minimizeOnCopyState = this.stateProvider.getActive(MINIMIZE_ON_COPY);
/**
@@ -246,6 +254,13 @@ export class DesktopSettingsService {
await this.browserIntegrationFingerprintEnabledState.update(() => value);
}
/**
* Sets a setting for whether or not the SSH agent is enabled.
*/
async setSshAgentEnabled(value: boolean) {
await this.sshAgentEnabledState.update(() => value);
}
/**
* Sets the minimize on copy value for the current user.
* @param value `true` if the application should minimize when a value is copied,

View File

@@ -0,0 +1,183 @@
import { Injectable, OnDestroy } from "@angular/core";
import {
catchError,
combineLatest,
concatMap,
EMPTY,
filter,
from,
map,
of,
Subject,
switchMap,
takeUntil,
timeout,
TimeoutError,
timer,
withLatestFrom,
} from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { DialogService, ToastService } from "@bitwarden/components";
import { ApproveSshRequestComponent } from "../components/approve-ssh-request";
import { DesktopSettingsService } from "./desktop-settings.service";
@Injectable({
providedIn: "root",
})
export class SshAgentService implements OnDestroy {
SSH_REFRESH_INTERVAL = 1000;
SSH_VAULT_UNLOCK_REQUEST_TIMEOUT = 1000 * 60;
SSH_REQUEST_UNLOCK_POLLING_INTERVAL = 100;
private destroy$ = new Subject<void>();
constructor(
private cipherService: CipherService,
private logService: LogService,
private dialogService: DialogService,
private messageListener: MessageListener,
private authService: AuthService,
private toastService: ToastService,
private i18nService: I18nService,
private desktopSettingsService: DesktopSettingsService,
private configService: ConfigService,
) {}
async init() {
const isSshAgentFeatureEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
if (isSshAgentFeatureEnabled) {
await ipc.platform.sshAgent.init();
this.messageListener
.messages$(new CommandDefinition("sshagent.signrequest"))
.pipe(
withLatestFrom(this.authService.activeAccountStatus$),
// This switchMap handles unlocking the vault if it is locked:
// - If the vault is locked, we will wait for it to be unlocked.
// - If the vault is not unlocked within the timeout, we will abort the flow.
// - If the vault is unlocked, we will continue with the flow.
// switchMap is used here to prevent multiple requests from being processed at the same time,
// and will cancel the previous request if a new one is received.
switchMap(([message, status]) => {
if (status !== AuthenticationStatus.Unlocked) {
ipc.platform.focusWindow();
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("sshAgentUnlockRequired"),
});
return this.authService.activeAccountStatus$.pipe(
filter((status) => status === AuthenticationStatus.Unlocked),
timeout(this.SSH_VAULT_UNLOCK_REQUEST_TIMEOUT),
catchError((error: unknown) => {
if (error instanceof TimeoutError) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("sshAgentUnlockTimeout"),
});
const requestId = message.requestId as number;
// Abort flow by sending a false response.
// Returning an empty observable this will prevent the rest of the flow from executing
return from(ipc.platform.sshAgent.signRequestResponse(requestId, false)).pipe(
map(() => EMPTY),
);
}
throw error;
}),
map(() => message),
);
}
return of(message);
}),
// This switchMap handles fetching the ciphers from the vault.
switchMap((message) =>
from(this.cipherService.getAllDecrypted()).pipe(
map((ciphers) => [message, ciphers] as const),
),
),
// This concatMap handles showing the dialog to approve the request.
concatMap(([message, decryptedCiphers]) => {
const cipherId = message.cipherId as string;
const requestId = message.requestId as number;
if (decryptedCiphers === undefined) {
return of(false).pipe(
switchMap((result) =>
ipc.platform.sshAgent.signRequestResponse(requestId, Boolean(result)),
),
);
}
const cipher = decryptedCiphers.find((cipher) => cipher.id == cipherId);
ipc.platform.focusWindow();
const dialogRef = ApproveSshRequestComponent.open(
this.dialogService,
cipher.name,
this.i18nService.t("unknownApplication"),
);
return dialogRef.closed.pipe(
switchMap((result) => {
return ipc.platform.sshAgent.signRequestResponse(requestId, Boolean(result));
}),
);
}),
takeUntil(this.destroy$),
)
.subscribe();
combineLatest([
timer(0, this.SSH_REFRESH_INTERVAL),
this.desktopSettingsService.sshAgentEnabled$,
])
.pipe(
concatMap(async ([, enabled]) => {
if (!enabled) {
await ipc.platform.sshAgent.setKeys([]);
return;
}
const ciphers = await this.cipherService.getAllDecrypted();
if (ciphers == null) {
await ipc.platform.sshAgent.lock();
return;
}
const sshCiphers = ciphers.filter(
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
);
const keys = sshCiphers.map((cipher) => {
return {
name: cipher.name,
privateKey: cipher.sshKey.privateKey,
cipherId: cipher.id,
};
});
await ipc.platform.sshAgent.setKeys(keys);
}),
takeUntil(this.destroy$),
)
.subscribe();
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -11,7 +11,7 @@
<div class="box-content">
<div class="box-content-row" *ngIf="!editMode" appBoxRow>
<label for="type">{{ "type" | i18n }}</label>
<select id="type" name="Type" [(ngModel)]="cipher.type">
<select id="type" name="Type" [(ngModel)]="cipher.type" (change)="typeChange()">
<option *ngFor="let o of typeOptions" [ngValue]="o.value">{{ o.name }}</option>
</select>
</div>
@@ -471,6 +471,115 @@
/>
</div>
</div>
<!-- Ssh Key -->
<div *ngIf="cipher.type === cipherType.SshKey">
<div class="box-content-row box-content-row-flex" appBoxRow>
<div class="row-main">
<label for="sshPrivateKey">{{ "sshPrivateKey" | i18n }}</label>
<div
*ngIf="!showPrivateKey"
class="monospaced"
style="white-space: pre-line"
[innerText]="cipher.sshKey.maskedPrivateKey"
></div>
<div
*ngIf="showPrivateKey"
class="monospaced"
style="white-space: pre-line"
[innerText]="cipher.sshKey.privateKey"
></div>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'copySshPrivateKey' | i18n }}"
(click)="copy(this.cipher.sshKey.privateKey, 'sshPrivateKey', 'SshPrivateKey')"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePrivateKey()"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
></i>
</button>
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'regenerateSshKey' | i18n }}"
(click)="generateSshKey()"
*ngIf="cipher.edit || !editMode"
>
<i class="bwi bwi-lg bwi-generate" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box-content-row box-content-row-flex" appBoxRow>
<div class="row-main">
<label for="sshPublicKey">{{ "sshPublicKey" | i18n }}</label>
<input
id="sshPublicKey"
type="text"
name="SSHKey.SSHPublicKey"
[ngModel]="cipher.sshKey.publicKey"
readonly
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
(click)="copy(cipher.sshKey.publicKey, 'sshPublicKey', 'SSHPublicKey')"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box-content-row box-content-row-flex" appBoxRow>
<div class="row-main">
<label for="sshKeyFingerprint">{{ "sshFingerprint" | i18n }}</label>
<input
id="sshKeyFingerprint"
type="text"
name="SSHKey.SSHKeyFingerprint"
[ngModel]="cipher.sshKey.keyFingerprint"
readonly
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
(click)="copy(cipher.sshKey.keyFingerprint, 'sshFingerprint', 'SSHFingerprint')"
appA11yTitle="{{ 'generateSSHKey' | i18n }}"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box-content-row box-content-row-flex" appBoxRow>
<button
type="button"
class="row-btn"
appStopClick
(click)="importSshKeyFromClipboard()"
>
{{ "importSshKeyFromClipboard" | i18n }}
</button>
</div>
</div>
</div>
</div>
<div class="box" *ngIf="cipher.type === cipherType.Login">

View File

@@ -1,6 +1,7 @@
import { DatePipe } from "@angular/common";
import { Component, NgZone, OnChanges, OnInit, OnDestroy, ViewChild } from "@angular/core";
import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { NgForm } from "@angular/forms";
import { sshagent as sshAgent } from "desktop_native/napi";
import { CollectionService } from "@bitwarden/admin-console/common";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
@@ -18,8 +19,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService } from "@bitwarden/components";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "AddEditComponent";
@@ -31,6 +33,7 @@ const BroadcasterSubscriptionId = "AddEditComponent";
export class AddEditComponent extends BaseAddEditComponent implements OnInit, OnChanges, OnDestroy {
@ViewChild("form")
private form: NgForm;
constructor(
cipherService: CipherService,
folderService: FolderService,
@@ -51,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
dialogService: DialogService,
datePipe: DatePipe,
configService: ConfigService,
private toastService: ToastService,
cipherAuthorizationService: CipherAuthorizationService,
) {
super(
@@ -140,4 +144,68 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
"https://bitwarden.com/help/managing-items/#protect-individual-items",
);
}
async generateSshKey() {
const sshKey = await ipc.platform.sshAgent.generateKey("ed25519");
this.cipher.sshKey.privateKey = sshKey.privateKey;
this.cipher.sshKey.publicKey = sshKey.publicKey;
this.cipher.sshKey.keyFingerprint = sshKey.keyFingerprint;
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("sshKeyGenerated"),
});
}
async importSshKeyFromClipboard() {
const key = await this.platformUtilsService.readFromClipboard();
const parsedKey = await ipc.platform.sshAgent.importKey(key, "");
if (parsedKey == null || parsedKey.status === sshAgent.SshKeyImportStatus.ParsingError) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("invalidSshKey"),
});
return;
} else if (parsedKey.status === sshAgent.SshKeyImportStatus.UnsupportedKeyType) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("sshKeyTypeUnsupported"),
});
} else if (
parsedKey.status === sshAgent.SshKeyImportStatus.PasswordRequired ||
parsedKey.status === sshAgent.SshKeyImportStatus.WrongPassword
) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("sshKeyPasswordUnsupported"),
});
return;
} else {
this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey;
this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey;
this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint;
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("sshKeyPasted"),
});
}
}
async typeChange() {
if (this.cipher.type === CipherType.SshKey) {
await this.generateSshKey();
}
}
truncateString(value: string, length: number) {
return value.length > length ? value.substring(0, length) + "..." : value;
}
togglePrivateKey() {
this.showPrivateKey = !this.showPrivateKey;
}
}

View File

@@ -79,4 +79,19 @@
</button>
</span>
</li>
<li
class="filter-option"
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SshKey }"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyFilter(cipherTypeEnum.SshKey)"
[attr.aria-pressed]="activeFilter.cipherType === cipherTypeEnum.SshKey"
>
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i>&nbsp;{{ "typeSshKey" | i18n }}
</button>
</span>
</li>
</ul>

View File

@@ -399,6 +399,105 @@
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
</div>
</div>
<!-- Ssh Key -->
<div *ngIf="cipher.sshKey">
<div class="box-content-row box-content-row-flex" *ngIf="cipher.sshKey.privateKey">
<div class="row-main">
<span class="row-label">{{ "sshPrivateKey" | i18n }}</span>
<div
*ngIf="!showPrivateKey"
class="monospaced"
style="white-space: pre-line"
[innerText]="cipher.sshKey.maskedPrivateKey"
></div>
<div
*ngIf="showPrivateKey"
class="monospaced"
style="white-space: pre-line"
[innerText]="cipher.sshKey.privateKey"
></div>
</div>
<div class="action-buttons" *ngIf="cipher.viewPassword">
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
[attr.aria-pressed]="showPrivateKey"
(click)="togglePrivateKey()"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
></i>
</button>
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'copySSHPrivateKey' | i18n }}"
(click)="copy(cipher.sshKey.privateKey, 'sshPrivateKey', 'SshPrivateKey')"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
<div
class="box-content-row box-content-row-flex"
*ngIf="cipher.sshKey.publicKey"
appBoxRow
>
<div class="row-main">
<label for="sshPublicKey">{{ "sshPublicKey" | i18n }}</label>
<input
id="sshPublicKey"
type="text"
name="SshKey.SshPublicKey"
[ngModel]="cipher.sshKey.publicKey"
readonly
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
(click)="copy(cipher.sshKey.publicKey, 'sshPublicKey', 'SshPublicKey')"
appA11yTitle="{{ 'generateSshKey' | i18n }}"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
<div
class="box-content-row box-content-row-flex"
*ngIf="cipher.sshKey.keyFingerprint"
appBoxRow
>
<div class="row-main">
<label for="sshKeyFingerprint">{{ "sshFingerprint" | i18n }}</label>
<input
id="sshKeyFingerprint"
type="text"
name="SshKey.SshKeyFingerprint"
[ngModel]="cipher.sshKey.keyFingerprint"
readonly
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
(click)="copy(cipher.sshKey.keyFingerprint, 'sshFingerprint', 'SshFingerprint')"
appA11yTitle="{{ 'generateSshKey' | i18n }}"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">