forked from mirrors/jj
sign: Implement SSH signing backend
This commit is contained in:
parent
7c11a61c23
commit
5e24677301
3 changed files with 311 additions and 1 deletions
|
@ -63,6 +63,7 @@ pub mod settings;
|
|||
pub mod signing;
|
||||
pub mod simple_op_heads_store;
|
||||
pub mod simple_op_store;
|
||||
pub mod ssh_signing;
|
||||
pub mod stacked_table;
|
||||
pub mod store;
|
||||
pub mod str_util;
|
||||
|
|
|
@ -24,6 +24,7 @@ use thiserror::Error;
|
|||
use crate::backend::CommitId;
|
||||
use crate::gpg_signing::GpgBackend;
|
||||
use crate::settings::UserSettings;
|
||||
use crate::ssh_signing::SshBackend;
|
||||
|
||||
/// A status of the signature, part of the [Verification] type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
@ -162,7 +163,7 @@ impl Signer {
|
|||
pub fn from_settings(settings: &UserSettings) -> Result<Self, SignInitError> {
|
||||
let mut backends = vec![
|
||||
Box::new(GpgBackend::from_config(settings.config())) as Box<dyn SigningBackend>,
|
||||
// Box::new(SshBackend::from_settings(settings)?) as Box<dyn SigningBackend>,
|
||||
Box::new(SshBackend::from_config(settings.config())) as Box<dyn SigningBackend>,
|
||||
// Box::new(X509Backend::from_settings(settings)?) as Box<dyn SigningBackend>,
|
||||
];
|
||||
|
||||
|
|
308
lib/src/ssh_signing.rs
Normal file
308
lib/src/ssh_signing.rs
Normal file
|
@ -0,0 +1,308 @@
|
|||
// 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;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus, Stdio};
|
||||
|
||||
use either::Either;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::signing::{SigStatus, SignError, SigningBackend, 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 {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>> {
|
||||
let process = command.spawn()?;
|
||||
process.stdin.as_ref().unwrap().write_all(stdin)?;
|
||||
|
||||
let output = process.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(SshError::Command {
|
||||
exit_status: output.status,
|
||||
stderr: String::from_utf8_lossy(&output.stderr).trim_end().into(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
// 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 = Path::new(key);
|
||||
return Ok(either::Left(key_path.to_path_buf()));
|
||||
}
|
||||
|
||||
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_config(config: &config::Config) -> Self {
|
||||
Self::new(
|
||||
config
|
||||
.get_string("signing.backends.ssh.program")
|
||||
.unwrap_or_else(|_| "ssh-keygen".into())
|
||||
.into(),
|
||||
config
|
||||
.get_string("signing.backends.ssh.allowed-signers")
|
||||
.map_or(None, |v| Some(v.into())),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_command(&self) -> Command {
|
||||
let mut command = Command::new(&self.program);
|
||||
|
||||
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.
|
||||
let process = command.spawn()?;
|
||||
let output = process.wait_with_output()?;
|
||||
|
||||
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, Write};
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue