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
114impl SshBackend {
115 pub fn new(
116 program: OsString,
117 allowed_signers: Option<OsString>,
118 revocation_list: Option<OsString>,
119 ) -> Self {
120 Self {
121 program,
122 allowed_signers,
123 revocation_list,
124 }
125 }
126
127 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
128 let program = settings.get_string("signing.backends.ssh.program")?;
129
130 let get_expanded_path = |name| {
131 Ok(settings
132 .get_string(name)
133 .optional()?
134 .map(|v| crate::file_util::expand_home_path(v.as_str())))
135 };
136
137 let allowed_signers = get_expanded_path("signing.backends.ssh.allowed-signers")?;
138 let revocation_list = get_expanded_path("signing.backends.ssh.revocation-list")?;
139
140 Ok(Self::new(
141 program.into(),
142 allowed_signers.map(Into::into),
143 revocation_list.map(Into::into),
144 ))
145 }
146
147 fn create_command(&self) -> Command {
148 let mut command = Command::new(&self.program);
149 #[cfg(windows)]
151 {
152 use std::os::windows::process::CommandExt as _;
153 const CREATE_NO_WINDOW: u32 = 0x08000000;
154 command.creation_flags(CREATE_NO_WINDOW);
155 }
156
157 command
158 .stdin(Stdio::piped())
159 .stdout(Stdio::piped())
160 .stderr(Stdio::piped());
161
162 command
163 }
164
165 fn find_principal(&self, signature_file_path: &Path) -> Result<Option<String>, SshError> {
166 let Some(allowed_signers) = &self.allowed_signers else {
167 return Ok(None);
168 };
169
170 let mut command = self.create_command();
171
172 command
173 .arg("-Y")
174 .arg("find-principals")
175 .arg("-f")
176 .arg(allowed_signers)
177 .arg("-s")
178 .arg(signature_file_path);
179
180 tracing::info!(?command, "running SSH signing command");
185 let process = command.spawn()?;
186 let output = process.wait_with_output()?;
187 tracing::info!(?command, ?output.status, "SSH signing command exited");
188
189 let principal = parse_utf8_string(output.stdout)?
190 .split('\n')
191 .next()
192 .unwrap()
193 .trim()
194 .to_string();
195
196 if principal.is_empty() {
197 return Ok(None);
198 }
199 Ok(Some(principal))
200 }
201}
202
203impl SigningBackend for SshBackend {
204 fn name(&self) -> &str {
205 "ssh"
206 }
207
208 fn can_read(&self, signature: &[u8]) -> bool {
209 signature.starts_with(b"-----BEGIN SSH SIGNATURE-----")
210 }
211
212 fn sign(&self, data: &[u8], key: Option<&str>) -> Result<Vec<u8>, SignError> {
213 let Some(key) = key else {
214 return Err(SshError::MissingKey.into());
215 };
216
217 let pub_key_path = ensure_key_as_file(key)?;
223 let mut command = self.create_command();
224
225 let path = match &pub_key_path {
226 either::Left(path) => path.as_os_str(),
227 either::Right(path) => path.as_os_str(),
228 };
229
230 command
231 .arg("-Y")
232 .arg("sign")
233 .arg("-f")
234 .arg(path)
235 .arg("-n")
236 .arg("git");
237
238 Ok(run_command(&mut command, data)?)
239 }
240
241 fn verify(&self, data: &[u8], signature: &[u8]) -> Result<Verification, SignError> {
242 let mut signature_file = tempfile::Builder::new()
243 .prefix(".jj-ssh-sig-")
244 .tempfile()
245 .map_err(SshError::Io)?;
246 signature_file.write_all(signature).map_err(SshError::Io)?;
247 signature_file.flush().map_err(SshError::Io)?;
248
249 let signature_file_path = signature_file.into_temp_path();
250
251 let principal = self.find_principal(&signature_file_path)?;
252
253 let mut command = self.create_command();
254
255 match (principal, self.allowed_signers.as_ref()) {
256 (Some(principal), Some(allowed_signers)) => {
257 command
258 .arg("-Y")
259 .arg("verify")
260 .arg("-s")
261 .arg(&signature_file_path)
262 .arg("-I")
263 .arg(&principal)
264 .arg("-f")
265 .arg(allowed_signers)
266 .arg("-n")
267 .arg("git");
268
269 if let Some(revocation_list) = self.revocation_list.as_ref() {
270 command.arg("-r").arg(revocation_list);
271 }
272
273 let result = run_command(&mut command, data);
274
275 let status = match result {
276 Ok(_) => SigStatus::Good,
277 Err(_) => SigStatus::Bad,
278 };
279 Ok(Verification::new(status, None, Some(principal)))
280 }
281 _ => {
282 command
283 .arg("-Y")
284 .arg("check-novalidate")
285 .arg("-s")
286 .arg(&signature_file_path)
287 .arg("-n")
288 .arg("git");
289
290 let result = run_command(&mut command, data);
291
292 match result {
293 Ok(_) => Ok(Verification::new(
294 SigStatus::Unknown,
295 None,
296 Some("Signature OK. Unknown principal".into()),
297 )),
298 Err(_) => Ok(Verification::new(SigStatus::Bad, None, None)),
299 }
300 }
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use std::fs::File;
308 use std::io::Read as _;
309
310 use super::*;
311
312 #[test]
313 fn test_ssh_key_to_file_conversion_raw_key_data() {
314 let keydata = "ssh-ed25519 some-key-data";
315 let path = ensure_key_as_file(keydata).unwrap();
316
317 let mut buf = vec![];
318 let mut file = File::open(path.right().unwrap()).unwrap();
319 file.read_to_end(&mut buf).unwrap();
320
321 assert_eq!("ssh-ed25519 some-key-data", String::from_utf8(buf).unwrap());
322 }
323
324 #[test]
325 fn test_ssh_key_to_file_conversion_non_ssh_prefix() {
326 let keydata = "ecdsa-sha2-nistp256 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!(
334 "ecdsa-sha2-nistp256 some-key-data",
335 String::from_utf8(buf).unwrap()
336 );
337 }
338
339 #[test]
340 fn test_ssh_key_to_file_conversion_existing_file() {
341 let mut file = tempfile::Builder::new()
342 .prefix("jj-signing-key-")
343 .tempfile()
344 .map_err(SshError::Io)
345 .unwrap();
346
347 file.write_all(b"some-data").map_err(SshError::Io).unwrap();
348 file.flush().map_err(SshError::Io).unwrap();
349
350 let file_path = file.into_temp_path();
351
352 let path = ensure_key_as_file(file_path.to_str().unwrap()).unwrap();
353
354 assert_eq!(
355 file_path.to_str().unwrap(),
356 path.left().unwrap().to_str().unwrap()
357 );
358 }
359}