Skip to main content

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::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
90// This attempts to convert given key data into a file and return the filepath.
91// If the given data is actually already a filepath to a key on disk then the
92// key input is returned directly.
93fn 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    // Configured key value or --key argument must be a UTF-8 string.
99    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    // This is converted into a TempPath so that the underlying file handle is
114    // closed. On Windows systems this is required for other programs to be able
115    // to open the file for reading.
116    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        // 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) -> &'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        // 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, 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}