just playing with tangled
at ig/vimdiffwarn 324 lines 10 kB view raw
1// Copyright 2023 The Jujutsu Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15#![allow(missing_docs)] 16 17use std::ffi::OsString; 18use std::fmt::Debug; 19use std::io::Write as _; 20use std::path::Path; 21use std::path::PathBuf; 22use std::process::Command; 23use std::process::ExitStatus; 24use std::process::Stdio; 25 26use either::Either; 27use thiserror::Error; 28 29use crate::config::ConfigGetError; 30use crate::config::ConfigGetResultExt as _; 31use crate::settings::UserSettings; 32use crate::signing::SigStatus; 33use crate::signing::SignError; 34use crate::signing::SigningBackend; 35use crate::signing::Verification; 36 37#[derive(Debug)] 38pub struct SshBackend { 39 program: OsString, 40 allowed_signers: Option<OsString>, 41} 42 43#[derive(Debug, Error)] 44pub enum SshError { 45 #[error("SSH sign failed with {exit_status}:\n{stderr}")] 46 Command { 47 exit_status: ExitStatus, 48 stderr: String, 49 }, 50 #[error("Failed to parse ssh program response")] 51 BadResult, 52 #[error("Failed to run ssh-keygen")] 53 Io(#[from] std::io::Error), 54 #[error("Signing key required")] 55 MissingKey, 56} 57 58impl From<SshError> for SignError { 59 fn from(e: SshError) -> Self { 60 SignError::Backend(Box::new(e)) 61 } 62} 63 64type SshResult<T> = Result<T, SshError>; 65 66fn parse_utf8_string(data: Vec<u8>) -> SshResult<String> { 67 String::from_utf8(data).map_err(|_| SshError::BadResult) 68} 69 70fn run_command(command: &mut Command, stdin: &[u8]) -> SshResult<Vec<u8>> { 71 tracing::info!(?command, "running SSH signing command"); 72 let process = command.spawn()?; 73 let write_result = process.stdin.as_ref().unwrap().write_all(stdin); 74 let output = process.wait_with_output()?; 75 tracing::info!(?command, ?output.status, "SSH signing command exited"); 76 if output.status.success() { 77 write_result?; 78 Ok(output.stdout) 79 } else { 80 Err(SshError::Command { 81 exit_status: output.status, 82 stderr: String::from_utf8_lossy(&output.stderr).trim_end().into(), 83 }) 84 } 85} 86 87// This attempts to convert given key data into a file and return the filepath. 88// If the given data is actually already a filepath to a key on disk then the 89// key input is returned directly. 90fn ensure_key_as_file(key: &str) -> SshResult<Either<PathBuf, tempfile::TempPath>> { 91 let is_inlined_ssh_key = key.starts_with("ssh-"); 92 if !is_inlined_ssh_key { 93 let key_path = crate::file_util::expand_home_path(key); 94 return Ok(either::Left(key_path)); 95 } 96 97 let mut pub_key_file = tempfile::Builder::new() 98 .prefix("jj-signing-key-") 99 .tempfile() 100 .map_err(SshError::Io)?; 101 102 pub_key_file 103 .write_all(key.as_bytes()) 104 .map_err(SshError::Io)?; 105 pub_key_file.flush().map_err(SshError::Io)?; 106 107 // This is converted into a TempPath so that the underlying file handle is 108 // closed. On Windows systems this is required for other programs to be able 109 // to open the file for reading. 110 let pub_key_path = pub_key_file.into_temp_path(); 111 Ok(either::Right(pub_key_path)) 112} 113 114impl SshBackend { 115 pub fn new(program: OsString, allowed_signers: Option<OsString>) -> Self { 116 Self { 117 program, 118 allowed_signers, 119 } 120 } 121 122 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> { 123 let program = settings.get_string("signing.backends.ssh.program")?; 124 let allowed_signers = settings 125 .get_string("signing.backends.ssh.allowed-signers") 126 .optional()? 127 .map(|v| crate::file_util::expand_home_path(v.as_str())); 128 Ok(Self::new(program.into(), allowed_signers.map(|v| v.into()))) 129 } 130 131 fn create_command(&self) -> Command { 132 let mut command = Command::new(&self.program); 133 // Hide console window on Windows (https://stackoverflow.com/a/60958956) 134 #[cfg(windows)] 135 { 136 use std::os::windows::process::CommandExt; 137 const CREATE_NO_WINDOW: u32 = 0x08000000; 138 command.creation_flags(CREATE_NO_WINDOW); 139 } 140 141 command 142 .stdin(Stdio::piped()) 143 .stdout(Stdio::piped()) 144 .stderr(Stdio::piped()); 145 146 command 147 } 148 149 fn find_principal(&self, signature_file_path: &Path) -> Result<Option<String>, SshError> { 150 let Some(allowed_signers) = &self.allowed_signers else { 151 return Ok(None); 152 }; 153 154 let mut command = self.create_command(); 155 156 command 157 .arg("-Y") 158 .arg("find-principals") 159 .arg("-f") 160 .arg(allowed_signers) 161 .arg("-s") 162 .arg(signature_file_path); 163 164 // We can't use the existing run_command helper here as `-Y find-principals` 165 // will return a non-0 exit code if no principals are found. 166 // 167 // In this case we don't want to error out, just return None. 168 tracing::info!(?command, "running SSH signing command"); 169 let process = command.spawn()?; 170 let output = process.wait_with_output()?; 171 tracing::info!(?command, ?output.status, "SSH signing command exited"); 172 173 let principal = parse_utf8_string(output.stdout)? 174 .split('\n') 175 .next() 176 .unwrap() 177 .trim() 178 .to_string(); 179 180 if principal.is_empty() { 181 return Ok(None); 182 } 183 Ok(Some(principal)) 184 } 185} 186 187impl SigningBackend for SshBackend { 188 fn name(&self) -> &str { 189 "ssh" 190 } 191 192 fn can_read(&self, signature: &[u8]) -> bool { 193 signature.starts_with(b"-----BEGIN SSH SIGNATURE-----") 194 } 195 196 fn sign(&self, data: &[u8], key: Option<&str>) -> Result<Vec<u8>, SignError> { 197 let Some(key) = key else { 198 return Err(SshError::MissingKey.into()); 199 }; 200 201 // The ssh-keygen `-f` flag expects to be given a file which contains either a 202 // private or public key. 203 // 204 // As it expects a file and we might have an inlined public key instead, we need 205 // to ensure it is written to a file first. 206 let pub_key_path = ensure_key_as_file(key)?; 207 let mut command = self.create_command(); 208 209 let path = match &pub_key_path { 210 either::Left(path) => path.as_os_str(), 211 either::Right(path) => path.as_os_str(), 212 }; 213 214 command 215 .arg("-Y") 216 .arg("sign") 217 .arg("-f") 218 .arg(path) 219 .arg("-n") 220 .arg("git"); 221 222 Ok(run_command(&mut command, data)?) 223 } 224 225 fn verify(&self, data: &[u8], signature: &[u8]) -> Result<Verification, SignError> { 226 let mut signature_file = tempfile::Builder::new() 227 .prefix(".jj-ssh-sig-") 228 .tempfile() 229 .map_err(SshError::Io)?; 230 signature_file.write_all(signature).map_err(SshError::Io)?; 231 signature_file.flush().map_err(SshError::Io)?; 232 233 let signature_file_path = signature_file.into_temp_path(); 234 235 let principal = self.find_principal(&signature_file_path)?; 236 237 let mut command = self.create_command(); 238 239 match (principal, self.allowed_signers.as_ref()) { 240 (Some(principal), Some(allowed_signers)) => { 241 command 242 .arg("-Y") 243 .arg("verify") 244 .arg("-s") 245 .arg(&signature_file_path) 246 .arg("-I") 247 .arg(&principal) 248 .arg("-f") 249 .arg(allowed_signers) 250 .arg("-n") 251 .arg("git"); 252 253 let result = run_command(&mut command, data); 254 255 let status = match result { 256 Ok(_) => SigStatus::Good, 257 Err(_) => SigStatus::Bad, 258 }; 259 Ok(Verification::new(status, None, Some(principal))) 260 } 261 _ => { 262 command 263 .arg("-Y") 264 .arg("check-novalidate") 265 .arg("-s") 266 .arg(&signature_file_path) 267 .arg("-n") 268 .arg("git"); 269 270 let result = run_command(&mut command, data); 271 272 match result { 273 Ok(_) => Ok(Verification::new( 274 SigStatus::Unknown, 275 None, 276 Some("Signature OK. Unknown principal".into()), 277 )), 278 Err(_) => Ok(Verification::new(SigStatus::Bad, None, None)), 279 } 280 } 281 } 282 } 283} 284 285#[cfg(test)] 286mod tests { 287 use std::fs::File; 288 use std::io::Read as _; 289 290 use super::*; 291 292 #[test] 293 fn test_ssh_key_to_file_conversion_raw_key_data() { 294 let keydata = "ssh-ed25519 some-key-data"; 295 let path = ensure_key_as_file(keydata).unwrap(); 296 297 let mut buf = vec![]; 298 let mut file = File::open(path.right().unwrap()).unwrap(); 299 file.read_to_end(&mut buf).unwrap(); 300 301 assert_eq!("ssh-ed25519 some-key-data", String::from_utf8(buf).unwrap()); 302 } 303 304 #[test] 305 fn test_ssh_key_to_file_conversion_existing_file() { 306 let mut file = tempfile::Builder::new() 307 .prefix("jj-signing-key-") 308 .tempfile() 309 .map_err(SshError::Io) 310 .unwrap(); 311 312 file.write_all(b"some-data").map_err(SshError::Io).unwrap(); 313 file.flush().map_err(SshError::Io).unwrap(); 314 315 let file_path = file.into_temp_path(); 316 317 let path = ensure_key_as_file(file_path.to_str().unwrap()).unwrap(); 318 319 assert_eq!( 320 file_path.to_str().unwrap(), 321 path.left().unwrap().to_str().unwrap() 322 ); 323 } 324}