1#![expect(missing_docs)]
16
17use std::ffi::OsStr;
18use std::ffi::OsString;
19use std::fmt::Debug;
20use std::io::Write as _;
21use std::path::Path;
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;
36use crate::subprocess_util::suppress_console_window;
37
38#[derive(Debug)]
39pub struct SshBackend {
40 program: OsString,
41 allowed_signers: Option<OsString>,
42 revocation_list: Option<OsString>,
43 default_key: Option<OsString>,
44}
45
46#[derive(Debug, Error)]
47pub enum SshError {
48 #[error("SSH sign failed with {exit_status}:\n{stderr}")]
49 Command {
50 exit_status: ExitStatus,
51 stderr: String,
52 },
53 #[error("Failed to parse ssh program response")]
54 BadResult,
55 #[error("Failed to run ssh-keygen")]
56 Io(#[from] std::io::Error),
57 #[error("Signing key required")]
58 MissingKey,
59}
60
61impl From<SshError> for SignError {
62 fn from(e: SshError) -> Self {
63 Self::Backend(Box::new(e))
64 }
65}
66
67type SshResult<T> = Result<T, SshError>;
68
69fn parse_utf8_string(data: &[u8]) -> SshResult<&str> {
70 str::from_utf8(data).map_err(|_| SshError::BadResult)
71}
72
73fn run_command(command: &mut Command, stdin: &[u8]) -> SshResult<Vec<u8>> {
74 tracing::info!(?command, "running SSH signing command");
75 let process = command.spawn()?;
76 let write_result = process.stdin.as_ref().unwrap().write_all(stdin);
77 let output = process.wait_with_output()?;
78 tracing::info!(?command, ?output.status, "SSH signing command exited");
79 if output.status.success() {
80 write_result?;
81 Ok(output.stdout)
82 } else {
83 Err(SshError::Command {
84 exit_status: output.status,
85 stderr: String::from_utf8_lossy(&output.stderr).trim_end().into(),
86 })
87 }
88}
89
90fn ensure_key_as_file(key_path_or_data: &OsStr) -> SshResult<Either<&Path, tempfile::TempPath>> {
94 let key_path = Path::new(key_path_or_data);
95 if key_path.is_absolute() {
96 return Ok(either::Left(key_path));
97 }
98 let Some(key_data) = key_path_or_data.to_str() else {
100 return Ok(either::Left(key_path));
101 };
102
103 let mut pub_key_file = tempfile::Builder::new()
104 .prefix("jj-signing-key-")
105 .tempfile()
106 .map_err(SshError::Io)?;
107
108 pub_key_file
109 .write_all(key_data.as_bytes())
110 .map_err(SshError::Io)?;
111 pub_key_file.flush().map_err(SshError::Io)?;
112
113 let pub_key_path = pub_key_file.into_temp_path();
117 Ok(either::Right(pub_key_path))
118}
119
120fn parse_fingerprint(output: &[u8]) -> SshResult<String> {
121 Ok(parse_utf8_string(output)?
122 .rsplit_once(' ')
123 .ok_or(SshError::BadResult)?
124 .1
125 .trim()
126 .into())
127}
128
129impl SshBackend {
130 pub fn new(
131 program: OsString,
132 allowed_signers: Option<OsString>,
133 revocation_list: Option<OsString>,
134 default_key: Option<OsString>,
135 ) -> Self {
136 Self {
137 program,
138 allowed_signers,
139 revocation_list,
140 default_key,
141 }
142 }
143
144 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
145 let program = settings.get_string("signing.backends.ssh.program")?;
146 let allowed_signers = settings
147 .get_path("signing.backends.ssh.allowed-signers")
148 .optional()?;
149 let revocation_list = settings
150 .get_path("signing.backends.ssh.revocation-list")
151 .optional()?;
152 let default_key = settings.get_path("signing.key").optional()?;
153
154 Ok(Self::new(
155 program.into(),
156 allowed_signers.map(Into::into),
157 revocation_list.map(Into::into),
158 default_key.map(Into::into),
159 ))
160 }
161
162 fn create_command(&self) -> Command {
163 let mut command = Command::new(&self.program);
164 suppress_console_window(&mut command);
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) -> &'static 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.map(OsStr::new).or(self.default_key.as_deref()) 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, key) = match &result {
285 Ok(output) => (SigStatus::Good, Some(parse_fingerprint(output)?)),
286 Err(_) => (SigStatus::Bad, None),
287 };
288 Ok(Verification::new(status, key, Some(principal)))
289 }
290 _ => {
291 command
292 .arg("-Y")
293 .arg("check-novalidate")
294 .arg("-s")
295 .arg(&signature_file_path)
296 .arg("-n")
297 .arg("git");
298
299 let result = run_command(&mut command, data);
300
301 match &result {
302 Ok(output) => Ok(Verification::new(
303 SigStatus::Unknown,
304 Some(parse_fingerprint(output)?),
305 Some("Signature OK. Unknown principal".into()),
306 )),
307 Err(_) => Ok(Verification::new(SigStatus::Bad, None, None)),
308 }
309 }
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use std::fs::File;
317 use std::io::Read as _;
318
319 use super::*;
320
321 #[test]
322 fn test_ssh_key_to_file_conversion_raw_key_data() {
323 let keydata = "ssh-ed25519 some-key-data";
324 let path = ensure_key_as_file(keydata.as_ref()).unwrap();
325
326 let mut buf = vec![];
327 let mut file = File::open(path.right().unwrap()).unwrap();
328 file.read_to_end(&mut buf).unwrap();
329
330 assert_eq!("ssh-ed25519 some-key-data", String::from_utf8(buf).unwrap());
331 }
332
333 #[test]
334 fn test_ssh_key_to_file_conversion_non_ssh_prefix() {
335 let keydata = "ecdsa-sha2-nistp256 some-key-data";
336 let path = ensure_key_as_file(keydata.as_ref()).unwrap();
337
338 let mut buf = vec![];
339 let mut file = File::open(path.right().unwrap()).unwrap();
340 file.read_to_end(&mut buf).unwrap();
341
342 assert_eq!(
343 "ecdsa-sha2-nistp256 some-key-data",
344 String::from_utf8(buf).unwrap()
345 );
346 }
347
348 #[test]
349 fn test_ssh_key_to_file_conversion_existing_file() {
350 let mut file = tempfile::Builder::new()
351 .prefix("jj-signing-key-")
352 .tempfile()
353 .map_err(SshError::Io)
354 .unwrap();
355
356 file.write_all(b"some-data").map_err(SshError::Io).unwrap();
357 file.flush().map_err(SshError::Io).unwrap();
358
359 let file_path = file.into_temp_path();
360
361 let path = ensure_key_as_file(file_path.as_ref()).unwrap();
362
363 assert_eq!(
364 file_path.to_str().unwrap(),
365 path.left().unwrap().to_str().unwrap()
366 );
367 }
368}