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

Move SSH Agent Files to Autofill Ownership (#13473)

* Move SSH agent files to Autofill ownership

* Fix ssh-agent.service.ts imports
This commit is contained in:
Colton Hurst
2025-03-04 12:31:36 -05:00
committed by GitHub
parent f7642aa0c6
commit 3bd60786b1
5 changed files with 5 additions and 6 deletions

View File

@@ -0,0 +1,282 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable, OnDestroy } from "@angular/core";
import {
catchError,
combineLatest,
concatMap,
EMPTY,
filter,
firstValueFrom,
from,
map,
of,
skip,
Subject,
switchMap,
takeUntil,
timeout,
TimeoutError,
timer,
withLatestFrom,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 { UserId } from "@bitwarden/common/types/guid";
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 "../../platform/components/approve-ssh-request";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@Injectable({
providedIn: "root",
})
export class SshAgentService implements OnDestroy {
SSH_REFRESH_INTERVAL = 1000;
SSH_VAULT_UNLOCK_REQUEST_TIMEOUT = 60_000;
SSH_REQUEST_UNLOCK_POLLING_INTERVAL = 100;
private isFeatureFlagEnabled = false;
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,
private accountService: AccountService,
) {}
async init() {
this.configService
.getFeatureFlag$(FeatureFlag.SSHAgent)
.pipe(
concatMap(async (enabled) => {
this.isFeatureFlagEnabled = enabled;
if (!(await ipc.platform.sshAgent.isLoaded()) && enabled) {
await ipc.platform.sshAgent.init();
}
}),
takeUntil(this.destroy$),
)
.subscribe();
await this.initListeners();
}
private async initListeners() {
this.messageListener
.messages$(new CommandDefinition("sshagent.signrequest"))
.pipe(
withLatestFrom(this.desktopSettingsService.sshAgentEnabled$),
concatMap(async ([message, enabled]) => {
if (!enabled) {
await ipc.platform.sshAgent.signRequestResponse(message.requestId as number, false);
}
return { message, enabled };
}),
filter(({ enabled }) => enabled),
map(({ message }) => message),
withLatestFrom(this.authService.activeAccountStatus$, this.accountService.activeAccount$),
// 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, account]) => {
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({
first: 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, account.id]),
);
}
return of([message, account.id]);
}),
// This switchMap handles fetching the ciphers from the vault.
switchMap(([message, userId]: [Record<string, unknown>, UserId]) =>
from(this.cipherService.getAllDecrypted(userId)).pipe(
map((ciphers) => [message, ciphers] as const),
),
),
// This concatMap handles showing the dialog to approve the request.
concatMap(async ([message, ciphers]) => {
const cipherId = message.cipherId as string;
const isListRequest = message.isListRequest as boolean;
const requestId = message.requestId as number;
let application = message.processName as string;
const namespace = message.namespace as string;
const isAgentForwarding = message.isAgentForwarding as boolean;
if (application == "") {
application = this.i18nService.t("unknownApplication");
}
if (isListRequest) {
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);
await ipc.platform.sshAgent.signRequestResponse(requestId, true);
return;
}
if (ciphers === undefined) {
ipc.platform.sshAgent
.signRequestResponse(requestId, false)
.catch((e) => this.logService.error("Failed to respond to SSH request", e));
}
const cipher = ciphers.find((cipher) => cipher.id == cipherId);
ipc.platform.focusWindow();
const dialogRef = ApproveSshRequestComponent.open(
this.dialogService,
cipher.name,
application,
isAgentForwarding,
namespace,
);
const result = await firstValueFrom(dialogRef.closed);
return ipc.platform.sshAgent.signRequestResponse(requestId, result);
}),
takeUntil(this.destroy$),
)
.subscribe();
this.accountService.activeAccount$.pipe(skip(1), takeUntil(this.destroy$)).subscribe({
next: (account) => {
if (!this.isFeatureFlagEnabled) {
return;
}
this.logService.info("Active account changed, clearing SSH keys");
ipc.platform.sshAgent
.clearKeys()
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
},
error: (e: unknown) => {
if (!this.isFeatureFlagEnabled) {
return;
}
this.logService.error("Error in active account observable", e);
ipc.platform.sshAgent
.clearKeys()
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
},
complete: () => {
if (!this.isFeatureFlagEnabled) {
return;
}
this.logService.info("Active account observable completed, clearing SSH keys");
ipc.platform.sshAgent
.clearKeys()
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
},
});
combineLatest([
timer(0, this.SSH_REFRESH_INTERVAL),
this.desktopSettingsService.sshAgentEnabled$,
])
.pipe(
concatMap(async ([, enabled]) => {
if (!this.isFeatureFlagEnabled) {
return;
}
if (!enabled) {
await ipc.platform.sshAgent.clearKeys();
return;
}
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
const authStatus = await firstValueFrom(
this.authService.authStatusFor$(activeAccount.id),
);
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
const ciphers = await this.cipherService.getAllDecrypted(activeAccount.id);
if (ciphers == null) {
await ipc.platform.sshAgent.lock();
return;
}
const sshCiphers = ciphers.filter(
(cipher) =>
cipher.type === CipherType.SshKey &&
!cipher.isDeleted &&
cipher.organizationId === null,
);
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();
}
}