mirror of
https://github.com/bitwarden/browser
synced 2026-01-08 03:23:50 +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:
@@ -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>
|
||||
59
apps/desktop/src/platform/components/approve-ssh-request.ts
Normal file
59
apps/desktop/src/platform/components/approve-ssh-request.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
115
apps/desktop/src/platform/main/main-ssh-agent.service.ts
Normal file
115
apps/desktop/src/platform/main/main-ssh-agent.service.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
183
apps/desktop/src/platform/services/ssh-agent.service.ts
Normal file
183
apps/desktop/src/platform/services/ssh-agent.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user