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#![allow(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}
42
43#[derive(Debug, Error)]
44pub enum SshError {
45    #[error("SSH sign failed with {exit_status}:\n{stderr}")]
46    Command {
47        exit_status: ExitStatus,
48        stderr: String,
49    },
50    #[error("Failed to parse ssh program response")]
51    BadResult,
52    #[error("Failed to run ssh-keygen")]
53    Io(#[from] std::io::Error),
54    #[error("Signing key required")]
55    MissingKey,
56}
57
58impl From<SshError> for SignError {
59    fn from(e: SshError) -> Self {
60        SignError::Backend(Box::new(e))
61    }
62}
63
64type SshResult<T> = Result<T, SshError>;
65
66fn parse_utf8_string(data: Vec<u8>) -> SshResult<String> {
67    String::from_utf8(data).map_err(|_| SshError::BadResult)
68}
69
70fn run_command(command: &mut Command, stdin: &[u8]) -> SshResult<Vec<u8>> {
71    tracing::info!(?command, "running SSH signing command");
72    let process = command.spawn()?;
73    let write_result = process.stdin.as_ref().unwrap().write_all(stdin);
74    let output = process.wait_with_output()?;
75    tracing::info!(?command, ?output.status, "SSH signing command exited");
76    if output.status.success() {
77        write_result?;
78        Ok(output.stdout)
79    } else {
80        Err(SshError::Command {
81            exit_status: output.status,
82            stderr: String::from_utf8_lossy(&output.stderr).trim_end().into(),
83        })
84    }
85}
86
87// This attempts to convert given key data into a file and return the filepath.
88// If the given data is actually already a filepath to a key on disk then the
89// key input is returned directly.
90fn ensure_key_as_file(key: &str) -> SshResult<Either<PathBuf, tempfile::TempPath>> {
91    let is_inlined_ssh_key = key.starts_with("ssh-");
92    if !is_inlined_ssh_key {
93        let key_path = crate::file_util::expand_home_path(key);
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
114impl SshBackend {
115    pub fn new(program: OsString, allowed_signers: Option<OsString>) -> Self {
116        Self {
117            program,
118            allowed_signers,
119        }
120    }
121
122    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
123        let program = settings.get_string("signing.backends.ssh.program")?;
124        let allowed_signers = settings
125            .get_string("signing.backends.ssh.allowed-signers")
126            .optional()?
127            .map(|v| crate::file_util::expand_home_path(v.as_str()));
128        Ok(Self::new(program.into(), allowed_signers.map(|v| v.into())))
129    }
130
131    fn create_command(&self) -> Command {
132        let mut command = Command::new(&self.program);
133        // Hide console window on Windows (https://stackoverflow.com/a/60958956)
134        #[cfg(windows)]
135        {
136            use std::os::windows::process::CommandExt as _;
137            const CREATE_NO_WINDOW: u32 = 0x08000000;
138            command.creation_flags(CREATE_NO_WINDOW);
139        }
140
141        command
142            .stdin(Stdio::piped())
143            .stdout(Stdio::piped())
144            .stderr(Stdio::piped());
145
146        command
147    }
148
149    fn find_principal(&self, signature_file_path: &Path) -> Result<Option<String>, SshError> {
150        let Some(allowed_signers) = &self.allowed_signers else {
151            return Ok(None);
152        };
153
154        let mut command = self.create_command();
155
156        command
157            .arg("-Y")
158            .arg("find-principals")
159            .arg("-f")
160            .arg(allowed_signers)
161            .arg("-s")
162            .arg(signature_file_path);
163
164        // We can't use the existing run_command helper here as `-Y find-principals`
165        // will return a non-0 exit code if no principals are found.
166        //
167        // In this case we don't want to error out, just return None.
168        tracing::info!(?command, "running SSH signing command");
169        let process = command.spawn()?;
170        let output = process.wait_with_output()?;
171        tracing::info!(?command, ?output.status, "SSH signing command exited");
172
173        let principal = parse_utf8_string(output.stdout)?
174            .split('\n')
175            .next()
176            .unwrap()
177            .trim()
178            .to_string();
179
180        if principal.is_empty() {
181            return Ok(None);
182        }
183        Ok(Some(principal))
184    }
185}
186
187impl SigningBackend for SshBackend {
188    fn name(&self) -> &str {
189        "ssh"
190    }
191
192    fn can_read(&self, signature: &[u8]) -> bool {
193        signature.starts_with(b"-----BEGIN SSH SIGNATURE-----")
194    }
195
196    fn sign(&self, data: &[u8], key: Option<&str>) -> Result<Vec<u8>, SignError> {
197        let Some(key) = key else {
198            return Err(SshError::MissingKey.into());
199        };
200
201        // The ssh-keygen `-f` flag expects to be given a file which contains either a
202        // private or public key.
203        //
204        // As it expects a file and we might have an inlined public key instead, we need
205        // to ensure it is written to a file first.
206        let pub_key_path = ensure_key_as_file(key)?;
207        let mut command = self.create_command();
208
209        let path = match &pub_key_path {
210            either::Left(path) => path.as_os_str(),
211            either::Right(path) => path.as_os_str(),
212        };
213
214        command
215            .arg("-Y")
216            .arg("sign")
217            .arg("-f")
218            .arg(path)
219            .arg("-n")
220            .arg("git");
221
222        Ok(run_command(&mut command, data)?)
223    }
224
225    fn verify(&self, data: &[u8], signature: &[u8]) -> Result<Verification, SignError> {
226        let mut signature_file = tempfile::Builder::new()
227            .prefix(".jj-ssh-sig-")
228            .tempfile()
229            .map_err(SshError::Io)?;
230        signature_file.write_all(signature).map_err(SshError::Io)?;
231        signature_file.flush().map_err(SshError::Io)?;
232
233        let signature_file_path = signature_file.into_temp_path();
234
235        let principal = self.find_principal(&signature_file_path)?;
236
237        let mut command = self.create_command();
238
239        match (principal, self.allowed_signers.as_ref()) {
240            (Some(principal), Some(allowed_signers)) => {
241                command
242                    .arg("-Y")
243                    .arg("verify")
244                    .arg("-s")
245                    .arg(&signature_file_path)
246                    .arg("-I")
247                    .arg(&principal)
248                    .arg("-f")
249                    .arg(allowed_signers)
250                    .arg("-n")
251                    .arg("git");
252
253                let result = run_command(&mut command, data);
254
255                let status = match result {
256                    Ok(_) => SigStatus::Good,
257                    Err(_) => SigStatus::Bad,
258                };
259                Ok(Verification::new(status, None, Some(principal)))
260            }
261            _ => {
262                command
263                    .arg("-Y")
264                    .arg("check-novalidate")
265                    .arg("-s")
266                    .arg(&signature_file_path)
267                    .arg("-n")
268                    .arg("git");
269
270                let result = run_command(&mut command, data);
271
272                match result {
273                    Ok(_) => Ok(Verification::new(
274                        SigStatus::Unknown,
275                        None,
276                        Some("Signature OK. Unknown principal".into()),
277                    )),
278                    Err(_) => Ok(Verification::new(SigStatus::Bad, None, None)),
279                }
280            }
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use std::fs::File;
288    use std::io::Read as _;
289
290    use super::*;
291
292    #[test]
293    fn test_ssh_key_to_file_conversion_raw_key_data() {
294        let keydata = "ssh-ed25519 some-key-data";
295        let path = ensure_key_as_file(keydata).unwrap();
296
297        let mut buf = vec![];
298        let mut file = File::open(path.right().unwrap()).unwrap();
299        file.read_to_end(&mut buf).unwrap();
300
301        assert_eq!("ssh-ed25519 some-key-data", String::from_utf8(buf).unwrap());
302    }
303
304    #[test]
305    fn test_ssh_key_to_file_conversion_existing_file() {
306        let mut file = tempfile::Builder::new()
307            .prefix("jj-signing-key-")
308            .tempfile()
309            .map_err(SshError::Io)
310            .unwrap();
311
312        file.write_all(b"some-data").map_err(SshError::Io).unwrap();
313        file.flush().map_err(SshError::Io).unwrap();
314
315        let file_path = file.into_temp_path();
316
317        let path = ensure_key_as_file(file_path.to_str().unwrap()).unwrap();
318
319        assert_eq!(
320            file_path.to_str().unwrap(),
321            path.left().unwrap().to_str().unwrap()
322        );
323    }
324}