jj_lib/
ssh_signing.rs

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#![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
88// This attempts to convert given key data into a file and return the filepath.
89// If the given data is actually already a filepath to a key on disk then the
90// key input is returned directly.
91fn 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    // 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
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        // Hide console window on Windows (https://stackoverflow.com/a/60958956)
159        #[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        // We can't use the existing run_command helper here as `-Y find-principals`
190        // will return a non-0 exit code if no principals are found.
191        //
192        // In this case we don't want to error out, just return None.
193        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        // The ssh-keygen `-f` flag expects to be given a file which contains either a
227        // private or public key.
228        //
229        // As it expects a file and we might have an inlined public key instead, we need
230        // to ensure it is written to a file first.
231        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}