From 4c1389ea7fa94b0e499521321b91f60cdb155f8f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 27 Apr 2026 22:53:51 +0200 Subject: [PATCH] Enable asking for confirmation on SSH signature request Add the capability to ask for confirmation on each SSH signature request. This is done by adding a pinentry::confirm function, a `confirm_ssh` option that defaults to None and a condition under the `sign` method in SshAgent's implementation of the ssh_agent_lib Session trait --- README.md | 2 ++ src/bin/rbw-agent/ssh_agent.rs | 33 ++++++++++++++++++ src/bin/rbw/commands.rs | 2 ++ src/config.rs | 6 ++++ src/pinentry.rs | 63 ++++++++++++++++++++++++++++++---- 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index eb9074b8..a2367444 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ configuration options: * `pinentry`: The [pinentry](https://www.gnupg.org/related_software/pinentry/index.html) executable to use. Defaults to `pinentry`. +* `confirm_ssh`: If set to `true` will ask for confirmation for SSH signature +requests. If unset defaults to not asking. ### Profiles diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 4d0bb50c..7a5b1b3b 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -3,6 +3,16 @@ use signature::{RandomizedSigner as _, SignatureEncoding as _, Signer as _}; const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; +async fn config_pinentry() -> anyhow::Result { + let config = rbw::config::Config::load_async().await?; + Ok(config.pinentry) +} + +async fn config_confirm_ssh() -> anyhow::Result { + let config = rbw::config::Config::load_async().await?; + Ok(config.confirm_ssh.is_some_and(|o| o == true)) +} + #[derive(Clone)] pub struct SshAgent { state: std::sync::Arc>, @@ -67,6 +77,29 @@ impl ssh_agent_lib::agent::Session for SshAgent { ssh_agent_lib::error::AgentError::Other(e.into()) })?; + if config_confirm_ssh().await.map_err(|_| { + ssh_agent_lib::error::AgentError::Other( + "Unable to load configuration".into(), + ) + })? { + let confirmed = rbw::pinentry::confirm( + &config_pinentry() + .await + .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?, + "Allow SSH key use?", + &self.state.lock().await.last_environment, + true, + ) + .await + .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?; + + if !confirmed { + return Err(ssh_agent_lib::error::AgentError::Other( + "User did not confirm".into(), + )); + } + } + match private_key.key_data() { ssh_agent_lib::ssh_key::private::KeypairData::Ed25519(key) => key .try_sign(&request.data) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..5383e12c 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1269,6 +1269,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { config.sync_interval = interval; } "pinentry" => config.pinentry = value.to_string(), + "confirm_ssh" => config.confirm_ssh = Some(value == "true"), _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; @@ -1298,6 +1299,7 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { config.lock_timeout = rbw::config::default_lock_timeout(); } "pinentry" => config.pinentry = rbw::config::default_pinentry(), + "confirm_ssh" => config.confirm_ssh = rbw::config::default_confirm_ssh(), _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; diff --git a/src/config.rs b/src/config.rs index 248c603c..2dddf724 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub sync_interval: u64, #[serde(default = "default_pinentry")] pub pinentry: String, + pub confirm_ssh: Option, pub client_cert_path: Option, // backcompat, no longer generated in new configs #[serde(skip_serializing)] @@ -36,6 +37,7 @@ impl Default for Config { lock_timeout: default_lock_timeout(), sync_interval: default_sync_interval(), pinentry: default_pinentry(), + confirm_ssh: None, client_cert_path: None, device_id: None, } @@ -54,6 +56,10 @@ pub fn default_pinentry() -> String { "pinentry".to_string() } +pub fn default_confirm_ssh() -> Option { + None +} + impl Config { pub fn new() -> Self { Self::default() diff --git a/src/pinentry.rs b/src/pinentry.rs index ab316d72..ab2a0230 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -2,16 +2,13 @@ use crate::prelude::*; use std::convert::TryFrom as _; -use tokio::io::AsyncWriteExt as _; +use tokio::{io::AsyncWriteExt as _, process::Child}; -pub async fn getpin( +fn spawn_pinentry( pinentry: &str, - prompt: &str, - desc: &str, - err: Option<&str>, environment: &crate::protocol::Environment, grab: bool, -) -> Result { +) -> Result { let mut opts = tokio::process::Command::new(pinentry); opts.stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()); @@ -42,9 +39,22 @@ pub async fn getpin( } opts.envs(env_vars); - let mut child = opts.spawn().map_err(|source| Error::Spawn { source })?; + let child = opts.spawn().map_err(|source| Error::Spawn { source })?; // unwrap is safe because we specified stdin as piped in the command opts // above + + Ok(child) +} + +pub async fn getpin( + pinentry: &str, + prompt: &str, + desc: &str, + err: Option<&str>, + environment: &crate::protocol::Environment, + grab: bool, +) -> Result { + let mut child = spawn_pinentry(pinentry, environment, grab)?; let mut stdin = child.stdin.take().unwrap(); let mut ncommands = 1; @@ -97,6 +107,45 @@ pub async fn getpin( Ok(crate::locked::Password::new(buf)) } +pub async fn confirm( + pinentry: &str, + desc: &str, + environment: &crate::protocol::Environment, + grab: bool, +) -> Result { + let mut child = spawn_pinentry(pinentry, environment, grab)?; + let mut stdin = child.stdin.take().unwrap(); + + let mut ncommands = 1; + stdin + .write_all(b"SETTITLE rbw\n") + .await + .map_err(|source| Error::WriteStdin { source })?; + ncommands += 1; + stdin + .write_all(format!("SETDESC {desc}\n").as_bytes()) + .await + .map_err(|source| Error::WriteStdin { source })?; + ncommands += 1; + stdin + .write_all(b"CONFIRM\n") + .await + .map_err(|source| Error::WriteStdin { source })?; + ncommands += 1; + drop(stdin); + + let mut buf = [0u8; 64]; + read_password(ncommands, &mut buf, child.stdout.as_mut().unwrap()) + .await?; + + child + .wait() + .await + .map_err(|source| Error::PinentryWait { source })?; + + Ok(true) +} + async fn read_password( mut ncommands: u8, data: &mut [u8],