1
0
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:
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

@@ -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();
}
}