324 lines · 10.0 KB · Rust
RawDownload
// Copyright 2023 The Jujutsu Authors//// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License. #![allow(missing_docs)] use std::ffi::OsString;use std::fmt::Debug;use std::io::Write as _;use std::path::Path;use std::path::PathBuf;use std::process::Command;use std::process::ExitStatus;use std::process::Stdio; use either::Either;use thiserror::Error; use crate::config::ConfigGetError;use crate::config::ConfigGetResultExt as _;use crate::settings::UserSettings;use crate::signing::SigStatus;use crate::signing::SignError;use crate::signing::SigningBackend;use crate::signing::Verification; #[derive(Debug)]pub struct SshBackend {    program: OsString,    allowed_signers: Option<OsString>,} #[derive(Debug, Error)]pub enum SshError {    #[error("SSH sign failed with {exit_status}:\n{stderr}")]    Command {        exit_status: ExitStatus,        stderr: String,    },    #[error("Failed to parse ssh program response")]    BadResult,    #[error("Failed to run ssh-keygen")]    Io(#[from] std::io::Error),    #[error("Signing key required")]    MissingKey,} impl From<SshError> for SignError {    fn from(e: SshError) -> Self {        SignError::Backend(Box::new(e))    }} type SshResult<T> = Result<T, SshError>; fn parse_utf8_string(data: Vec<u8>) -> SshResult<String> {    String::from_utf8(data).map_err(|_| SshError::BadResult)} fn run_command(command: &mut Command, stdin: &[u8]) -> SshResult<Vec<u8>> {    tracing::info!(?command, "running SSH signing command");    let process = command.spawn()?;    let write_result = process.stdin.as_ref().unwrap().write_all(stdin);    let output = process.wait_with_output()?;    tracing::info!(?command, ?output.status, "SSH signing command exited");    if output.status.success() {        write_result?;        Ok(output.stdout)    } else {        Err(SshError::Command {            exit_status: output.status,            stderr: String::from_utf8_lossy(&output.stderr).trim_end().into(),        })    }} // This attempts to convert given key data into a file and return the filepath.// If the given data is actually already a filepath to a key on disk then the// key input is returned directly.fn ensure_key_as_file(key: &str) -> SshResult<Either<PathBuf, tempfile::TempPath>> {    let is_inlined_ssh_key = key.starts_with("ssh-");    if !is_inlined_ssh_key {        let key_path = crate::file_util::expand_home_path(key);        return Ok(either::Left(key_path));    }     let mut pub_key_file = tempfile::Builder::new()        .prefix("jj-signing-key-")        .tempfile()        .map_err(SshError::Io)?;     pub_key_file        .write_all(key.as_bytes())        .map_err(SshError::Io)?;    pub_key_file.flush().map_err(SshError::Io)?;     // This is converted into a TempPath so that the underlying file handle is    // closed. On Windows systems this is required for other programs to be able    // to open the file for reading.    let pub_key_path = pub_key_file.into_temp_path();    Ok(either::Right(pub_key_path))} impl SshBackend {    pub fn new(program: OsString, allowed_signers: Option<OsString>) -> Self {        Self {            program,            allowed_signers,        }    }     pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {        let program = settings.get_string("signing.backends.ssh.program")?;        let allowed_signers = settings            .get_string("signing.backends.ssh.allowed-signers")            .optional()?            .map(|v| crate::file_util::expand_home_path(v.as_str()));        Ok(Self::new(program.into(), allowed_signers.map(|v| v.into())))    }     fn create_command(&self) -> Command {        let mut command = Command::new(&self.program);        // Hide console window on Windows (https://stackoverflow.com/a/60958956)        #[cfg(windows)]        {            use std::os::windows::process::CommandExt as _;            const CREATE_NO_WINDOW: u32 = 0x08000000;            command.creation_flags(CREATE_NO_WINDOW);        }         command            .stdin(Stdio::piped())            .stdout(Stdio::piped())            .stderr(Stdio::piped());         command    }     fn find_principal(&self, signature_file_path: &Path) -> Result<Option<String>, SshError> {        let Some(allowed_signers) = &self.allowed_signers else {            return Ok(None);        };         let mut command = self.create_command();         command            .arg("-Y")            .arg("find-principals")            .arg("-f")            .arg(allowed_signers)            .arg("-s")            .arg(signature_file_path);         // We can't use the existing run_command helper here as `-Y find-principals`        // will return a non-0 exit code if no principals are found.        //        // In this case we don't want to error out, just return None.        tracing::info!(?command, "running SSH signing command");        let process = command.spawn()?;        let output = process.wait_with_output()?;        tracing::info!(?command, ?output.status, "SSH signing command exited");         let principal = parse_utf8_string(output.stdout)?            .split('\n')            .next()            .unwrap()            .trim()            .to_string();         if principal.is_empty() {            return Ok(None);        }        Ok(Some(principal))    }} impl SigningBackend for SshBackend {    fn name(&self) -> &str {        "ssh"    }     fn can_read(&self, signature: &[u8]) -> bool {        signature.starts_with(b"-----BEGIN SSH SIGNATURE-----")    }     fn sign(&self, data: &[u8], key: Option<&str>) -> Result<Vec<u8>, SignError> {        let Some(key) = key else {            return Err(SshError::MissingKey.into());        };         // The ssh-keygen `-f` flag expects to be given a file which contains either a        // private or public key.        //        // As it expects a file and we might have an inlined public key instead, we need        // to ensure it is written to a file first.        let pub_key_path = ensure_key_as_file(key)?;        let mut command = self.create_command();         let path = match &pub_key_path {            either::Left(path) => path.as_os_str(),            either::Right(path) => path.as_os_str(),        };         command            .arg("-Y")            .arg("sign")            .arg("-f")            .arg(path)            .arg("-n")            .arg("git");         Ok(run_command(&mut command, data)?)    }     fn verify(&self, data: &[u8], signature: &[u8]) -> Result<Verification, SignError> {        let mut signature_file = tempfile::Builder::new()            .prefix(".jj-ssh-sig-")            .tempfile()            .map_err(SshError::Io)?;        signature_file.write_all(signature).map_err(SshError::Io)?;        signature_file.flush().map_err(SshError::Io)?;         let signature_file_path = signature_file.into_temp_path();         let principal = self.find_principal(&signature_file_path)?;         let mut command = self.create_command();         match (principal, self.allowed_signers.as_ref()) {            (Some(principal), Some(allowed_signers)) => {                command                    .arg("-Y")                    .arg("verify")                    .arg("-s")                    .arg(&signature_file_path)                    .arg("-I")                    .arg(&principal)                    .arg("-f")                    .arg(allowed_signers)                    .arg("-n")                    .arg("git");                 let result = run_command(&mut command, data);                 let status = match result {                    Ok(_) => SigStatus::Good,                    Err(_) => SigStatus::Bad,                };                Ok(Verification::new(status, None, Some(principal)))            }            _ => {                command                    .arg("-Y")                    .arg("check-novalidate")                    .arg("-s")                    .arg(&signature_file_path)                    .arg("-n")                    .arg("git");                 let result = run_command(&mut command, data);                 match result {                    Ok(_) => Ok(Verification::new(                        SigStatus::Unknown,                        None,                        Some("Signature OK. Unknown principal".into()),                    )),                    Err(_) => Ok(Verification::new(SigStatus::Bad, None, None)),                }            }        }    }} #[cfg(test)]mod tests {    use std::fs::File;    use std::io::Read as _;     use super::*;     #[test]    fn test_ssh_key_to_file_conversion_raw_key_data() {        let keydata = "ssh-ed25519 some-key-data";        let path = ensure_key_as_file(keydata).unwrap();         let mut buf = vec![];        let mut file = File::open(path.right().unwrap()).unwrap();        file.read_to_end(&mut buf).unwrap();         assert_eq!("ssh-ed25519 some-key-data", String::from_utf8(buf).unwrap());    }     #[test]    fn test_ssh_key_to_file_conversion_existing_file() {        let mut file = tempfile::Builder::new()            .prefix("jj-signing-key-")            .tempfile()            .map_err(SshError::Io)            .unwrap();         file.write_all(b"some-data").map_err(SshError::Io).unwrap();        file.flush().map_err(SshError::Io).unwrap();         let file_path = file.into_temp_path();         let path = ensure_key_as_file(file_path.to_str().unwrap()).unwrap();         assert_eq!(            file_path.to_str().unwrap(),            path.left().unwrap().to_str().unwrap()        );    }}