1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-28 03:21:40 +01: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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 203 additions and 39 deletions

View File

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

View File

@ -439,7 +439,7 @@ checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
[[package]]
name = "bitwarden-russh"
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 = [
"anyhow",
"byteorder",
@ -942,6 +942,7 @@ dependencies = [
"base64",
"bitwarden-russh",
"byteorder",
"bytes",
"cbc",
"core-foundation",
"desktop_objc",

View File

@ -21,7 +21,7 @@ manual_test = []
aes = "=0.8.4"
anyhow = { workspace = true }
arboard = { version = "=3.4.1", default-features = false, features = [
"wayland-data-control",
"wayland-data-control",
] }
argon2 = { version = "=0.5.3", features = ["zeroize"] }
base64 = "=0.22.1"
@ -39,12 +39,12 @@ scopeguard = "=1.2.0"
sha2 = "=0.10.8"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false, features = [
"encryption",
"ed25519",
"rsa",
"getrandom",
"encryption",
"ed25519",
"rsa",
"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-stream = { workspace = true, features = ["net"] }
tokio-util = { workspace = true, features = ["codec"] }
@ -53,21 +53,22 @@ typenum = "=1.17.0"
pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] }
rsa = "=0.9.6"
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]
widestring = { version = "=1.1.0", optional = true }
windows = { version = "=0.58.0", features = [
"Foundation",
"Security_Credentials_UI",
"Security_Cryptography",
"Storage_Streams",
"Win32_Foundation",
"Win32_Security_Credentials",
"Win32_System_WinRT",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_System_Pipes",
"Foundation",
"Security_Credentials_UI",
"Security_Cryptography",
"Storage_Streams",
"Win32_Foundation",
"Win32_Security_Credentials",
"Win32_System_WinRT",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_System_Pipes",
], optional = true }
[target.'cfg(windows)'.dev-dependencies]

View File

@ -18,6 +18,8 @@ mod peercred_unix_listener_stream;
pub mod importer;
pub mod peerinfo;
mod request_parser;
#[derive(Clone)]
pub struct BitwardenDesktopAgent {
keystore: ssh_agent::KeyStore,
@ -35,19 +37,37 @@ pub struct SshAgentUIRequest {
pub cipher_id: Option<String>,
pub process_name: String,
pub is_list: bool,
pub namespace: Option<String>,
pub is_forwarding: bool,
}
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() {
println!("[BitwardenDesktopAgent] Agent is not running, but tried to call confirm");
return false;
}
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!(
"[SSH Agent] Confirming request from application: {}",
info.process_name()
"[SSH Agent] Confirming request from application: {}, is_forwarding: {}, namespace: {}",
info.process_name(),
info.is_forwarding(),
namespace.clone().unwrap_or_default(),
);
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()),
process_name: info.process_name().to_string(),
is_list: false,
namespace,
is_forwarding: info.is_forwarding(),
})
.await
.expect("Should send request to ui");
@ -81,6 +103,8 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
cipher_id: None,
process_name: info.process_name().to_string(),
is_list: true,
namespace: None,
is_forwarding: info.is_forwarding(),
};
self.show_ui_request_tx
.send(message)
@ -93,6 +117,17 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
}
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 {

View File

@ -34,9 +34,7 @@ impl Stream for PeercredUnixListenerStream {
return Poll::Ready(Some(Ok((stream, PeerInfo::unknown()))));
}
},
Err(_) => {
return Poll::Ready(Some(Ok((stream, PeerInfo::unknown()))));
}
Err(_) => return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))),
};
let peer_info = peerinfo::gather::get_peer_info(pid as u32);
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.
* 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,
pid: u32,
process_name: String,
is_forwarding: Arc<AtomicBool>,
}
impl PeerInfo {
@ -15,6 +18,16 @@ impl PeerInfo {
uid,
pid,
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
}
pub fn unknown() -> Self {
Self::new(0, 0, "Unknown application".to_string())
pub fn is_forwarding(&self) -> bool {
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
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 isRunning(agentState: SshAgentState): boolean
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]
pub async fn serve(
callback: ThreadsafeFunction<(Option<String>, bool, String), CalleeHandled>,
callback: ThreadsafeFunction<SshUIRequest, CalleeHandled>,
) -> napi::Result<SshAgentState> {
let (auth_request_tx, mut auth_request_rx) =
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 callback = cloned_callback;
let promise_result: Result<Promise<bool>, napi::Error> = callback
.call_async(Ok((
request.cipher_id,
request.is_list,
request.process_name,
)))
.call_async(Ok(SshUIRequest {
cipher_id: request.cipher_id,
is_list: request.is_list,
process_name: request.process_name,
is_forwarding: request.is_forwarding,
namespace: request.namespace,
}))
.await;
match promise_result {
Ok(promise_result) => match promise_result.await {

View File

@ -3509,9 +3509,27 @@
"sshkeyApprovalTitle": {
"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": {
"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": {
"message": "An application"
},

View File

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

View File

@ -17,6 +17,8 @@ import { CipherFormGeneratorComponent } from "@bitwarden/vault";
export interface ApproveSshRequestParams {
cipherName: string;
applicationName: string;
isAgentForwarding: boolean;
action: string;
}
@Component({
@ -44,11 +46,26 @@ export class ApproveSshRequestComponent {
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, {
data: {
cipherName,
applicationName,
isAgentForwarding,
action: actioni18nKey,
},
});
}

View File

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

View File

@ -148,6 +148,8 @@ export class SshAgentService implements OnDestroy {
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");
}
@ -181,6 +183,8 @@ export class SshAgentService implements OnDestroy {
this.dialogService,
cipher.name,
application,
isAgentForwarding,
namespace,
);
const result = await firstValueFrom(dialogRef.closed);