1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-15934] Add agent-forwarding detection and git signing detection parsers (#12371)

* Add agent-forwarding detection and git signing detection parsers

* Cleanup

* Pin russh version

* Run cargo fmt

* Fix build

* Update apps/desktop/desktop_native/core/src/ssh_agent/mod.rs

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>

* Pass through entire namespace

* Move to bytes crate

* Fix clippy errors

* Fix clippy warning

* Run cargo fmt

* Fix build

* Add renovate for bytes

* Fix clippy warn

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann
2025-02-26 12:12:27 +01:00
committed by GitHub
parent ce5a5e3649
commit cb028eadb5
14 changed files with 203 additions and 39 deletions

View File

@@ -123,6 +123,7 @@
matchPackageNames: [ matchPackageNames: [
"@emotion/css", "@emotion/css",
"@webcomponents/custom-elements", "@webcomponents/custom-elements",
"bytes",
"concurrently", "concurrently",
"cross-env", "cross-env",
"del", "del",

View File

@@ -439,7 +439,7 @@ checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
[[package]] [[package]]
name = "bitwarden-russh" name = "bitwarden-russh"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/bitwarden/bitwarden-russh.git?rev=23b50e3bbe6d56ef19ab0e98e8bb1462cb6d77ae#23b50e3bbe6d56ef19ab0e98e8bb1462cb6d77ae" source = "git+https://github.com/bitwarden/bitwarden-russh.git?rev=3d48f140fd506412d186203238993163a8c4e536#3d48f140fd506412d186203238993163a8c4e536"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"byteorder", "byteorder",
@@ -942,6 +942,7 @@ dependencies = [
"base64", "base64",
"bitwarden-russh", "bitwarden-russh",
"byteorder", "byteorder",
"bytes",
"cbc", "cbc",
"core-foundation", "core-foundation",
"desktop_objc", "desktop_objc",

View File

@@ -21,7 +21,7 @@ manual_test = []
aes = "=0.8.4" aes = "=0.8.4"
anyhow = { workspace = true } anyhow = { workspace = true }
arboard = { version = "=3.4.1", default-features = false, features = [ arboard = { version = "=3.4.1", default-features = false, features = [
"wayland-data-control", "wayland-data-control",
] } ] }
argon2 = { version = "=0.5.3", features = ["zeroize"] } argon2 = { version = "=0.5.3", features = ["zeroize"] }
base64 = "=0.22.1" base64 = "=0.22.1"
@@ -39,12 +39,12 @@ scopeguard = "=1.2.0"
sha2 = "=0.10.8" sha2 = "=0.10.8"
ssh-encoding = "=0.2.0" ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false, features = [ ssh-key = { version = "=0.6.7", default-features = false, features = [
"encryption", "encryption",
"ed25519", "ed25519",
"rsa", "rsa",
"getrandom", "getrandom",
] } ] }
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "23b50e3bbe6d56ef19ab0e98e8bb1462cb6d77ae" } bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" }
tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] } tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] }
tokio-stream = { workspace = true, features = ["net"] } tokio-stream = { workspace = true, features = ["net"] }
tokio-util = { workspace = true, features = ["codec"] } tokio-util = { workspace = true, features = ["codec"] }
@@ -53,21 +53,22 @@ typenum = "=1.17.0"
pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] } pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] }
rsa = "=0.9.6" rsa = "=0.9.6"
ed25519 = { version = "=2.2.3", features = ["pkcs8"] } ed25519 = { version = "=2.2.3", features = ["pkcs8"] }
sysinfo = { version = "=0.33.1", features = ["windows"] } bytes = "1.9.0"
sysinfo = { version = "0.33.1", features = ["windows"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
widestring = { version = "=1.1.0", optional = true } widestring = { version = "=1.1.0", optional = true }
windows = { version = "=0.58.0", features = [ windows = { version = "=0.58.0", features = [
"Foundation", "Foundation",
"Security_Credentials_UI", "Security_Credentials_UI",
"Security_Cryptography", "Security_Cryptography",
"Storage_Streams", "Storage_Streams",
"Win32_Foundation", "Win32_Foundation",
"Win32_Security_Credentials", "Win32_Security_Credentials",
"Win32_System_WinRT", "Win32_System_WinRT",
"Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",
"Win32_System_Pipes", "Win32_System_Pipes",
], optional = true } ], optional = true }
[target.'cfg(windows)'.dev-dependencies] [target.'cfg(windows)'.dev-dependencies]

View File

@@ -18,6 +18,8 @@ mod peercred_unix_listener_stream;
pub mod importer; pub mod importer;
pub mod peerinfo; pub mod peerinfo;
mod request_parser;
#[derive(Clone)] #[derive(Clone)]
pub struct BitwardenDesktopAgent { pub struct BitwardenDesktopAgent {
keystore: ssh_agent::KeyStore, keystore: ssh_agent::KeyStore,
@@ -35,19 +37,37 @@ pub struct SshAgentUIRequest {
pub cipher_id: Option<String>, pub cipher_id: Option<String>,
pub process_name: String, pub process_name: String,
pub is_list: bool, pub is_list: bool,
pub namespace: Option<String>,
pub is_forwarding: bool,
} }
impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent { impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
async fn confirm(&self, ssh_key: Key, info: &peerinfo::models::PeerInfo) -> bool { async fn confirm(&self, ssh_key: Key, data: &[u8], info: &peerinfo::models::PeerInfo) -> bool {
if !self.is_running() { if !self.is_running() {
println!("[BitwardenDesktopAgent] Agent is not running, but tried to call confirm"); println!("[BitwardenDesktopAgent] Agent is not running, but tried to call confirm");
return false; return false;
} }
let request_id = self.get_request_id().await; let request_id = self.get_request_id().await;
let request_data = match request_parser::parse_request(data) {
Ok(data) => data,
Err(e) => {
println!("[SSH Agent] Error while parsing request: {}", e);
return false;
}
};
let namespace = match request_data {
request_parser::SshAgentSignRequest::SshSigRequest(ref req) => {
Some(req.namespace.clone())
}
_ => None,
};
println!( println!(
"[SSH Agent] Confirming request from application: {}", "[SSH Agent] Confirming request from application: {}, is_forwarding: {}, namespace: {}",
info.process_name() info.process_name(),
info.is_forwarding(),
namespace.clone().unwrap_or_default(),
); );
let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe(); let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe();
@@ -57,6 +77,8 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
cipher_id: Some(ssh_key.cipher_uuid.clone()), cipher_id: Some(ssh_key.cipher_uuid.clone()),
process_name: info.process_name().to_string(), process_name: info.process_name().to_string(),
is_list: false, is_list: false,
namespace,
is_forwarding: info.is_forwarding(),
}) })
.await .await
.expect("Should send request to ui"); .expect("Should send request to ui");
@@ -81,6 +103,8 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
cipher_id: None, cipher_id: None,
process_name: info.process_name().to_string(), process_name: info.process_name().to_string(),
is_list: true, is_list: true,
namespace: None,
is_forwarding: info.is_forwarding(),
}; };
self.show_ui_request_tx self.show_ui_request_tx
.send(message) .send(message)
@@ -93,6 +117,17 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
} }
false false
} }
async fn set_is_forwarding(
&self,
is_forwarding: bool,
connection_info: &peerinfo::models::PeerInfo,
) {
// is_forwarding can only be added but never removed from a connection
if is_forwarding {
connection_info.set_forwarding(is_forwarding);
}
}
} }
impl BitwardenDesktopAgent { impl BitwardenDesktopAgent {

View File

@@ -34,9 +34,7 @@ impl Stream for PeercredUnixListenerStream {
return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))); return Poll::Ready(Some(Ok((stream, PeerInfo::unknown()))));
} }
}, },
Err(_) => { Err(_) => return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))),
return Poll::Ready(Some(Ok((stream, PeerInfo::unknown()))));
}
}; };
let peer_info = peerinfo::gather::get_peer_info(pid as u32); let peer_info = peerinfo::gather::get_peer_info(pid as u32);
match peer_info { match peer_info {

View File

@@ -1,3 +1,5 @@
use std::sync::{atomic::AtomicBool, Arc};
/** /**
* Peerinfo represents the information of a peer process connecting over a socket. * Peerinfo represents the information of a peer process connecting over a socket.
* This can be later extended to include more information (icon, app name) for the corresponding application. * This can be later extended to include more information (icon, app name) for the corresponding application.
@@ -7,6 +9,7 @@ pub struct PeerInfo {
uid: u32, uid: u32,
pid: u32, pid: u32,
process_name: String, process_name: String,
is_forwarding: Arc<AtomicBool>,
} }
impl PeerInfo { impl PeerInfo {
@@ -15,6 +18,16 @@ impl PeerInfo {
uid, uid,
pid, pid,
process_name, process_name,
is_forwarding: Arc::new(AtomicBool::new(false)),
}
}
pub fn unknown() -> Self {
Self {
uid: 0,
pid: 0,
process_name: "Unknown application".to_string(),
is_forwarding: Arc::new(AtomicBool::new(false)),
} }
} }
@@ -30,7 +43,13 @@ impl PeerInfo {
&self.process_name &self.process_name
} }
pub fn unknown() -> Self { pub fn is_forwarding(&self) -> bool {
Self::new(0, 0, "Unknown application".to_string()) self.is_forwarding
.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn set_forwarding(&self, value: bool) {
self.is_forwarding
.store(value, std::sync::atomic::Ordering::Relaxed);
} }
} }

View File

@@ -0,0 +1,41 @@
use bytes::{Buf, Bytes};
#[derive(Debug)]
pub(crate) struct SshSigRequest {
pub namespace: String,
}
#[derive(Debug)]
pub(crate) struct SignRequest {}
#[derive(Debug)]
pub(crate) enum SshAgentSignRequest {
SshSigRequest(SshSigRequest),
SignRequest(SignRequest),
}
pub(crate) fn parse_request(data: &[u8]) -> Result<SshAgentSignRequest, anyhow::Error> {
let mut data = Bytes::copy_from_slice(data);
let magic_header = "SSHSIG";
let header = data.split_to(magic_header.len());
// sshsig; based on https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig
if header == magic_header.as_bytes() {
let _version = data.get_u32();
// read until null byte
let namespace = data
.into_iter()
.take_while(|&x| x != 0)
.collect::<Vec<u8>>();
let namespace =
String::from_utf8(namespace).map_err(|_| anyhow::anyhow!("Invalid namespace"))?;
Ok(SshAgentSignRequest::SshSigRequest(SshSigRequest {
namespace,
}))
} else {
// regular sign request
Ok(SshAgentSignRequest::SignRequest(SignRequest {}))
}
}

View File

@@ -67,7 +67,14 @@ export declare namespace sshagent {
status: SshKeyImportStatus status: SshKeyImportStatus
sshKey?: SshKey sshKey?: SshKey
} }
export function serve(callback: (err: Error | null, arg0: string | undefined | null, arg1: boolean, arg2: string) => any): Promise<SshAgentState> export interface SshUiRequest {
cipherId?: string
isList: boolean
processName: string
isForwarding: boolean
namespace?: string
}
export function serve(callback: (err: Error | null, arg: SshUiRequest) => any): Promise<SshAgentState>
export function stop(agentState: SshAgentState): void export function stop(agentState: SshAgentState): void
export function isRunning(agentState: SshAgentState): boolean export function isRunning(agentState: SshAgentState): boolean
export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void

View File

@@ -243,9 +243,18 @@ pub mod sshagent {
} }
} }
#[napi(object)]
pub struct SshUIRequest {
pub cipher_id: Option<String>,
pub is_list: bool,
pub process_name: String,
pub is_forwarding: bool,
pub namespace: Option<String>,
}
#[napi] #[napi]
pub async fn serve( pub async fn serve(
callback: ThreadsafeFunction<(Option<String>, bool, String), CalleeHandled>, callback: ThreadsafeFunction<SshUIRequest, CalleeHandled>,
) -> napi::Result<SshAgentState> { ) -> napi::Result<SshAgentState> {
let (auth_request_tx, mut auth_request_rx) = let (auth_request_tx, mut auth_request_rx) =
tokio::sync::mpsc::channel::<desktop_core::ssh_agent::SshAgentUIRequest>(32); tokio::sync::mpsc::channel::<desktop_core::ssh_agent::SshAgentUIRequest>(32);
@@ -262,11 +271,13 @@ pub mod sshagent {
let auth_response_tx_arc = cloned_response_tx_arc; let auth_response_tx_arc = cloned_response_tx_arc;
let callback = cloned_callback; let callback = cloned_callback;
let promise_result: Result<Promise<bool>, napi::Error> = callback let promise_result: Result<Promise<bool>, napi::Error> = callback
.call_async(Ok(( .call_async(Ok(SshUIRequest {
request.cipher_id, cipher_id: request.cipher_id,
request.is_list, is_list: request.is_list,
request.process_name, process_name: request.process_name,
))) is_forwarding: request.is_forwarding,
namespace: request.namespace,
}))
.await; .await;
match promise_result { match promise_result {
Ok(promise_result) => match promise_result.await { Ok(promise_result) => match promise_result.await {

View File

@@ -3509,9 +3509,27 @@
"sshkeyApprovalTitle": { "sshkeyApprovalTitle": {
"message": "Confirm SSH key usage" "message": "Confirm SSH key usage"
}, },
"agentForwardingWarningTitle": {
"message": "Warning: Agent Forwarding"
},
"agentForwardingWarningText": {
"message": "This request comes from a remote device that you are logged into"
},
"sshkeyApprovalMessageInfix": { "sshkeyApprovalMessageInfix": {
"message": "is requesting access to" "message": "is requesting access to"
}, },
"sshkeyApprovalMessageSuffix": {
"message": "in order to"
},
"sshActionLogin": {
"message": "authenticate to a server"
},
"sshActionSign": {
"message": "sign a message"
},
"sshActionGitSign": {
"message": "sign a git commit"
},
"unknownApplication": { "unknownApplication": {
"message": "An application" "message": "An application"
}, },

View File

@@ -2,8 +2,17 @@
<bit-dialog> <bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div> <div class="tw-font-semibold" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div>
<div bitDialogContent> <div bitDialogContent>
<app-callout
type="warning"
title="{{ 'agentForwardingWarningTitle' | i18n }}"
*ngIf="params.isAgentForwarding"
>
{{ 'agentForwardingWarningText' | i18n }}
</app-callout>
<b>{{params.applicationName}}</b> {{ "sshkeyApprovalMessageInfix" | i18n }} <b>{{params.applicationName}}</b> {{ "sshkeyApprovalMessageInfix" | i18n }}
<b>{{params.cipherName}}</b>. <b>{{params.cipherName}}</b>
{{ "sshkeyApprovalMessageSuffix" | i18n }} {{ params.action | i18n }}
</div> </div>
<div bitDialogFooter> <div bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary"> <button type="submit" bitButton bitFormButton buttonType="primary">

View File

@@ -17,6 +17,8 @@ import { CipherFormGeneratorComponent } from "@bitwarden/vault";
export interface ApproveSshRequestParams { export interface ApproveSshRequestParams {
cipherName: string; cipherName: string;
applicationName: string; applicationName: string;
isAgentForwarding: boolean;
action: string;
} }
@Component({ @Component({
@@ -44,11 +46,26 @@ export class ApproveSshRequestComponent {
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
) {} ) {}
static open(dialogService: DialogService, cipherName: string, applicationName: string) { static open(
dialogService: DialogService,
cipherName: string,
applicationName: string,
isAgentForwarding: boolean,
namespace: string,
) {
let actioni18nKey = "sshActionLogin";
if (namespace === "git") {
actioni18nKey = "sshActionGitSign";
} else if (namespace != null && namespace != "") {
actioni18nKey = "sshActionSign";
}
return dialogService.open<boolean, ApproveSshRequestParams>(ApproveSshRequestComponent, { return dialogService.open<boolean, ApproveSshRequestParams>(ApproveSshRequestComponent, {
data: { data: {
cipherName, cipherName,
applicationName, applicationName,
isAgentForwarding,
action: actioni18nKey,
}, },
}); });
} }

View File

@@ -47,7 +47,7 @@ export class MainSshAgentService {
init() { init() {
// handle sign request passing to UI // handle sign request passing to UI
sshagent sshagent
.serve(async (err: Error, cipherId: string, isListRequest: boolean, processName: string) => { .serve(async (err: Error, sshUiRequest: sshagent.SshUiRequest) => {
// clear all old (> SIGN_TIMEOUT) requests // clear all old (> SIGN_TIMEOUT) requests
this.requestResponses = this.requestResponses.filter( this.requestResponses = this.requestResponses.filter(
(response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT), (response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT),
@@ -56,10 +56,12 @@ export class MainSshAgentService {
this.request_id += 1; this.request_id += 1;
const id_for_this_request = this.request_id; const id_for_this_request = this.request_id;
this.messagingService.send("sshagent.signrequest", { this.messagingService.send("sshagent.signrequest", {
cipherId, cipherId: sshUiRequest.cipherId,
isListRequest, isListRequest: sshUiRequest.isList,
requestId: id_for_this_request, requestId: id_for_this_request,
processName, processName: sshUiRequest.processName,
isAgentForwarding: sshUiRequest.isForwarding,
namespace: sshUiRequest.namespace,
}); });
const result = await firstValueFrom( const result = await firstValueFrom(

View File

@@ -148,6 +148,8 @@ export class SshAgentService implements OnDestroy {
const isListRequest = message.isListRequest as boolean; const isListRequest = message.isListRequest as boolean;
const requestId = message.requestId as number; const requestId = message.requestId as number;
let application = message.processName as string; let application = message.processName as string;
const namespace = message.namespace as string;
const isAgentForwarding = message.isAgentForwarding as boolean;
if (application == "") { if (application == "") {
application = this.i18nService.t("unknownApplication"); application = this.i18nService.t("unknownApplication");
} }
@@ -181,6 +183,8 @@ export class SshAgentService implements OnDestroy {
this.dialogService, this.dialogService,
cipher.name, cipher.name,
application, application,
isAgentForwarding,
namespace,
); );
const result = await firstValueFrom(dialogRef.closed); const result = await firstValueFrom(dialogRef.closed);