1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 20:24:01 +00:00

MVP rewrite

This commit is contained in:
Bernd Schoolmann
2025-10-16 11:50:59 +02:00
parent e9cd6d2b7f
commit 4c0e7a464e
27 changed files with 2834 additions and 135 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ members = [
"macos_provider",
"napi",
"proxy",
"ssh_agent",
"windows_plugin_authenticator"
]
@@ -53,8 +54,8 @@ security-framework = "=3.5.0"
security-framework-sys = "=2.15.0"
serde = "=1.0.209"
serde_json = "=1.0.127"
sha2 = "=0.10.8"
simplelog = "=0.12.2"
sha2 = "=0.10.9"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false }
sysinfo = "=0.35.0"

View File

@@ -19,6 +19,7 @@ autotype = { path = "../autotype" }
base64 = { workspace = true }
bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" }
desktop_core = { path = "../core" }
ssh_agent = { path = "../ssh_agent" }
hex = { workspace = true }
napi = { workspace = true, features = ["async"] }
napi-derive = { workspace = true }

View File

@@ -236,3 +236,29 @@ export declare namespace autotype {
export function getForegroundWindowTitle(): string
export function typeInput(input: Array<number>, keyboardShortcut: Array<string>): void
}
export declare namespace sshagent_v2 {
export interface PrivateKey {
privateKey: string
name: string
cipherId: string
}
export interface SshKey {
privateKey: string
publicKey: string
keyFingerprint: string
}
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
export function lock(agentState: SshAgentState): void
export function clearKeys(agentState: SshAgentState): void
export class SshAgentState { }
}

View File

@@ -1053,3 +1053,207 @@ pub mod autotype {
})
}
}
#[napi]
pub mod sshagent_v2 {
use std::sync::Arc;
use napi::{
bindgen_prelude::Promise,
threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction},
};
use ssh_agent::agent::ui_requester;
use ssh_agent::{
self,
agent::{ui_requester::UiRequestMessage, BitwardenDesktopAgent},
memory::UnlockedSshItem,
protocol::types::KeyPair,
transport::unix_listener_stream::UnixListenerStream,
};
use tokio::{self, sync::Mutex};
use tracing::{error, info};
#[napi]
pub struct SshAgentState {
agent: BitwardenDesktopAgent,
}
#[napi(object)]
pub struct PrivateKey {
pub private_key: String,
pub name: String,
pub cipher_id: String,
}
#[napi(object)]
pub struct SshKey {
pub private_key: String,
pub public_key: String,
pub key_fingerprint: String,
}
#[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>,
}
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn serve(
callback: ThreadsafeFunction<SshUIRequest, CalleeHandled>,
) -> napi::Result<SshAgentState> {
let (auth_request_tx, mut auth_request_rx) =
tokio::sync::mpsc::channel::<ssh_agent::agent::ui_requester::UiRequestMessage>(32);
let (auth_response_tx, auth_response_rx) =
tokio::sync::broadcast::channel::<(u32, bool)>(32);
let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
let ui_requester =
ui_requester::UiRequester::new(auth_request_tx, Arc::new(Mutex::new(auth_response_rx)));
tokio::spawn(async move {
let _ = ui_requester;
while let Some(request) = auth_request_rx.recv().await {
let cloned_response_tx_arc = auth_response_tx_arc.clone();
let cloned_callback = callback.clone();
tokio::spawn(async move {
let auth_response_tx_arc = cloned_response_tx_arc;
let callback = cloned_callback;
let js_request = match request.clone() {
UiRequestMessage::ListRequest {
request_id: _,
connection_info,
} => SshUIRequest {
cipher_id: None,
is_list: true,
process_name: "".to_string(),
is_forwarding: connection_info.is_forwarding(),
namespace: None,
},
UiRequestMessage::AuthRequest {
request_id,
connection_info,
cipher_id,
} => SshUIRequest {
cipher_id: Some(cipher_id),
is_list: false,
process_name: "".to_string(),
is_forwarding: connection_info.is_forwarding(),
namespace: None,
},
UiRequestMessage::SignRequest {
request_id,
connection_info,
cipher_id,
namespace,
} => SshUIRequest {
cipher_id: Some(cipher_id),
is_list: false,
process_name: "".to_string(),
is_forwarding: connection_info.is_forwarding(),
namespace: Some(namespace),
},
};
let promise_result: Result<Promise<bool>, napi::Error> =
callback.call_async(Ok(js_request)).await;
match promise_result {
Ok(promise_result) => match promise_result.await {
Ok(result) => {
let _ = auth_response_tx_arc
.lock()
.await
.send((request.id(), result))
.expect("should be able to send auth response to agent");
}
Err(e) => {
error!(error = %e, "Calling UI callback promise was rejected");
let _ = auth_response_tx_arc
.lock()
.await
.send((request.id(), false))
.expect("should be able to send auth response to agent");
}
},
Err(e) => {
error!(error = %e, "Calling UI callback could not create promise");
let _ = auth_response_tx_arc
.lock()
.await
.send((request.id(), false))
.expect("should be able to send auth response to agent");
}
}
});
}
});
let agent = BitwardenDesktopAgent::new(ui_requester);
let agent_copy = agent.clone();
tokio::spawn(async move {
UnixListenerStream::listen("/home/quexten/.ssh-sock".to_string(), agent_copy)
.await
.unwrap();
});
Ok(SshAgentState { agent: agent })
}
#[napi]
pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> {
agent_state.agent.stop();
Ok(())
}
#[napi]
pub fn is_running(agent_state: &mut SshAgentState) -> bool {
// let bitwarden_agent_state = agent_state.state.clone();
// bitwarden_agent_state.is_running()
true
}
#[napi]
pub fn set_keys(
agent_state: &mut SshAgentState,
new_keys: Vec<PrivateKey>,
) -> napi::Result<()> {
agent_state.agent.set_keys(
new_keys
.iter()
.filter_map(|k| {
let private_key =
ssh_agent::protocol::types::PrivateKey::try_from(k.private_key.clone())
.ok()?;
Some(UnlockedSshItem::new(
KeyPair::new(private_key, k.name.clone()),
k.cipher_id.clone(),
))
})
.collect(),
);
Ok(())
}
#[napi]
pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> {
// let bitwarden_agent_state = &mut agent_state.state;
// bitwarden_agent_state
// .lock()
// .map_err(|e| napi::Error::from_reason(e.to_string()))
Ok(())
}
#[napi]
pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
// let bitwarden_agent_state = &mut agent_state.state;
// bitwarden_agent_state
// .clear_keys()
// .map_err(|e| napi::Error::from_reason(e.to_string()))
Ok(())
}
}

View File

@@ -0,0 +1,54 @@
[package]
name = "ssh_agent"
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[features]
default = [
"dep:windows",
]
manual_test = []
[dependencies]
anyhow = { workspace = true }
base64 = { workspace = true }
bytes = { workspace = true }
byteorder = { workspace = true }
block-padding = { version = "=0.4.0-rc.4" }
ed25519 = { workspace = true, features = ["pkcs8"] }
ed25519-dalek = { version = "2.2.0", features = ["rand_core"] }
futures = { workspace = true }
inout = { version = "=0.2.0-rc.6" }
homedir = { workspace = true }
log = { workspace = true }
rsa = { version = "=0.10.0-rc.8", features = ["sha2"] }
sha2 = "0.10.9"
ssh-encoding = "=0.3.0-rc.2"
ssh-key = { version = "=0.7.0-rc.3", features = [
"encryption",
"ed25519",
"rsa",
"ecdsa",
"getrandom",
] }
num_enum = "0.7.4"
signature = "3.0.0-rc.4"
sysinfo = { workspace = true, features = ["windows"] }
tracing-subscriber.workspace = true
tracing = { workspace = true }
p256 = "0.13.2"
p384 = "0.13.1"
p521 = "0.13.3"
tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] }
tokio-util = { workspace = true, features = ["codec"] }
[target.'cfg(windows)'.dependencies]
windows = { workspace = true, features = [
"Win32_System_Pipes",
], optional = true }
windows-future = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,102 @@
use std::sync::{Arc, Mutex};
use futures::Stream;
use tokio_util::sync::CancellationToken;
use crate::{
agent::ui_requester::{self, UiRequester},
memory::UnlockedSshItem,
protocol::{
self,
key_store::Agent,
protocol::serve_listener,
types::{KeyPair, PublicKeyWithName},
},
transport::peer_info::PeerInfo,
};
#[derive(Clone)]
pub struct BitwardenDesktopAgent {
cancellation_token: CancellationToken,
key_store: Arc<Mutex<crate::memory::KeyStore>>,
ui_requester: UiRequester,
}
impl BitwardenDesktopAgent {
pub fn new(ui_requester: UiRequester) -> Self {
Self {
cancellation_token: CancellationToken::new(),
key_store: Arc::new(Mutex::new(crate::memory::KeyStore::new())),
ui_requester,
}
}
pub async fn serve<S, L>(&self, listener: L)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Sync + Unpin + 'static,
L: Stream<Item = tokio::io::Result<(S, PeerInfo)>> + Unpin,
{
serve_listener(listener, self.cancellation_token.clone(), self)
.await
.unwrap();
}
pub fn stop(&self) {
self.cancellation_token.cancel();
}
pub fn set_keys(&self, keys: Vec<UnlockedSshItem>) {
self.key_store
.lock()
.expect("Failed to lock key store")
.set_unlocked(keys);
}
}
impl Agent for &BitwardenDesktopAgent {
async fn list_keys(&self) -> Result<Vec<PublicKeyWithName>, anyhow::Error> {
Ok(self
.key_store
.lock()
.expect("Failed to lock key store")
.list_keys())
}
async fn find_ssh_item(
&self,
public_key: &protocol::types::PublicKey,
) -> Result<Option<UnlockedSshItem>, anyhow::Error> {
Ok(self
.key_store
.lock()
.expect("Failed to lock key store")
.get_unlocked_keypair(public_key))
}
async fn request_can_list(
&self,
connection_info: &protocol::connection::ConnectionInfo,
) -> Result<bool, anyhow::Error> {
Ok(self.ui_requester.request_list(connection_info).await)
}
async fn request_can_sign(
&self,
public_key: &protocol::types::PublicKey,
connection_info: &protocol::connection::ConnectionInfo,
) -> Result<bool, anyhow::Error> {
let id = self
.key_store
.lock()
.expect("Failed to lock key store")
.get_cipher_id(public_key);
if let Some(cipher_id) = id {
return Ok(self
.ui_requester
.request_sign(connection_info, cipher_id, "unknown".to_string())
.await);
} else {
return Ok(false);
}
}
}

View File

@@ -0,0 +1,3 @@
pub mod agent;
pub mod ui_requester;
pub use agent::BitwardenDesktopAgent;

View File

@@ -0,0 +1,66 @@
use tracing::info;
use homedir::my_home;
use crate::{agent::{self, BitwardenDesktopAgent}, transport::unix_listener_stream::UnixListenerStream};
struct PlatformListener {
}
impl PlatformListener {
pub fn spawn_listeners(agent: BitwardenDesktopAgent) {
#[cfg(target_os = "linux")]
{
Self::spawn_linux_listeners(agent);
}
#[cfg(target_os = "macos")]
{
Self::spawn_macos_listeners(agent);
}
}
fn spawn_linux_listeners(agent: BitwardenDesktopAgent) {
let ssh_agent_directory = match my_home() {
Ok(Some(home)) => home,
_ => {
info!("Could not determine home directory");
return;
}
};
let is_flatpak = std::env::var("container") == Ok("flatpak".to_string());
let path = if !is_flatpak {
ssh_agent_directory
.join(".bitwarden-ssh-agent.sock")
.to_str()
.expect("Path should be valid")
.to_owned()
} else {
ssh_agent_directory
.join(".var/app/com.bitwarden.desktop/data/.bitwarden-ssh-agent.sock")
.to_str()
.expect("Path should be valid")
.to_owned()
};
tokio::spawn(UnixListenerStream::listen(path, agent));
}
fn spawn_macos_listeners(agent: BitwardenDesktopAgent) {
let ssh_agent_directory = match my_home() {
Ok(Some(home)) => home,
_ => {
info!("Could not determine home directory");
return;
}
};
let path = ssh_agent_directory
.join(".bitwarden-ssh-agent.sock")
.to_str()
.expect("Path should be valid")
.to_owned();
tokio::spawn(UnixListenerStream::listen(path, agent));
}
}

View File

@@ -0,0 +1,100 @@
use std::sync::{atomic::AtomicU32, Arc};
use tokio::sync::Mutex;
use crate::protocol::connection::ConnectionInfo;
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[derive(Clone)]
pub struct UiRequester {
show_ui_request_tx: tokio::sync::mpsc::Sender<UiRequestMessage>,
get_ui_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
}
const REQUEST_ID_COUNTER: AtomicU32 = AtomicU32::new(0);
#[derive(Clone, Debug)]
pub enum UiRequestMessage {
ListRequest {
request_id: u32,
connection_info: ConnectionInfo,
},
AuthRequest {
request_id: u32,
connection_info: ConnectionInfo,
cipher_id: String,
},
SignRequest {
request_id: u32,
connection_info: ConnectionInfo,
cipher_id: String,
namespace: String,
},
}
impl UiRequestMessage {
pub fn id(&self) -> u32 {
match self {
UiRequestMessage::ListRequest { request_id, .. } => *request_id,
UiRequestMessage::AuthRequest { request_id, .. } => *request_id,
UiRequestMessage::SignRequest { request_id, .. } => *request_id,
}
}
}
impl UiRequester {
pub fn new(
show_ui_request_tx: tokio::sync::mpsc::Sender<UiRequestMessage>,
get_ui_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
) -> Self {
Self {
show_ui_request_tx,
get_ui_response_rx,
}
}
pub async fn request_list(&self, connection_info: &ConnectionInfo) -> bool {
let request_id = REQUEST_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.request(UiRequestMessage::ListRequest {
request_id,
connection_info: connection_info.clone(),
})
.await
}
pub async fn request_sign(
&self,
connection_info: &ConnectionInfo,
cipher_id: String,
namespace: String,
) -> bool {
let request_id = REQUEST_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.request(UiRequestMessage::SignRequest {
request_id,
connection_info: connection_info.clone(),
cipher_id,
namespace,
})
.await
}
async fn request(&self, request: UiRequestMessage) -> bool {
let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe();
self.show_ui_request_tx
.send(request.clone())
.await
.expect("Should send request to ui");
tokio::time::timeout(TIMEOUT, async move {
while let Ok((id, response)) = rx_channel.recv().await {
if id == request.id() {
return response;
}
}
false
})
.await
.unwrap_or(false)
}
}

View File

@@ -0,0 +1,4 @@
pub mod agent;
pub mod memory;
pub mod protocol;
pub mod transport;

View File

@@ -0,0 +1,119 @@
//! This module implements memory storage for the SSH agent. The current
//! implementation caches the keys in memory, and ideally uses platform secure memory APIs.
use crate::protocol::types::{KeyPair, PublicKeyWithName};
struct LockedSshItem {
public_key: PublicKeyWithName,
cipher_id: String,
}
#[derive(Clone)]
pub struct UnlockedSshItem {
pub(crate) key_pair: KeyPair,
cipher_id: String,
}
impl UnlockedSshItem {
pub fn new(key_pair: KeyPair, cipher_id: String) -> Self {
Self {
key_pair,
cipher_id,
}
}
}
struct LockedKeyStore {
keys: Vec<LockedSshItem>,
}
struct UnlockedKeyStore {
keys: Vec<UnlockedSshItem>,
}
pub(crate) enum KeyStore {
Locked(LockedKeyStore),
Unlocked(UnlockedKeyStore),
}
impl KeyStore {
pub fn new() -> Self {
KeyStore::Locked(LockedKeyStore { keys: vec![] })
}
pub fn lock(&mut self) {
if let KeyStore::Unlocked(unlocked) = self {
let keys = unlocked
.keys
.iter()
.map(|kp| LockedSshItem {
public_key: PublicKeyWithName::new(
kp.key_pair.public_key().clone(),
kp.key_pair.name().to_string(),
),
cipher_id: kp.cipher_id.clone(),
})
.collect();
*self = KeyStore::Locked(LockedKeyStore { keys });
}
}
pub fn set_unlocked(&mut self, keys: Vec<UnlockedSshItem>) {
*self = KeyStore::Unlocked(UnlockedKeyStore { keys });
}
pub fn list_keys(&self) -> Vec<PublicKeyWithName> {
match self {
KeyStore::Locked(locked) => locked
.keys
.iter()
.map(|item| item.public_key.clone())
.collect(),
KeyStore::Unlocked(unlocked) => unlocked
.keys
.iter()
.map(|item| {
PublicKeyWithName::new(
item.key_pair.public_key().clone(),
item.key_pair.name().to_string(),
)
})
.collect(),
}
}
pub fn get_unlocked_keypair(
&self,
public_key: &crate::protocol::types::PublicKey,
) -> Option<UnlockedSshItem> {
if let KeyStore::Unlocked(unlocked) = self {
for item in &unlocked.keys {
if *item.key_pair.public_key() == *public_key {
return Some(item.clone());
}
}
}
None
}
pub fn get_cipher_id(&self, public_key: &crate::protocol::types::PublicKey) -> Option<String> {
if let KeyStore::Locked(locked) = self {
for item in &locked.keys {
if item.public_key.key == *public_key {
return Some(item.cipher_id.clone());
}
}
} else if let KeyStore::Unlocked(unlocked) = self {
for item in &unlocked.keys {
if *item.key_pair.public_key() == *public_key {
return Some(item.cipher_id.clone());
}
}
}
None
}
pub fn is_locked(&self) -> bool {
matches!(self, KeyStore::Locked(_))
}
}

View File

@@ -0,0 +1,45 @@
use ssh_encoding::Decode;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::protocol::replies::ReplyFrame;
pub(crate) struct AsyncStreamWrapper<PeerStream>
where
PeerStream: AsyncRead + AsyncWrite + Send + Sync + Unpin,
{
stream: PeerStream,
}
impl<PeerStream> AsyncStreamWrapper<PeerStream>
where
PeerStream: AsyncRead + AsyncWrite + Send + Sync + Unpin,
{
pub fn new(stream: PeerStream) -> Self {
Self { stream }
}
pub async fn read_u32(&mut self) -> Result<u32, anyhow::Error> {
let mut buf = [0u8; 4];
self.stream.read_exact(&mut buf).await?;
u32::decode(&mut buf.as_slice()).map_err(|e| anyhow::anyhow!("Failed to decode u32: {}", e))
}
pub async fn read_vec(&mut self, len: usize) -> Result<Vec<u8>, anyhow::Error> {
let mut buf = vec![0u8; len];
self.stream.read_exact(&mut buf).await?;
Ok(buf)
}
pub async fn read_message(&mut self) -> Result<Vec<u8>, anyhow::Error> {
// An SSH agent message consists of a 32 bit integer denoting the length, followed by that many bytes
let length = self.read_u32().await? as usize;
self.read_vec(length).await
}
pub async fn write_reply(&mut self, data: &ReplyFrame) -> Result<(), anyhow::Error> {
let raw_frame: Vec<u8> = data.into();
self.stream.write_u32(raw_frame.len() as u32).await?;
self.stream.write_all(&raw_frame).await?;
Ok(())
}
}

View File

@@ -0,0 +1,64 @@
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use crate::{
protocol::types::{PublicKey, SessionId},
transport::peer_info::PeerInfo,
};
// The connection id is global and increasing throughout the lifetime of the desktop app
static CONNECTION_COUNTER: AtomicU32 = AtomicU32::new(0);
#[derive(Clone, Debug)]
pub struct ConnectionInfo {
id: u32,
peer_info: PeerInfo,
is_forwarding: bool,
host_key: Option<PublicKey>,
session_identifier: Option<SessionId>,
}
impl ConnectionInfo {
pub fn new(peer_info: PeerInfo) -> Self {
let id = CONNECTION_COUNTER.fetch_add(1, Ordering::SeqCst);
Self {
id,
peer_info,
is_forwarding: false,
host_key: None,
session_identifier: None,
}
}
pub fn id(&self) -> u32 {
self.id
}
pub fn peer_info(&self) -> &PeerInfo {
&self.peer_info
}
pub fn is_forwarding(&self) -> bool {
self.is_forwarding
}
pub fn set_forwarding(&mut self) {
self.is_forwarding = true;
}
pub fn host_key(&self) -> Option<&PublicKey> {
self.host_key.as_ref()
}
pub fn set_host_key(&mut self, host_key: PublicKey) {
self.host_key = Some(host_key);
}
pub fn session_identifier(&self) -> Option<&SessionId> {
self.session_identifier.as_ref()
}
pub fn set_session_identifier(&mut self, session_id: SessionId) {
self.session_identifier = Some(session_id);
}
}

View File

@@ -0,0 +1,33 @@
use crate::{
memory::UnlockedSshItem,
protocol::{
connection::ConnectionInfo,
types::{KeyPair, PublicKey, PublicKeyWithName},
},
};
pub(crate) trait Agent: Send + Sync {
async fn request_can_list(
&self,
connection_info: &ConnectionInfo,
) -> Result<bool, anyhow::Error>;
async fn list_keys(&self) -> Result<Vec<PublicKeyWithName>, anyhow::Error>;
async fn request_can_sign(
&self,
public_key: &PublicKey,
connection_info: &ConnectionInfo,
) -> Result<bool, anyhow::Error>;
async fn find_ssh_item(
&self,
public_key: &PublicKey,
) -> Result<Option<UnlockedSshItem>, anyhow::Error>;
}
#[cfg(test)]
const PRIVATE_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBDUDO7ChZIednIJxGA95T/ZTyREftahrFEJM/eeC8mmAAAAKByJoOYciaD
mAAAAAtzc2gtZWQyNTUxOQAAACBDUDO7ChZIednIJxGA95T/ZTyREftahrFEJM/eeC8mmA
AAAEBQK5JpycFzP/4rchfpZhbdwxjTwHNuGx2/kvG4i6xfp0NQM7sKFkh52cgnEYD3lP9l
PJER+1qGsUQkz954LyaYAAAAHHF1ZXh0ZW5ATWFjQm9vay1Qcm8tMTYubG9jYWwB
-----END OPENSSH PRIVATE KEY-----";

View File

@@ -0,0 +1,8 @@
//! An implementation of ssh agent
pub(crate) mod async_stream_wrapper;
pub(crate) mod connection;
pub(crate) mod key_store;
pub(crate) mod protocol;
pub(crate) mod replies;
pub(crate) mod requests;
pub mod types;

View File

@@ -0,0 +1,121 @@
use futures::{Stream, StreamExt};
use tokio::{
io::{AsyncRead, AsyncWrite},
select,
};
use tokio_util::sync::CancellationToken;
use tracing::{error, info};
use crate::{
protocol::{
async_stream_wrapper::AsyncStreamWrapper,
connection::ConnectionInfo,
key_store::Agent,
replies::{AgentFailure, IdentitiesReply, SshSignReply},
requests::Request,
},
transport::peer_info::PeerInfo,
};
pub async fn serve_listener<PeerStream, Listener>(
mut listener: Listener,
cancellation_token: CancellationToken,
agent: impl Agent,
) -> Result<(), anyhow::Error>
where
PeerStream: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static,
Listener: Stream<Item = tokio::io::Result<(PeerStream, PeerInfo)>> + Unpin,
{
loop {
select! {
_ = cancellation_token.cancelled() => {
break;
}
Some(Ok((stream, peer_info))) = listener.next() => {
let mut stream = AsyncStreamWrapper::new(stream);
let connection_info = ConnectionInfo::new(peer_info);
info!("Accepted connection {} from {:?}", connection_info.id(), connection_info.peer_info());
if let Err(e) = handle_connection(&agent, &mut stream, &connection_info).await {
error!("Error handling request: {e}");
}
}
}
}
Ok(())
}
async fn handle_connection(
agent: &impl Agent,
stream: &mut AsyncStreamWrapper<impl AsyncRead + AsyncWrite + Send + Sync + Unpin>,
connection: &ConnectionInfo,
) -> Result<(), anyhow::Error> {
loop {
let span = tracing::info_span!("Connection", connection_id = connection.id());
span.in_scope(|| info!("Waiting for request"));
let request = match stream.read_message().await {
Ok(request) => request,
Err(_) => {
span.in_scope(|| info!("Connection closed"));
break;
}
};
span.in_scope(|| info!("Request {:x?}", request));
let Ok(request) = Request::try_from(request.as_slice()) else {
span.in_scope(|| error!("Failed to parse request"));
stream.write_reply(&AgentFailure::new().into()).await?;
continue;
};
let response = match request {
Request::IdentitiesRequest => {
span.in_scope(|| info!("Received IdentitiesRequest"));
let Ok(true) = agent.request_can_list(connection).await else {
span.in_scope(|| error!("List keys request denied by UI"));
return stream.write_reply(&AgentFailure::new().into()).await;
};
IdentitiesReply::new(agent.list_keys().await?)
.encode()
.map_err(|e| anyhow::anyhow!("Failed to encode identities reply: {e}"))
}
Request::SignRequest(sign_request) => {
span.in_scope(|| info!("Received SignRequest {:?}", sign_request));
let Ok(true) = agent
.request_can_sign(sign_request.public_key(), connection)
.await
else {
span.in_scope(|| error!("Sign request denied by UI"));
return stream.write_reply(&AgentFailure::new().into()).await;
};
let ssh_item = agent
.find_ssh_item(sign_request.public_key())
.await
.ok()
.flatten();
if let Some(ssh_item) = ssh_item {
SshSignReply::new(
&ssh_item.key_pair.private_key(),
&sign_request.payload_to_sign(),
sign_request.signing_scheme(),
)
.encode()
} else {
Ok(AgentFailure::new().into())
}
.map_err(|e| anyhow::anyhow!("Failed to create sign reply: {e}"))
}
}?;
let encoded: Vec<u8> = (&response).into();
span.in_scope(|| info!("Sending response"));
span.in_scope(|| info!("Response {:x?}", encoded));
stream.write_reply(&response).await?;
}
Ok(())
}

View File

@@ -0,0 +1,131 @@
use num_enum::{IntoPrimitive, TryFromPrimitive};
use ssh_encoding::Encode;
use crate::protocol::types::{
KeyPair, PrivateKey, PublicKey, PublicKeyWithName, RsaSigningScheme, Signature,
};
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-protocol-messages`
/// The different types of replies that the SSH agent can send to a client.
#[allow(non_camel_case_types)]
#[derive(Debug, Eq, PartialEq, TryFromPrimitive, IntoPrimitive, Default)]
#[repr(u8)]
pub enum ReplyType {
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-generic-server-responses`
/// Generic response indicating failure
/// Unsupported extensions must be replied to with SSH_AGENT_FAILURE.
SSH_AGENT_FAILURE = 5,
/// Generic response indicating success
SSH_AGENT_SUCCESS = 6,
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-extension-mechanism`
/// Failure within an extension are replied to with this message.
SSH_AGENT_EXTENSION_FAILURE = 28,
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-requesting-a-list-of-keys`
/// Response to `RequestType::SSH_AGENTC_REQUEST_IDENTITIES`
SSH_AGENT_IDENTITIES_ANSWER = 12,
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-private-key-operations``
/// Response to `RequestType::SSH_AGENTC_SIGN_REQUEST`
SSH_AGENT_SIGN_RESPONSE = 14,
/// Invalid reply type
#[default]
SSH_AGENT_INVALID = 0,
}
/// A reply is structured as a single byte indicating the type, followed by a
/// payload that is structured according to the type.
pub struct ReplyFrame {
/// The serialized frame structured as
/// reply_type|payload
raw_frame: Vec<u8>,
}
impl ReplyFrame {
pub fn new(reply: ReplyType, payload: Vec<u8>) -> Self {
let mut raw_frame = Vec::new();
Into::<u8>::into(reply)
.encode(&mut raw_frame)
.expect("Encoding into Vec cannot fail");
raw_frame.extend_from_slice(&payload);
Self { raw_frame }
}
}
impl Into<Vec<u8>> for &ReplyFrame {
fn into(self) -> Vec<u8> {
self.raw_frame.clone()
}
}
pub(crate) struct IdentitiesReply {
keys: Vec<PublicKeyWithName>,
}
impl IdentitiesReply {
pub fn new(keys: Vec<PublicKeyWithName>) -> Self {
Self { keys }
}
/// https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-requesting-a-list-of-keys
/// The reply to a request is structured as:
///
/// byte SSH_AGENT_IDENTITIES_ANSWER
/// uint32 nkeys
/// [
/// string public key blob
/// string comment (a utf-8 string)
/// ... (nkeys times)
/// ]
pub fn encode(&self) -> Result<ReplyFrame, ssh_encoding::Error> {
Ok(ReplyFrame::new(ReplyType::SSH_AGENT_IDENTITIES_ANSWER, {
let mut reply_message = Vec::new();
(self.keys.len() as u32).encode(&mut reply_message)?;
for key in &self.keys {
key.key.encode(&mut reply_message)?;
key.name.encode(&mut reply_message)?;
}
reply_message
}))
}
}
pub(crate) struct SshSignReply(Signature);
impl SshSignReply {
pub fn new(
private_key: &PrivateKey,
data: &[u8],
requested_signing_scheme: Option<RsaSigningScheme>,
) -> Self {
Self(
// Note, this should take into account the extension / signing scheme.
private_key.sign(data, requested_signing_scheme).unwrap(),
)
}
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-private-key-operations`
/// A reply to a sign request is structured as:
///
/// byte SSH_AGENT_SIGN_RESPONSE
/// string signature blob
pub fn encode(&self) -> Result<ReplyFrame, ssh_encoding::Error> {
Ok(ReplyFrame::new(ReplyType::SSH_AGENT_SIGN_RESPONSE, {
let mut reply_payload = Vec::new();
self.0.encode().unwrap().encode(&mut reply_payload)?;
reply_payload
}))
}
}
pub(crate) struct AgentFailure;
impl AgentFailure {
pub fn new() -> Self {
Self {}
}
}
impl From<AgentFailure> for ReplyFrame {
fn from(_value: AgentFailure) -> Self {
ReplyFrame::new(ReplyType::SSH_AGENT_EXTENSION_FAILURE, Vec::new())
}
}

View File

@@ -0,0 +1,334 @@
//! This file contains parsing logic for requests sent to the SSH agent.
//! Parsers must include test vectors recorded from real SSH operations.
use byteorder::ReadBytesExt;
use bytes::{Buf, Bytes};
use log::info;
use num_enum::{IntoPrimitive, TryFromPrimitive};
use crate::protocol::types::{PublicKey, RsaSigningScheme, SessionId, Signature};
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-protocol-messages`
/// The different types of requests that a client can send to the SSH agent.
#[allow(non_camel_case_types)]
#[derive(Debug, Eq, PartialEq, TryFromPrimitive, IntoPrimitive, Default)]
#[repr(u8)]
pub(crate) enum RequestType {
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-requesting-a-list-of-keys`
/// Request the list of keys the agent is holding
SSH_AGENTC_REQUEST_IDENTITIES = 11,
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-private-key-operations`
/// Sign an authentication request or SSHSIG request
SSH_AGENTC_SIGN_REQUEST = 13,
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-extension-mechanism`
/// Handle vendor specific extensions such as session binding
SSH_AGENTC_EXTENSION = 27,
/// An invalid request
#[default]
SSH_AGENTC_INVALID = 0,
}
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-signature-flags`
///
/// There are currently two flags defined which control which signature method
/// are used for RSA. These have no effect on other key types. If neither of these is defined,
/// RSA is used with SHA1, however this is deprecated and should not be used.
#[allow(non_camel_case_types)]
#[derive(Debug, Eq, PartialEq, TryFromPrimitive, IntoPrimitive)]
#[repr(u8)]
pub(crate) enum SshSignFlags {
/// Sign with SHA256 if RSA is used
SSH_AGENT_RSA_SHA2_256 = 2,
/// Sign with SHA512 if RSA is used
SSH_AGENT_RSA_SHA2_512 = 4,
}
#[derive(Debug)]
pub(crate) enum Request {
/// Request the list of keys the agent is holding
IdentitiesRequest,
/// Sign an authentication request or SSHSIG request
SignRequest(SshSignRequest),
}
impl TryFrom<&[u8]> for Request {
type Error = anyhow::Error;
// A protocol message consists of
//
// uint32 length
// byte type
// byte[length-1] contents
//
// The length is already stripped of in the `async_stream_wrapper::read_message`, so
// the message is just type|contents.
fn try_from(message: &[u8]) -> Result<Self, Self::Error> {
if message.is_empty() {
return Err(anyhow::anyhow!("Empty request"));
}
let r#type = RequestType::try_from_primitive(message[0])?;
let contents = message[1..].to_vec();
match r#type {
RequestType::SSH_AGENTC_REQUEST_IDENTITIES => Ok(Request::IdentitiesRequest),
RequestType::SSH_AGENTC_SIGN_REQUEST => {
Ok(Request::SignRequest(contents.as_slice().try_into()?))
}
RequestType::SSH_AGENTC_EXTENSION => {
// Only support session bind for now
let _extension_request: SessionBindRequest = contents.as_slice().try_into()?;
info!("Received extension request: {:?}", _extension_request);
Err(anyhow::anyhow!("Unsupported extension request"))
}
_ => Err(anyhow::anyhow!("Unsupported request type: {:?}", r#type)),
}
}
}
/// A sign request requests the agent to sign a blob of data with a specific key. The key is
/// referenced by its public key blob. The payload usually has a specific structure for auth
/// requests or SSHSIG requests. There are also flags supported that control signing behavior.
#[derive(Debug)]
pub(crate) struct SshSignRequest {
public_key: PublicKey,
payload_to_sign: Vec<u8>,
parsed_sign_request: ParsedSignRequest,
flags: u32,
}
impl SshSignRequest {
pub fn is_flag_set(&self, flag: SshSignFlags) -> bool {
(self.flags & (flag as u32)) != 0
}
pub fn signing_scheme(&self) -> Option<RsaSigningScheme> {
if self.is_flag_set(SshSignFlags::SSH_AGENT_RSA_SHA2_256) {
Some(RsaSigningScheme::Pkcs1v15Sha256)
} else if self.is_flag_set(SshSignFlags::SSH_AGENT_RSA_SHA2_512) {
Some(RsaSigningScheme::Pkcs1v15Sha512)
} else {
None
}
}
pub fn public_key(&self) -> &PublicKey {
&self.public_key
}
pub fn payload_to_sign(&self) -> &[u8] {
&self.payload_to_sign
}
pub fn parsed_payload(&self) -> &ParsedSignRequest {
&self.parsed_sign_request
}
}
impl TryFrom<&[u8]> for SshSignRequest {
type Error = anyhow::Error;
/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-private-key-operations`
/// A private key operation is structured as follows:
///
/// byte SSH_AGENTC_SIGN_REQUEST
/// string key blob
/// string data
/// uint32 flags
///
/// In this case, the message already has the leading byte stripped off by the previous parsing code.
fn try_from(mut message: &[u8]) -> Result<Self, Self::Error> {
let public_key_blob = read_bytes(&mut message)?.to_vec();
let data = read_bytes(&mut message)?;
let flags = message
.read_u32::<byteorder::BigEndian>()
.map_err(|e| anyhow::anyhow!("Failed to read flags from sign request: {e}"))?;
Ok(SshSignRequest {
public_key: public_key_blob.try_into()?,
payload_to_sign: data.clone(),
parsed_sign_request: data.as_slice().try_into()?,
flags,
})
}
}
#[derive(Debug)]
pub(crate) enum ParsedSignRequest {
SshSigRequest { namespace: String },
SignRequest {},
}
impl<'a> TryFrom<&'a [u8]> for ParsedSignRequest {
type Error = anyhow::Error;
fn try_from(data: &'a [u8]) -> Result<Self, Self::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(ParsedSignRequest::SshSigRequest { namespace })
} else {
Ok(ParsedSignRequest::SignRequest {})
}
}
}
fn read_bool(data: &mut &[u8]) -> Result<bool, anyhow::Error> {
let byte = data
.read_u8()
.map_err(|e| anyhow::anyhow!("Failed to read bool: {e}"))?;
match byte {
0 => Ok(false),
1 => Ok(true),
_ => Err(anyhow::anyhow!("Invalid boolean value")),
}
}
/// A helper function to read a length prefixed byte array
pub(super) fn read_bytes(data: &mut &[u8]) -> Result<Vec<u8>, anyhow::Error> {
let length = data
.read_u32::<byteorder::BigEndian>()
.map_err(|e| anyhow::anyhow!("Failed to read length: {e}"))?;
let mut buf = vec![
0;
length
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid length"))?
];
std::io::Read::read_exact(data, &mut buf)
.map_err(|e| anyhow::anyhow!("Failed to read exact bytes: {e}"))?;
Ok(buf)
}
enum Extension {
SessionBind,
Unsupported,
}
impl From<String> for Extension {
fn from(value: String) -> Self {
match value.as_str() {
"session-bind@openssh.com" => Extension::SessionBind,
_ => Extension::Unsupported,
}
}
}
/// https://www.openssh.com/agent-restrict.html
/// byte SSH_AGENTC_EXTENSION (0x1b)
/// string session-bind@openssh.com
/// string hostkey
/// string session identifier
/// string signature
/// bool is_forwarding
#[derive(Debug)]
struct SessionBindRequest {
host_key: PublicKey,
session_id: SessionId,
signature: Signature,
is_forwarding: bool,
}
impl TryFrom<&[u8]> for SessionBindRequest {
type Error = anyhow::Error;
fn try_from(mut message: &[u8]) -> Result<Self, Self::Error> {
let extension_name = String::from_utf8(read_bytes(&mut message)?)
.map_err(|_| anyhow::anyhow!("Invalid extension name"))?;
match Extension::from(extension_name) {
Extension::SessionBind => {
let host_key = read_bytes(&mut message)?.try_into()?;
let session_id = read_bytes(&mut message)?;
let signature = read_bytes(&mut message)?;
let is_forwarding = read_bool(&mut message)?;
Ok(SessionBindRequest {
host_key,
session_id: SessionId::from(session_id),
signature: Signature::try_from(signature.as_slice())?,
is_forwarding,
})
}
Extension::Unsupported => Err(anyhow::anyhow!("Unsupported extension")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Protocol Request Messages
const TEST_VECTOR_REQUEST_LIST: &[u8] = &[11];
const TEST_VECTOR_REQUEST_SIGN: &[u8] = &[
13, 0, 0, 0, 51, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, 0, 0, 0, 32,
29, 223, 117, 159, 179, 182, 138, 116, 19, 26, 175, 28, 112, 116, 125, 161, 73, 110, 213,
155, 210, 209, 216, 151, 51, 134, 209, 95, 89, 119, 233, 120, 0, 0, 0, 146, 0, 0, 0, 32,
181, 207, 94, 63, 132, 40, 223, 192, 113, 235, 146, 168, 148, 99, 10, 232, 43, 52, 136,
115, 113, 29, 242, 9, 69, 130, 8, 140, 111, 100, 189, 9, 50, 0, 0, 0, 3, 103, 105, 116, 0,
0, 0, 14, 115, 115, 104, 45, 99, 111, 110, 110, 101, 99, 116, 105, 111, 110, 0, 0, 0, 9,
112, 117, 98, 108, 105, 99, 107, 101, 121, 1, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50,
53, 53, 49, 57, 0, 0, 0, 51, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57,
0, 0, 0, 32, 29, 223, 117, 159, 179, 182, 138, 116, 19, 26, 175, 28, 112, 116, 125, 161,
73, 110, 213, 155, 210, 209, 216, 151, 51, 134, 209, 95, 89, 119, 233, 120, 0, 0, 0, 0,
];
// Inner messages for the sign request
const TEST_VECTOR_REQUEST_SIGN_AUTHENTICATE: &[u8] = &[
0, 0, 0, 32, 181, 207, 94, 63, 132, 40, 223, 192, 113, 235, 146, 168, 148, 99, 10, 232, 43,
52, 136, 115, 113, 29, 242, 9, 69, 130, 8, 140, 111, 100, 189, 9, 50, 0, 0, 0, 3, 103, 105,
116, 0, 0, 0, 14, 115, 115, 104, 45, 99, 111, 110, 110, 101, 99, 116, 105, 111, 110, 0, 0,
0, 9, 112, 117, 98, 108, 105, 99, 107, 101, 121, 1, 0, 0, 0, 11, 115, 115, 104, 45, 101,
100, 50, 53, 53, 49, 57, 0, 0, 0, 51, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53,
49, 57, 0, 0, 0, 32, 29, 223, 117, 159, 179, 182, 138, 116, 19, 26, 175, 28, 112, 116, 125,
161, 73, 110, 213, 155, 210, 209, 216, 151, 51, 134, 209, 95, 89, 119, 233, 120,
];
const TEST_VECTOR_REQUEST_SIGN_SSHSIG_GIT: &[u8] = &[
83, 83, 72, 83, 73, 71, 0, 0, 0, 3, 103, 105, 116, 0, 0, 0, 0, 0, 0, 0, 6, 115, 104, 97,
53, 49, 50, 0, 0, 0, 64, 30, 64, 7, 140, 213, 231, 218, 138, 18, 144, 116, 7, 182, 82, 23,
205, 39, 91, 32, 189, 66, 61, 26, 22, 93, 175, 87, 211, 52, 127, 62, 223, 177, 70, 125, 65,
44, 147, 16, 177, 89, 5, 162, 230, 184, 137, 234, 155, 152, 93, 161, 105, 254, 223, 93,
178, 118, 238, 176, 38, 145, 49, 56, 92,
];
#[test]
fn test_parse_identities_request() {
let req = Request::try_from(TEST_VECTOR_REQUEST_LIST).expect("Should parse");
assert!(matches!(req, Request::IdentitiesRequest));
}
#[test]
fn test_parse_sign_request() {
let req = Request::try_from(TEST_VECTOR_REQUEST_SIGN).expect("Should parse");
assert!(matches!(req, Request::SignRequest { .. }));
}
#[test]
fn test_parse_sign_authenticate_request() {
let req = ParsedSignRequest::try_from(TEST_VECTOR_REQUEST_SIGN_AUTHENTICATE)
.expect("Should parse");
assert!(matches!(req, ParsedSignRequest::SignRequest {}));
}
#[test]
fn test_parse_sign_sshsig_git_request() {
let req =
ParsedSignRequest::try_from(TEST_VECTOR_REQUEST_SIGN_SSHSIG_GIT).expect("Should parse");
assert!(
matches!(req, ParsedSignRequest::SshSigRequest { namespace } if namespace == "git".to_string())
);
}
}

View File

@@ -0,0 +1,380 @@
use std::fmt::Debug;
use std::fmt::Formatter;
use base64::prelude::BASE64_STANDARD;
use base64::Engine as _;
use rsa::Pkcs1v15Sign;
use ssh_key::private::KeypairData;
use ssh_key::{Algorithm, EcdsaCurve, HashAlg};
use rsa::sha2::{self, Digest};
use signature::Signer;
use ssh_encoding::Encode;
use ssh_key::private::{EcdsaKeypair, Ed25519Keypair, RsaKeypair};
use crate::protocol::requests::read_bytes;
#[derive(Clone)]
pub struct PublicKeyWithName {
pub key: PublicKey,
pub name: String,
}
impl PublicKeyWithName {
pub fn new(key: PublicKey, name: String) -> Self {
Self { key, name }
}
}
/// A named SSH key pair consisting of a public and private key, and a name (comment).
#[derive(Debug, Clone)]
pub struct KeyPair {
private_key: PrivateKey,
public_key: PublicKey,
name: String,
}
impl KeyPair {
pub fn new(private_key: PrivateKey, name: String) -> Self {
KeyPair {
public_key: private_key.public_key(),
private_key,
name,
}
}
pub fn public_key(&self) -> &PublicKey {
&self.public_key
}
pub fn name(&self) -> &str {
&self.name
}
pub fn private_key(&self) -> &PrivateKey {
&self.private_key
}
}
/// A detached SSH signature, containing the signature scheme and blob.
pub struct Signature(ssh_key::Signature);
impl Signature {
pub(crate) fn encode(&self) -> Result<Vec<u8>, ssh_encoding::Error> {
let mut buffer = Vec::new();
self.0.algorithm().as_str().encode(&mut buffer)?;
self.0.as_bytes().encode(&mut buffer)?;
Ok(buffer)
}
pub(crate) fn verify(
&self,
public_key: &PublicKey,
data: &[u8],
) -> Result<bool, anyhow::Error> {
let public_key_parsed =
ssh_key::PublicKey::from_bytes(public_key.blob()).map_err(|e| anyhow::anyhow!(e))?;
match self.0.algorithm() {
Algorithm::Ed25519 => {
let verifying_key = public_key_parsed
.key_data()
.ed25519()
.ok_or(anyhow::anyhow!("Public key is not Ed25519"))?;
let signature = &ed25519_dalek::Signature::from_slice(self.0.as_bytes())?;
ed25519_dalek::VerifyingKey::from_bytes(&verifying_key.0)
.map_err(|e| anyhow::anyhow!("Failed to parse Ed25519 key: {e}"))?
.verify_strict(data, signature)?;
return Ok(true);
}
Algorithm::Rsa { hash: Some(alg) } => {
let verifying_key: Result<rsa::RsaPublicKey, _> = public_key_parsed
.key_data()
.rsa()
.ok_or(anyhow::anyhow!("Public key is not RSA"))?
.try_into();
let verifying_key =
verifying_key.map_err(|e| anyhow::anyhow!("Failed to parse RSA key: {e}"))?;
match alg {
HashAlg::Sha256 => verifying_key.verify(
Pkcs1v15Sign::new::<sha2::Sha256>(),
sha2::Sha256::digest(data).as_slice(),
self.0.as_bytes(),
),
HashAlg::Sha512 => verifying_key.verify(
Pkcs1v15Sign::new::<sha2::Sha512>(),
sha2::Sha512::digest(data).as_slice(),
self.0.as_bytes(),
),
_ => return Ok(false),
}
.map_err(|e| anyhow::anyhow!("RSA signature verification failed: {e}"))?;
return Ok(true);
}
Algorithm::Ecdsa { curve } => {
let sec1_bytes = public_key_parsed
.key_data()
.ecdsa()
.unwrap()
.as_sec1_bytes();
match curve {
EcdsaCurve::NistP256 => {
use p256::ecdsa::signature::Verifier;
p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1_bytes)?
.verify(
data,
&p256::ecdsa::Signature::from_slice(self.0.as_bytes())?,
)
.map_err(|e| {
anyhow::anyhow!("ECDSA P-256 signature verification failed: {e}")
})?;
return Ok(true);
}
EcdsaCurve::NistP384 => {
use p384::ecdsa::signature::Verifier;
p384::ecdsa::VerifyingKey::from_sec1_bytes(sec1_bytes)?
.verify(
data,
&p384::ecdsa::Signature::from_slice(self.0.as_bytes())?,
)
.map_err(|e| {
anyhow::anyhow!("ECDSA P-384 signature verification failed: {e}")
})?;
return Ok(true);
}
EcdsaCurve::NistP521 => {
use p521::ecdsa::signature::Verifier;
p521::ecdsa::VerifyingKey::from_sec1_bytes(sec1_bytes)?
.verify(
data,
&p521::ecdsa::Signature::from_slice(self.0.as_bytes())?,
)
.map_err(|e| {
anyhow::anyhow!("ECDSA P-521 signature verification failed: {e}")
})?;
return Ok(true);
}
_ => return Ok(false),
}
}
_ => return Ok(false),
}
}
}
impl Debug for Signature {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"SshSignature(\"{} {}\")",
self.0.algorithm().as_str(),
BASE64_STANDARD.encode(self.0.as_bytes())
)
}
}
impl TryFrom<&[u8]> for Signature {
type Error = anyhow::Error;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let mut buffer = bytes;
let alg = Algorithm::new(
&String::from_utf8_lossy(read_bytes(&mut buffer).unwrap().as_slice()).to_string(),
)?;
let sig = read_bytes(&mut buffer).unwrap();
Ok(Signature(ssh_key::Signature::new(alg, sig)?))
}
}
#[derive(Clone)]
pub(super) struct SessionId(Vec<u8>);
impl From<Vec<u8>> for SessionId {
fn from(v: Vec<u8>) -> Self {
SessionId(v)
}
}
impl Debug for SessionId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "SessionId(\"{}\")", BASE64_STANDARD.encode(&self.0))
}
}
#[allow(unused)]
pub enum RsaSigningScheme {
Pkcs1v15Sha512,
Pkcs1v15Sha256,
// Sha1 is not supported because it is deprecated
}
impl RsaSigningScheme {
fn to_hash_alg(&self) -> HashAlg {
match self {
RsaSigningScheme::Pkcs1v15Sha512 => HashAlg::Sha512,
RsaSigningScheme::Pkcs1v15Sha256 => HashAlg::Sha256,
}
}
}
#[derive(Clone)]
pub enum PrivateKey {
Ed25519(Ed25519Keypair),
Rsa(RsaKeypair),
Ecdsa(EcdsaKeypair),
}
impl Debug for PrivateKey {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
PrivateKey::Ed25519(_key) => write!(f, "Ed25519()"),
PrivateKey::Rsa(_key) => write!(f, "Rsa()"),
PrivateKey::Ecdsa(_key) => write!(f, "Ecdsa()"),
}
}
}
impl PrivateKey {
fn public_key(&self) -> PublicKey {
let private_key = match self {
PrivateKey::Ed25519(key) => ssh_key::private::PrivateKey::from(key.to_owned()),
PrivateKey::Rsa(key) => ssh_key::private::PrivateKey::from(key.to_owned()),
PrivateKey::Ecdsa(key) => ssh_key::private::PrivateKey::from(key.to_owned()),
};
private_key
.public_key()
.to_bytes()
.map(PublicKey::try_from)
.expect("Key is always valid")
.expect("Key is always valid")
}
pub(crate) fn sign(
&self,
data: &[u8],
scheme: Option<RsaSigningScheme>,
) -> Result<Signature, anyhow::Error> {
let private_key = match self {
PrivateKey::Ed25519(key) => ssh_key::private::PrivateKey::from(key.clone()),
PrivateKey::Rsa(key) => ssh_key::private::PrivateKey::from(key.clone()),
PrivateKey::Ecdsa(key) => ssh_key::private::PrivateKey::from(key.clone()),
};
let result: Result<ssh_key::Signature, _> =
if let KeypairData::Rsa(keypair) = private_key.key_data() {
(keypair, scheme.map(|s| s.to_hash_alg())).try_sign(data)
} else {
private_key.try_sign(data)
};
result.map(Signature).map_err(|e| anyhow::anyhow!(e))
}
}
impl TryFrom<String> for PrivateKey {
type Error = anyhow::Error;
fn try_from(pem: String) -> Result<Self, Self::Error> {
let parsed_key = parse_key_safe(&pem)?;
Self::try_from(parsed_key)
}
}
impl TryFrom<ssh_key::private::PrivateKey> for PrivateKey {
type Error = anyhow::Error;
fn try_from(key: ssh_key::private::PrivateKey) -> Result<Self, Self::Error> {
match key.algorithm() {
ssh_key::Algorithm::Ed25519 => {
Ok(Self::Ed25519(key.key_data().ed25519().unwrap().to_owned()))
}
ssh_key::Algorithm::Rsa { hash: _ } => {
Ok(Self::Rsa(key.key_data().rsa().unwrap().to_owned()))
}
ssh_key::Algorithm::Ecdsa { curve: _ } => {
Ok(Self::Ecdsa(key.key_data().ecdsa().unwrap().to_owned()))
}
_ => Err(anyhow::anyhow!("Unsupported key type")),
}
}
}
#[derive(Clone, PartialEq)]
pub struct PublicKey {
alg: String,
blob: Vec<u8>,
}
impl PublicKey {
pub(super) fn encode(
&self,
writer: &mut impl ssh_encoding::Writer,
) -> Result<(), ssh_encoding::Error> {
let mut buf = Vec::new();
self.alg().as_bytes().encode(&mut buf)?;
self.blob().encode(&mut buf)?;
buf.encode(writer)?;
Ok(())
}
fn try_read_from(mut bytes: &[u8]) -> Result<Self, anyhow::Error> {
let alg = String::from_utf8_lossy(read_bytes(&mut bytes)?.as_slice()).to_string();
let blob = read_bytes(&mut bytes)?;
Ok(PublicKey { alg, blob })
}
}
impl TryFrom<PublicKey> for ssh_key::PublicKey {
type Error = anyhow::Error;
fn try_from(key: PublicKey) -> Result<Self, Self::Error> {
ssh_key::PublicKey::from_bytes(&key.blob).map_err(|e| anyhow::anyhow!(e))
}
}
impl TryFrom<Vec<u8>> for PublicKey {
type Error = anyhow::Error;
fn try_from(bytes: Vec<u8>) -> Result<Self, Self::Error> {
PublicKey::try_read_from(&bytes)
}
}
impl Debug for PublicKey {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "SshPublicKey(\"{} {}\")", self.alg(), self.blob_b64())
}
}
impl PublicKey {
fn alg(&self) -> &str {
&self.alg
}
fn blob(&self) -> &[u8] {
&self.blob
}
fn blob_b64(&self) -> String {
BASE64_STANDARD.encode(self.blob())
}
}
fn parse_key_safe(pem: &str) -> Result<ssh_key::private::PrivateKey, anyhow::Error> {
match ssh_key::private::PrivateKey::from_openssh(pem) {
Ok(key) => match key.public_key().to_bytes() {
Ok(_) => Ok(key),
Err(e) => Err(anyhow::Error::msg(format!(
"Failed to parse public key: {e}"
))),
},
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_keypair_creation() {
let private_key = PrivateKey::try_from(PRIVATE_ED25519_KEY.to_string())
.expect("Test key is always valid");
}
}

View File

@@ -0,0 +1,4 @@
#[cfg(windows)]
mod named_pipe_listener_stream;
pub mod peer_info;
pub mod unix_listener_stream;

View File

@@ -0,0 +1,102 @@
use futures::Stream;
use std::os::windows::prelude::AsRawHandle as _;
use std::{
io,
pin::Pin,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
task::{Context, Poll},
};
use tokio::{
net::windows::named_pipe::{NamedPipeServer, ServerOptions},
select,
};
use tokio_util::sync::CancellationToken;
use tracing::{error, info};
use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId};
use crate::ssh_agent::peerinfo::{self, models::PeerInfo};
const PIPE_NAME: &str = r"\\.\pipe\openssh-ssh-agent";
#[pin_project::pin_project]
pub struct NamedPipeServerStream {
rx: tokio::sync::mpsc::Receiver<(NamedPipeServer, PeerInfo)>,
}
impl NamedPipeServerStream {
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
pub fn new(cancellation_token: CancellationToken, is_running: Arc<AtomicBool>) -> Self {
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::spawn(async move {
info!("Creating named pipe server on {}", PIPE_NAME);
let mut listener = match ServerOptions::new().create(PIPE_NAME) {
Ok(pipe) => pipe,
Err(e) => {
error!(error = %e, "Encountered an error creating the first pipe. The system's openssh service must likely be disabled");
cancellation_token.cancel();
is_running.store(false, Ordering::Relaxed);
return;
}
};
loop {
info!("Waiting for connection");
select! {
_ = cancellation_token.cancelled() => {
info!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server");
break;
}
_ = listener.connect() => {
info!("[SSH Agent Native Module] Incoming connection");
let handle = HANDLE(listener.as_raw_handle());
let mut pid = 0;
unsafe {
if let Err(e) = GetNamedPipeClientProcessId(handle, &mut pid) {
error!(error = %e, pid, "Faile to get named pipe client process id");
continue
}
};
let peer_info = peerinfo::gather::get_peer_info(pid);
let peer_info = match peer_info {
Err(e) => {
error!(error = %e, pid = %pid, "Failed getting process info");
continue
},
Ok(info) => info,
};
tx.send((listener, peer_info)).await.unwrap();
listener = match ServerOptions::new().create(PIPE_NAME) {
Ok(pipe) => pipe,
Err(e) => {
error!(error = %e, "Encountered an error creating a new pipe");
cancellation_token.cancel();
is_running.store(false, Ordering::Relaxed);
return;
}
};
}
}
}
});
Self { rx }
}
}
impl Stream for NamedPipeServerStream {
type Item = io::Result<(NamedPipeServer, PeerInfo)>;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<io::Result<(NamedPipeServer, PeerInfo)>>> {
let this = self.project();
this.rx.poll_recv(cx).map(|v| v.map(Ok))
}
}

View File

@@ -0,0 +1,76 @@
use std::fmt::Debug;
use sysinfo::{Pid, System};
/// 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.
#[derive(Clone)]
pub struct PeerInfo {
uid: u32,
pid: u32,
process_name: String,
peer_type: PeerType,
}
#[derive(Clone, Copy, Debug)]
pub enum PeerType {
#[cfg(windows)]
NamedPipe,
UnixSocket,
}
impl PeerInfo {
pub fn new(pid: u32, peer_type: PeerType) -> Self {
Self::from_pid(pid, peer_type).unwrap_or_else(|_| PeerInfo::unknown())
}
fn from_pid(peer_pid: u32, peer_type: PeerType) -> Result<Self, ()> {
let mut system = System::new();
system.refresh_processes(
sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(peer_pid)]),
true,
);
if let Some(process) = system.process(Pid::from_u32(peer_pid)) {
return Ok(Self {
uid: **process.user_id().ok_or(())?,
pid: peer_pid,
process_name: process.name().to_str().ok_or(())?.to_string(),
peer_type,
});
} else {
Err(())
}
}
pub fn unknown() -> Self {
Self {
uid: 0,
pid: 0,
process_name: "Unknown application".to_string(),
peer_type: PeerType::UnixSocket,
}
}
pub fn uid(&self) -> u32 {
self.uid
}
pub fn pid(&self) -> u32 {
self.pid
}
pub fn process_name(&self) -> &str {
&self.process_name
}
}
impl Debug for PeerInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PeerInfo")
.field("uid", &self.uid)
.field("pid", &self.pid)
.field("process_name", &self.process_name)
.field("peer_type", &self.peer_type)
.finish()
}
}

View File

@@ -0,0 +1,101 @@
use futures::Stream;
use std::os::unix::fs::PermissionsExt;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::{fs, io};
use tokio::net::{UnixListener, UnixStream};
use tracing::{error, info};
use crate::agent::agent::BitwardenDesktopAgent;
use crate::transport::peer_info::{PeerInfo, PeerType};
pub struct UnixListenerStream {
inner: tokio::net::UnixListener,
}
impl UnixListenerStream {
fn new(listener: tokio::net::UnixListener) -> Self {
Self { inner: listener }
}
/// Start listening on the given Unix socket path.
/// This will return only once the lister stops. Returning will attempt to clean up the socket file.
pub async fn listen(
ssh_path: String,
agent: BitwardenDesktopAgent,
) -> Result<(), anyhow::Error> {
info!(socket = %ssh_path, "Starting SSH Unix listener");
// Remove existing socket file if it exists
let socket_path = std::path::Path::new(&ssh_path);
if let Err(e) = std::fs::remove_file(socket_path) {
error!(error = %e, socket = %ssh_path, "Could not remove existing socket file");
if e.kind() != std::io::ErrorKind::NotFound {
return Err(anyhow::Error::new(e));
}
}
match UnixListener::bind(socket_path) {
Ok(listener) => {
// Only the current user should be able to access the socket
if let Err(e) = fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600))
{
error!(error = %e, socket = ?socket_path, "Could not set socket permissions");
return Err(anyhow::Error::new(e));
}
let stream = Self::new(listener);
agent.serve(stream).await;
}
Err(e) => {
error!(error = %e, socket = %ssh_path, "Unable to start start agent server");
}
}
let _ = std::fs::remove_file(socket_path);
info!(socket = %ssh_path, "SSH Unix listener stopped");
Ok(())
}
}
impl Stream for UnixListenerStream {
type Item = io::Result<(UnixStream, PeerInfo)>;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<io::Result<(UnixStream, PeerInfo)>>> {
match self.inner.poll_accept(cx) {
Poll::Ready(Ok((stream, _))) => {
let pid = match stream.peer_cred() {
Ok(peer) => match peer.pid() {
Some(pid) => pid,
None => {
return Poll::Ready(Some(Ok((stream, PeerInfo::unknown()))));
}
},
Err(_) => return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))),
};
Poll::Ready(Some(Ok((
stream,
PeerInfo::new(pid as u32, PeerType::UnixSocket),
))))
}
Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))),
Poll::Pending => Poll::Pending,
}
}
}
impl AsRef<tokio::net::UnixListener> for UnixListenerStream {
fn as_ref(&self) -> &tokio::net::UnixListener {
&self.inner
}
}
impl AsMut<tokio::net::UnixListener> for UnixListenerStream {
fn as_mut(&mut self) -> &mut tokio::net::UnixListener {
&mut self.inner
}
}

View File

@@ -5,7 +5,7 @@ import { concatMap, delay, filter, firstValueFrom, from, race, take, timer } fro
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { sshagent } from "@bitwarden/desktop-napi";
import { sshagent, sshagent_v2 } from "@bitwarden/desktop-napi";
class AgentResponse {
requestId: number;
@@ -19,7 +19,8 @@ export class MainSshAgentService {
private requestResponses: AgentResponse[] = [];
private request_id = 0;
private agentState: sshagent.SshAgentState;
private agentStateV1: sshagent.SshAgentState;
private agentStateV2: sshagent_v2.SshAgentState;
constructor(
private logService: LogService,
@@ -30,11 +31,15 @@ export class MainSshAgentService {
});
ipcMain.handle("sshagent.isloaded", async (event: any) => {
return this.agentState != null;
return this.agentStateV1 != null && this.agentStateV2 != null;
});
}
init() {
this.init_v2();
}
init_v1() {
// handle sign request passing to UI
sshagent
.serve(async (err: Error, sshUiRequest: sshagent.SshUiRequest) => {
@@ -83,7 +88,7 @@ export class MainSshAgentService {
return response.accepted;
})
.then((agentState: sshagent.SshAgentState) => {
this.agentState = agentState;
this.agentStateV1 = agentState;
this.logService.info("SSH agent started");
})
.catch((e) => {
@@ -93,8 +98,8 @@ export class MainSshAgentService {
ipcMain.handle(
"sshagent.setkeys",
async (event: any, keys: { name: string; privateKey: string; cipherId: string }[]) => {
if (this.agentState != null && (await sshagent.isRunning(this.agentState))) {
sshagent.setKeys(this.agentState, keys);
if (this.agentStateV1 != null && (await sshagent.isRunning(this.agentStateV1))) {
sshagent.setKeys(this.agentStateV1, keys);
}
},
);
@@ -106,14 +111,99 @@ export class MainSshAgentService {
);
ipcMain.handle("sshagent.lock", async (event: any) => {
if (this.agentState != null && (await sshagent.isRunning(this.agentState))) {
sshagent.lock(this.agentState);
if (this.agentStateV1 != null && (await sshagent.isRunning(this.agentStateV1))) {
sshagent.lock(this.agentStateV1);
}
});
ipcMain.handle("sshagent.clearkeys", async (event: any) => {
if (this.agentState != null) {
sshagent.clearKeys(this.agentState);
if (this.agentStateV1 != null) {
sshagent.clearKeys(this.agentStateV1);
}
});
}
init_v2() {
// handle sign request passing to UI
sshagent_v2
.serve(async (err: Error, sshUiRequest: sshagent_v2.SshUiRequest) => {
// 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: sshUiRequest.cipherId,
isListRequest: sshUiRequest.isList,
requestId: id_for_this_request,
processName: sshUiRequest.processName,
isAgentForwarding: sshUiRequest.isForwarding,
namespace: sshUiRequest.namespace,
});
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_v2.SshAgentState) => {
this.agentStateV2 = 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.agentStateV2 != null && (await sshagent_v2.isRunning(this.agentStateV2))) {
sshagent_v2.setKeys(this.agentStateV2, 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.lock", async (event: any) => {
if (this.agentStateV2 != null && (await sshagent_v2.isRunning(this.agentStateV2))) {
sshagent_v2.lock(this.agentStateV2);
}
});
ipcMain.handle("sshagent.clearkeys", async (event: any) => {
if (this.agentStateV2 != null) {
sshagent_v2.clearKeys(this.agentStateV2);
}
});
}

View File

@@ -46,7 +46,7 @@ export class SshAgentService implements OnDestroy {
private authorizedSshKeys: Record<string, Date> = {};
private isFeatureFlagEnabled = false;
private isFeatureFlagEnabled = true;
private destroy$ = new Subject<void>();
@@ -272,6 +272,7 @@ export class SshAgentService implements OnDestroy {
cipherId: cipher.id,
};
});
//this.logService.info(`Setting ${keys.length} SSH keys in agent renderer`);
await ipc.platform.sshAgent.setKeys(keys);
}),
takeUntil(this.destroy$),

View File

@@ -33,6 +33,7 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract
) {}
async startProcessReload(authService: AuthService): Promise<void> {
return;
const accounts = await firstValueFrom(this.accountService.accounts$);
if (accounts != null) {
const keys = Object.keys(accounts);