use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use super::{Error, Programs, SignatureFormat, SignedPayload, Signer, Status, Trust, Verification};
use crate::Config;
use crate::workflow::Environment;
const NAMESPACE: &str = "git";
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SshKey {
Path(PathBuf),
Literal(String),
}
#[derive(Clone, Debug)]
pub struct SshSigner {
key: SshKey,
program: OsString,
programs: Programs,
environment: Option<Environment>,
}
impl SshSigner {
pub fn new(key: SshKey) -> Self {
Self {
key,
program: "ssh-keygen".into(),
programs: Programs::Refuse,
environment: None,
}
}
pub fn from_config(
config: &Config,
environment: &Environment,
programs: Programs,
) -> Result<Self, Error> {
let key = match config.string("user", None, "signingkey") {
Some(value) if !value.is_empty() => parse_key(value, environment)?,
_ => {
let command = config
.string("gpg", Some(b"ssh"), "defaultkeycommand")
.filter(|command| !command.is_empty())
.ok_or(Error::NoSigningKey)?;
default_key(command, programs, environment)?
}
};
let mut signer = Self::new(key).with_programs(programs);
if let Some(program) = program(config)? {
signer.program = program;
}
Ok(signer)
}
#[must_use]
pub fn with_programs(mut self, programs: Programs) -> Self {
self.programs = programs;
self
}
#[must_use]
pub fn with_program(mut self, program: impl Into<OsString>) -> Self {
self.program = program.into();
self
}
#[must_use]
pub fn with_environment(mut self, environment: Environment) -> Self {
self.environment = Some(environment);
self
}
pub fn key(&self) -> &SshKey {
&self.key
}
}
impl Signer for SshSigner {
fn format(&self) -> SignatureFormat {
SignatureFormat::Ssh
}
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, Error> {
self.programs.check(&self.program)?;
let literal;
let mut args: Vec<OsString> =
vec!["-Y".into(), "sign".into(), "-n".into(), NAMESPACE.into()];
match &self.key {
SshKey::Path(path) => args.extend(["-f".into(), path.into()]),
SshKey::Literal(key) => {
literal = temporary(key.as_bytes())?;
args.extend(["-f".into(), literal.path().into(), "-U".into()]);
}
}
let output = run(&self.program, &args, payload, self.environment.as_ref())?;
if !output.status.success() {
return Err(Error::failed(&self.program, &output));
}
Ok(output.stdout)
}
}
#[derive(Clone, Debug)]
pub struct SshVerifier {
allowed_signers: Option<PathBuf>,
revocations: Option<PathBuf>,
program: OsString,
programs: Programs,
environment: Option<Environment>,
}
impl SshVerifier {
pub fn new(allowed_signers: impl Into<PathBuf>) -> Self {
Self {
allowed_signers: Some(allowed_signers.into()),
revocations: None,
program: "ssh-keygen".into(),
programs: Programs::Refuse,
environment: None,
}
}
pub fn from_config(
config: &Config,
environment: &Environment,
programs: Programs,
) -> Result<Self, Error> {
let path = |name| {
config
.string("gpg", Some(b"ssh"), name)
.map(|value| expand(value, environment))
.transpose()
};
Ok(Self {
allowed_signers: path("allowedsignersfile")?,
revocations: path("revocationfile")?.filter(|path| !path.as_os_str().is_empty()),
program: program(config)?.unwrap_or_else(|| "ssh-keygen".into()),
programs,
environment: None,
})
}
#[must_use]
pub fn with_revocations(mut self, revocations: impl Into<PathBuf>) -> Self {
self.revocations = Some(revocations.into());
self
}
#[must_use]
pub fn with_programs(mut self, programs: Programs) -> Self {
self.programs = programs;
self
}
#[must_use]
pub fn with_program(mut self, program: impl Into<OsString>) -> Self {
self.program = program.into();
self
}
#[must_use]
pub fn with_environment(mut self, environment: Environment) -> Self {
self.environment = Some(environment);
self
}
pub fn verify(&self, signed: &SignedPayload) -> Result<Verification, Error> {
if signed.format() != Some(SignatureFormat::Ssh) {
return Err(Error::NotSsh);
}
let allowed = self
.allowed_signers
.as_ref()
.ok_or(Error::NoAllowedSigners)?;
self.programs.check(&self.program)?;
let signature = temporary(signed.signature())?;
let mut options: Vec<OsString> = vec!["-s".into(), signature.path().into()];
if let Some(time) = signed.signed_at().and_then(verify_time) {
options.push(format!("-Overify-time={time}").into());
}
let mut find: Vec<OsString> = vec!["-Y".into(), "find-principals".into()];
find.extend(["-f".into(), allowed.into()]);
find.extend(options.iter().cloned());
let found = run(&self.program, &find, b"", self.environment.as_ref())?;
let principal = found
.status
.success()
.then(|| found.stdout.split(|&byte| byte == b'\n').next())
.flatten()
.filter(|line| !line.is_empty());
let mut args: Vec<OsString> = vec!["-Y".into()];
match principal {
Some(principal) => {
args.extend(["verify".into(), "-n".into(), NAMESPACE.into()]);
args.extend(["-f".into(), allowed.into(), "-I".into()]);
args.push(String::from_utf8_lossy(principal).into_owned().into());
args.extend(options);
if let Some(revocations) = &self.revocations {
args.extend(["-r".into(), revocations.into()]);
}
}
None => {
args.extend(["check-novalidate".into(), "-n".into(), NAMESPACE.into()]);
args.extend(options);
}
}
let output = run(
&self.program,
&args,
signed.payload(),
self.environment.as_ref(),
)?;
let mut report = output.stdout.clone();
report.extend_from_slice(&found.stderr);
report.extend_from_slice(&output.stderr);
Ok(interpret(
output.status.success(),
principal.is_some(),
&output.stdout,
report,
))
}
}
fn interpret(success: bool, validated: bool, stdout: &[u8], output: Vec<u8>) -> Verification {
if !success {
return Verification {
status: Status::Bad,
signer: None,
fingerprint: None,
trust: Trust::Never,
output,
};
}
let line = String::from_utf8_lossy(
stdout
.split(|&byte| byte == b'\n')
.find(|line| line.starts_with(b"Good \""))
.unwrap_or_default(),
)
.into_owned();
let fingerprint = line
.rsplit_once(" key ")
.map(|(_, fingerprint)| fingerprint.to_owned());
let signer = line
.split_once(" signature for ")
.and_then(|(_, rest)| rest.rsplit_once(" with "))
.map(|(principal, _)| principal.to_owned());
let (status, trust) = if validated {
(Status::Good, Trust::Fully)
} else {
(Status::UnknownValidity, Trust::Undefined)
};
Verification {
status,
signer: signer.filter(|_| validated),
fingerprint,
trust,
output,
}
}
fn verify_time(seconds: i64) -> Option<String> {
let time = jiff::Timestamp::from_second(seconds).ok()?;
Some(time.strftime("%Y%m%d%H%M%SZ").to_string())
}
fn parse_key(value: &[u8], environment: &Environment) -> Result<SshKey, Error> {
let text = std::str::from_utf8(value).map_err(|_| Error::Path("user.signingKey"))?;
if let Some(key) = text.strip_prefix("key::") {
return Ok(SshKey::Literal(key.to_owned()));
}
if text.starts_with("ssh-") {
return Ok(SshKey::Literal(text.to_owned()));
}
Ok(SshKey::Path(expand(value, environment)?))
}
fn default_key(
command: &[u8],
programs: Programs,
environment: &Environment,
) -> Result<SshKey, Error> {
let command = String::from_utf8_lossy(command).into_owned();
programs.check(command.as_ref())?;
let output = run(
"sh".as_ref(),
&["-c".into(), command.clone().into()],
b"",
Some(environment),
)?;
if !output.status.success() {
return Err(Error::failed(command.as_ref(), &output));
}
let first = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or_default()
.to_owned();
match first.strip_prefix("key::") {
Some(key) => Ok(SshKey::Literal(key.to_owned())),
None if first.starts_with("ssh-") => Ok(SshKey::Literal(first)),
None => Err(Error::NoSigningKey),
}
}
fn program(config: &Config) -> Result<Option<OsString>, Error> {
config
.string("gpg", Some(b"ssh"), "program")
.filter(|value| !value.is_empty())
.map(|value| {
crate::source_files::os_path(value)
.map(PathBuf::into_os_string)
.ok_or(Error::Path("gpg.ssh.program"))
})
.transpose()
}
fn expand(value: &[u8], environment: &Environment) -> Result<PathBuf, Error> {
let path = |bytes| crate::source_files::os_path(bytes).ok_or(Error::Path("ssh signing path"));
match value.strip_prefix(b"~/") {
Some(rest) => {
let home = environment
.get("HOME")
.ok_or(Error::Path("~/ without HOME"))?;
Ok(Path::new(home).join(path(rest)?))
}
None => path(value),
}
}
fn temporary(contents: &[u8]) -> Result<tempfile::NamedTempFile, Error> {
let mut file = tempfile::NamedTempFile::new().map_err(Error::Temporary)?;
file.write_all(contents).map_err(Error::Temporary)?;
file.flush().map_err(Error::Temporary)?;
Ok(file)
}
fn run(
program: &std::ffi::OsStr,
args: &[OsString],
input: &[u8],
environment: Option<&Environment>,
) -> Result<Output, Error> {
let spawn = |source| Error::Spawn {
program: program.to_owned(),
source,
};
let mut command = Command::new(program);
if let Some(environment) = environment {
command.env_clear().envs(environment.vars());
}
let mut child = command
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(spawn)?;
let stdin = child.stdin.take();
let (written, output) = std::thread::scope(|scope| {
let writer = scope.spawn(move || stdin.map(|mut stdin| stdin.write_all(input)));
let output = child.wait_with_output();
(writer.join(), output)
});
let output = output.map_err(spawn)?;
match written {
Ok(Some(Err(error))) if error.kind() != std::io::ErrorKind::BrokenPipe => Err(spawn(error)),
Ok(_) => Ok(output),
Err(panic) => std::panic::resume_unwind(panic),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn large_input_and_output_do_not_deadlock() {
let input = vec![b'x'; 1 << 20];
let args = [
"-c".into(),
"head -c 1048576 /dev/zero; cat >/dev/null".into(),
];
let output = run("sh".as_ref(), &args, &input, None).unwrap();
assert!(output.status.success());
assert_eq!(output.stdout.len(), 1 << 20);
}
#[test]
fn verify_time_is_utc() {
assert_eq!(verify_time(1_700_000_000).unwrap(), "20231114221320Z");
}
#[test]
fn good_lines_give_signer_and_fingerprint() {
let stdout = b"Good \"git\" signature for a@x with ED25519 key SHA256:abc\n";
let verification = interpret(true, true, stdout, Vec::new());
assert_eq!(verification.status, Status::Good);
assert_eq!(verification.signer.as_deref(), Some("a@x"));
assert_eq!(verification.fingerprint.as_deref(), Some("SHA256:abc"));
let stdout = b"Good \"git\" signature with ED25519 key SHA256:abc\n";
let verification = interpret(true, false, stdout, Vec::new());
assert_eq!(verification.status, Status::UnknownValidity);
assert_eq!(verification.signer, None);
assert_eq!(verification.fingerprint.as_deref(), Some("SHA256:abc"));
}
#[test]
fn signing_keys_are_literal_or_paths() {
let environment = Environment::default().with("HOME", "/home/me");
assert_eq!(
parse_key(b"key::ssh-ed25519 AAAA", &environment).unwrap(),
SshKey::Literal("ssh-ed25519 AAAA".into())
);
assert_eq!(
parse_key(b"ssh-ed25519 AAAA", &environment).unwrap(),
SshKey::Literal("ssh-ed25519 AAAA".into())
);
assert_eq!(
parse_key(b"~/.ssh/id", &environment).unwrap(),
SshKey::Path("/home/me/.ssh/id".into())
);
}
}