1#![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
87fn 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 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 #[cfg(windows)]
135 {
136 use std::os::windows::process::CommandExt as _;
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 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 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}