1#![expect(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 revocation_list: Option<OsString>,
42}
43
44#[derive(Debug, Error)]
45pub enum SshError {
46 #[error("SSH sign failed with {exit_status}:\n{stderr}")]
47 Command {
48 exit_status: ExitStatus,
49 stderr: String,
50 },
51 #[error("Failed to parse ssh program response")]
52 BadResult,
53 #[error("Failed to run ssh-keygen")]
54 Io(#[from] std::io::Error),
55 #[error("Signing key required")]
56 MissingKey,
57}
58
59impl From<SshError> for SignError {
60 fn from(e: SshError) -> Self {
61 Self::Backend(Box::new(e))
62 }
63}
64
65type SshResult<T> = Result<T, SshError>;
66
67fn parse_utf8_string(data: Vec<u8>) -> SshResult<String> {
68 String::from_utf8(data).map_err(|_| SshError::BadResult)
69}
70
71fn run_command(command: &mut Command, stdin: &[u8]) -> SshResult<Vec<u8>> {
72 tracing::info!(?command, "running SSH signing command");
73 let process = command.spawn()?;
74 let write_result = process.stdin.as_ref().unwrap().write_all(stdin);
75 let output = process.wait_with_output()?;
76 tracing::info!(?command, ?output.status, "SSH signing command exited");
77 if output.status.success() {
78 write_result?;
79 Ok(output.stdout)
80 } else {
81 Err(SshError::Command {
82 exit_status: output.status,
83 stderr: String::from_utf8_lossy(&output.stderr).trim_end().into(),
84 })
85 }
86}
87
88fn ensure_key_as_file(key: &str) -> SshResult<Either<PathBuf, tempfile::TempPath>> {
92 let key_path = crate::file_util::expand_home_path(key);
93 if key_path.is_absolute() {
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
114fn parse_fingerprint(output: Vec<u8>) -> SshResult<String> {
115 Ok(parse_utf8_string(output)?
116 .rsplit_once(' ')
117 .ok_or(SshError::BadResult)?
118 .1
119 .trim()
120 .into())
121}
122
123impl SshBackend {
124 pub fn new(
125 program: OsString,
126 allowed_signers: Option<OsString>,
127 revocation_list: Option<OsString>,
128 ) -> Self {
129 Self {
130 program,
131 allowed_signers,
132 revocation_list,
133 }
134 }
135
136 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
137 let program = settings.get_string("signing.backends.ssh.program")?;
138
139 let get_expanded_path = |name| {
140 Ok(settings
141 .get_string(name)
142 .optional()?
143 .map(|v| crate::file_util::expand_home_path(v.as_str())))
144 };
145
146 let allowed_signers = get_expanded_path("signing.backends.ssh.allowed-signers")?;
147 let revocation_list = get_expanded_path("signing.backends.ssh.revocation-list")?;
148
149 Ok(Self::new(
150 program.into(),
151 allowed_signers.map(Into::into),
152 revocation_list.map(Into::into),
153 ))
154 }
155
156 fn create_command(&self) -> Command {
157 let mut command = Command::new(&self.program);
158 #[cfg(windows)]
160 {
161 use std::os::windows::process::CommandExt as _;
162 const CREATE_NO_WINDOW: u32 = 0x08000000;
163 command.creation_flags(CREATE_NO_WINDOW);
164 }
165
166 command
167 .stdin(Stdio::piped())
168 .stdout(Stdio::piped())
169 .stderr(Stdio::piped());
170
171 command
172 }
173
174 fn find_principal(&self, signature_file_path: &Path) -> Result<Option<String>, SshError> {
175 let Some(allowed_signers) = &self.allowed_signers else {
176 return Ok(None);
177 };
178
179 let mut command = self.create_command();
180
181 command
182 .arg("-Y")
183 .arg("find-principals")
184 .arg("-f")
185 .arg(allowed_signers)
186 .arg("-s")
187 .arg(signature_file_path);
188
189 tracing::info!(?command, "running SSH signing command");
194 let process = command.spawn()?;
195 let output = process.wait_with_output()?;
196 tracing::info!(?command, ?output.status, "SSH signing command exited");
197
198 let principal = parse_utf8_string(output.stdout)?
199 .split('\n')
200 .next()
201 .unwrap()
202 .trim()
203 .to_string();
204
205 if principal.is_empty() {
206 return Ok(None);
207 }
208 Ok(Some(principal))
209 }
210}
211
212impl SigningBackend for SshBackend {
213 fn name(&self) -> &str {
214 "ssh"
215 }
216
217 fn can_read(&self, signature: &[u8]) -> bool {
218 signature.starts_with(b"-----BEGIN SSH SIGNATURE-----")
219 }
220
221 fn sign(&self, data: &[u8], key: Option<&str>) -> Result<Vec<u8>, SignError> {
222 let Some(key) = key else {
223 return Err(SshError::MissingKey.into());
224 };
225
226 let pub_key_path = ensure_key_as_file(key)?;
232 let mut command = self.create_command();
233
234 let path = match &pub_key_path {
235 either::Left(path) => path.as_os_str(),
236 either::Right(path) => path.as_os_str(),
237 };
238
239 command
240 .arg("-Y")
241 .arg("sign")
242 .arg("-f")
243 .arg(path)
244 .arg("-n")
245 .arg("git");
246
247 Ok(run_command(&mut command, data)?)
248 }
249
250 fn verify(&self, data: &[u8], signature: &[u8]) -> Result<Verification, SignError> {
251 let mut signature_file = tempfile::Builder::new()
252 .prefix(".jj-ssh-sig-")
253 .tempfile()
254 .map_err(SshError::Io)?;
255 signature_file.write_all(signature).map_err(SshError::Io)?;
256 signature_file.flush().map_err(SshError::Io)?;
257
258 let signature_file_path = signature_file.into_temp_path();
259
260 let principal = self.find_principal(&signature_file_path)?;
261
262 let mut command = self.create_command();
263
264 match (principal, self.allowed_signers.as_ref()) {
265 (Some(principal), Some(allowed_signers)) => {
266 command
267 .arg("-Y")
268 .arg("verify")
269 .arg("-s")
270 .arg(&signature_file_path)
271 .arg("-I")
272 .arg(&principal)
273 .arg("-f")
274 .arg(allowed_signers)
275 .arg("-n")
276 .arg("git");
277
278 if let Some(revocation_list) = self.revocation_list.as_ref() {
279 command.arg("-r").arg(revocation_list);
280 }
281
282 let result = run_command(&mut command, data);
283
284 let status = match result {
285 Ok(_) => SigStatus::Good,
286 Err(_) => SigStatus::Bad,
287 };
288
289 let key = result.ok().map(parse_fingerprint).transpose()?;
290
291 Ok(Verification::new(status, key, Some(principal)))
292 }
293 _ => {
294 command
295 .arg("-Y")
296 .arg("check-novalidate")
297 .arg("-s")
298 .arg(&signature_file_path)
299 .arg("-n")
300 .arg("git");
301
302 let result = run_command(&mut command, data);
303
304 match result {
305 Ok(result) => Ok(Verification::new(
306 SigStatus::Unknown,
307 Some(parse_fingerprint(result)?),
308 Some("Signature OK. Unknown principal".into()),
309 )),
310 Err(_) => Ok(Verification::new(SigStatus::Bad, None, None)),
311 }
312 }
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use std::fs::File;
320 use std::io::Read as _;
321
322 use super::*;
323
324 #[test]
325 fn test_ssh_key_to_file_conversion_raw_key_data() {
326 let keydata = "ssh-ed25519 some-key-data";
327 let path = ensure_key_as_file(keydata).unwrap();
328
329 let mut buf = vec![];
330 let mut file = File::open(path.right().unwrap()).unwrap();
331 file.read_to_end(&mut buf).unwrap();
332
333 assert_eq!("ssh-ed25519 some-key-data", String::from_utf8(buf).unwrap());
334 }
335
336 #[test]
337 fn test_ssh_key_to_file_conversion_non_ssh_prefix() {
338 let keydata = "ecdsa-sha2-nistp256 some-key-data";
339 let path = ensure_key_as_file(keydata).unwrap();
340
341 let mut buf = vec![];
342 let mut file = File::open(path.right().unwrap()).unwrap();
343 file.read_to_end(&mut buf).unwrap();
344
345 assert_eq!(
346 "ecdsa-sha2-nistp256 some-key-data",
347 String::from_utf8(buf).unwrap()
348 );
349 }
350
351 #[test]
352 fn test_ssh_key_to_file_conversion_existing_file() {
353 let mut file = tempfile::Builder::new()
354 .prefix("jj-signing-key-")
355 .tempfile()
356 .map_err(SshError::Io)
357 .unwrap();
358
359 file.write_all(b"some-data").map_err(SshError::Io).unwrap();
360 file.flush().map_err(SshError::Io).unwrap();
361
362 let file_path = file.into_temp_path();
363
364 let path = ensure_key_as_file(file_path.to_str().unwrap()).unwrap();
365
366 assert_eq!(
367 file_path.to_str().unwrap(),
368 path.left().unwrap().to_str().unwrap()
369 );
370 }
371}