use std::ffi::{OsStr, OsString};
use std::process::{ExitStatus, Output};
use crate::workflow::Environment;
use crate::{Commit, CommitPayload, Config, ObjectFormat, Tag, TagError, TagFields};
mod gpg;
mod ssh;
pub use gpg::{GpgSigner, GpgVerifier};
pub use ssh::{SshKey, SshSigner, SshVerifier};
const SHA1_HEADER: &[u8] = b"gpgsig";
const SHA256_HEADER: &[u8] = b"gpgsig-sha256";
const ARMOR: [(&[u8], SignatureFormat); 4] = [
(b"-----BEGIN PGP SIGNATURE-----", SignatureFormat::OpenPgp),
(b"-----BEGIN PGP MESSAGE-----", SignatureFormat::OpenPgp),
(b"-----BEGIN SIGNED MESSAGE-----", SignatureFormat::X509),
(b"-----BEGIN SSH SIGNATURE-----", SignatureFormat::Ssh),
];
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum SignatureFormat {
OpenPgp,
X509,
Ssh,
}
impl SignatureFormat {
pub fn detect(signature: &[u8]) -> Option<Self> {
ARMOR
.iter()
.find(|(marker, _)| signature.starts_with(marker))
.map(|&(_, format)| format)
}
pub fn as_str(self) -> &'static str {
match self {
Self::OpenPgp => "openpgp",
Self::X509 => "x509",
Self::Ssh => "ssh",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SignedPayload {
payload: Vec<u8>,
signature: Vec<u8>,
signed_at: Option<i64>,
}
impl SignedPayload {
pub fn payload(&self) -> &[u8] {
&self.payload
}
pub fn signature(&self) -> &[u8] {
&self.signature
}
pub fn format(&self) -> Option<SignatureFormat> {
SignatureFormat::detect(&self.signature)
}
pub fn signed_at(&self) -> Option<i64> {
self.signed_at
}
pub fn into_parts(self) -> (Vec<u8>, Vec<u8>) {
(self.payload, self.signature)
}
}
impl Commit {
pub fn signed_payload(&self) -> Option<SignedPayload> {
let bytes = self.as_bytes();
let view = CommitPayload::from_bytes(bytes);
let native = native_header(self.object_format());
let mut signature = Vec::new();
let mut found = false;
let mut removed = Vec::new();
for (index, header) in view.headers().enumerate() {
if !header.has_value_separator {
continue;
}
if header.name == native {
found = true;
signature.extend_from_slice(&header.unfolded_value());
if is_terminated(bytes, header.value) {
signature.push(b'\n');
}
}
if header.name == SHA1_HEADER || header.name == SHA256_HEADER {
removed.push(index);
}
}
if !found {
return None;
}
let payload = view.without_headers(&removed)?;
let signed_at = self
.committer()
.ok()
.flatten()
.and_then(|identity| identity.date().ok())
.map(|date| date.seconds);
Some(SignedPayload {
payload,
signature,
signed_at,
})
}
pub fn merge_tags(&self) -> impl Iterator<Item = Result<Tag, TagError>> + '_ {
let format = self.object_format();
let commit = self.as_bytes();
CommitPayload::from_bytes(commit)
.headers()
.filter(|header| header.has_value_separator && header.name == b"mergetag")
.map(|header| {
let mut bytes = header.unfolded_value();
if is_terminated(commit, header.value) {
bytes.push(b'\n');
}
bytes
})
.collect::<Vec<_>>()
.into_iter()
.map(move |bytes| Tag::parse(format, &bytes))
}
}
impl Tag {
pub fn signed_payload(&self) -> Option<SignedPayload> {
let bytes = self.as_bytes();
let start = line_starts(bytes)
.filter(|&start| {
bytes
.get(start..)
.is_some_and(|line| SignatureFormat::detect(line).is_some())
})
.last()?;
let (signed, signature) = bytes.split_at_checked(start)?;
let view = CommitPayload::from_bytes(signed);
let is_signature: Vec<bool> = view
.headers()
.map(|header| {
header.has_value_separator
&& (header.name == SHA1_HEADER || header.name == SHA256_HEADER)
})
.collect();
let removed: Vec<usize> = (0..is_signature.len())
.filter(|&index| {
is_signature.get(index) == Some(&true) && is_signature.get(index + 1) != Some(&true)
})
.collect();
let signed_at = self
.tagger()
.ok()
.flatten()
.and_then(|identity| identity.date().ok())
.map(|date| date.seconds);
Some(SignedPayload {
payload: view.without_headers(&removed)?,
signature: signature.to_vec(),
signed_at,
})
}
pub fn new_signed<E>(
mut fields: TagFields,
sign: impl FnOnce(&[u8]) -> Result<Vec<u8>, E>,
) -> Result<Self, SignTagError<E>> {
if !fields.message.is_empty() && !fields.message.ends_with(b"\n") {
return Err(SignTagError::MessageNewline);
}
let signature_header = fields
.extra_headers
.iter()
.filter_map(|line| crate::bytes::split_once(line, b' '))
.any(|(name, _)| name == SHA1_HEADER || name == SHA256_HEADER);
let unsigned = Self::new(fields.clone()).map_err(SignTagError::Tag)?;
if signature_header || unsigned.signed_payload().is_some() {
return Err(SignTagError::ExistingSignature);
}
let signature = sign(unsigned.as_bytes()).map_err(SignTagError::Signer)?;
fields.message.extend_from_slice(&signature);
Self::new(fields).map_err(SignTagError::Tag)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SignTagError<E> {
#[error(transparent)]
Tag(TagError),
#[error("tag message must end with a newline before it is signed")]
MessageNewline,
#[error("tag already contains a signature")]
ExistingSignature,
#[error("signing tag")]
Signer(#[source] E),
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum Programs {
#[default]
Refuse,
Run,
}
impl Programs {
fn check(self, program: &OsStr) -> Result<(), Error> {
match self {
Self::Refuse => Err(Error::Refused {
program: program.to_owned(),
}),
Self::Run => Ok(()),
}
}
}
pub trait Signer {
fn format(&self) -> SignatureFormat;
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, Error>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct Verification {
pub status: Status,
pub signer: Option<String>,
pub key: Option<String>,
pub fingerprint: Option<String>,
pub primary_fingerprint: Option<String>,
pub trust: Trust,
pub output: Vec<u8>,
accepted: bool,
}
impl Verification {
pub fn is_good(&self) -> bool {
self.accepted
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum Status {
NoSignature,
Good,
Bad,
UnknownValidity,
ExpiredSignature,
ExpiredKey,
RevokedKey,
CannotCheck,
}
impl Status {
pub fn letter(self) -> char {
match self {
Self::NoSignature => 'N',
Self::Good => 'G',
Self::Bad => 'B',
Self::UnknownValidity => 'U',
Self::ExpiredSignature => 'X',
Self::ExpiredKey => 'Y',
Self::RevokedKey => 'R',
Self::CannotCheck => 'E',
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum Trust {
Undefined,
Never,
Marginal,
Fully,
Ultimate,
}
impl Trust {
pub fn at_least(self, minimum: Self) -> bool {
self.rank() >= minimum.rank()
}
fn rank(self) -> u8 {
match self {
Self::Never => 0,
Self::Undefined => 1,
Self::Marginal => 2,
Self::Fully => 3,
Self::Ultimate => 4,
}
}
pub fn parse(value: &str) -> Option<Self> {
[
Self::Never,
Self::Undefined,
Self::Marginal,
Self::Fully,
Self::Ultimate,
]
.into_iter()
.find(|trust| trust.as_str().eq_ignore_ascii_case(value))
}
pub fn as_str(self) -> &'static str {
match self {
Self::Undefined => "undefined",
Self::Never => "never",
Self::Marginal => "marginal",
Self::Fully => "fully",
Self::Ultimate => "ultimate",
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("running {program:?} is not allowed")]
Refused {
program: OsString,
},
#[error("either user.signingKey or gpg.ssh.defaultKeyCommand needs to be configured")]
NoSigningKey,
#[error("gpg.ssh.allowedSignersFile needs to be configured for ssh signature verification")]
NoAllowedSigners,
#[error("signature is not an ssh signature")]
NotSsh,
#[error("{} signatures are not handled here", format.as_str())]
WrongFormat {
format: SignatureFormat,
},
#[error("bad/incompatible signature")]
UnknownFormat,
#[error(transparent)]
Config(#[from] crate::config::InvalidValue),
#[error("no signing key and no committer identity")]
Identity(#[source] crate::CommitError),
#[error("unusable path: {0}")]
Path(&'static str),
#[error("running {program:?}")]
Spawn {
program: OsString,
#[source]
source: std::io::Error,
},
#[error("{program:?} failed with {status}: {stderr}")]
Failed {
program: OsString,
status: ExitStatus,
stderr: String,
},
#[error("writing a temporary file")]
Temporary(#[source] std::io::Error),
}
fn min_trust(config: &Config) -> Result<Option<Trust>, Error> {
config
.value("gpg", None, "mintrustlevel")
.map(|value| {
value
.and_then(|value| std::str::from_utf8(value).ok())
.and_then(Trust::parse)
.ok_or_else(|| {
crate::config::InvalidValue::new("gpg", "minTrustLevel", value).into()
})
})
.transpose()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct Settings {
pub format: SignatureFormat,
pub sign_commits: bool,
pub sign_tags: bool,
}
impl Settings {
pub fn from_config(config: &Config) -> Result<Self, Error> {
let format = match config.value("gpg", None, "format") {
None => SignatureFormat::OpenPgp,
Some(Some(b"openpgp")) => SignatureFormat::OpenPgp,
Some(Some(b"x509")) => SignatureFormat::X509,
Some(Some(b"ssh")) => SignatureFormat::Ssh,
Some(value) => {
return Err(crate::config::InvalidValue::new("gpg", "format", value).into());
}
};
Ok(Self {
format,
sign_commits: config.boolean("commit", None, "gpgsign")?.unwrap_or(false),
sign_tags: config.boolean("tag", None, "gpgsign")?.unwrap_or(false),
})
}
pub fn signer(
&self,
config: &Config,
environment: &Environment,
programs: Programs,
) -> Result<Box<dyn Signer + Send + Sync>, Error> {
Ok(match self.format {
SignatureFormat::Ssh => Box::new(
SshSigner::from_config(config, environment, programs)?
.with_environment(environment.clone()),
),
format => Box::new(
GpgSigner::from_config(config, environment, format, programs)?
.with_environment(environment.clone()),
),
})
}
}
#[derive(Clone, Debug)]
pub struct Verifier {
ssh: SshVerifier,
gpg: GpgVerifier,
}
impl Verifier {
pub fn new(ssh: SshVerifier, gpg: GpgVerifier) -> Self {
Self { ssh, gpg }
}
pub fn from_config(
config: &Config,
environment: &Environment,
programs: Programs,
) -> Result<Self, Error> {
Ok(Self {
ssh: SshVerifier::from_config(config, environment, programs)?,
gpg: GpgVerifier::from_config(config, programs)?,
})
}
#[must_use]
pub fn with_environment(self, environment: Environment) -> Self {
Self {
ssh: self.ssh.with_environment(environment.clone()),
gpg: self.gpg.with_environment(environment),
}
}
pub fn verify(&self, signed: &SignedPayload) -> Result<Verification, Error> {
match signed.format() {
Some(SignatureFormat::Ssh) => self.ssh.verify(signed),
Some(_) => self.gpg.verify(signed),
None => Err(Error::UnknownFormat),
}
}
}
impl Error {
fn failed(program: &OsStr, output: &Output) -> Self {
Self::Failed {
program: program.to_owned(),
status: output.status,
stderr: String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_owned(),
}
}
}
fn native_header(format: ObjectFormat) -> &'static [u8] {
match format {
ObjectFormat::Sha1 => SHA1_HEADER,
ObjectFormat::Sha256 => SHA256_HEADER,
}
}
fn is_terminated(bytes: &[u8], value: &[u8]) -> bool {
let end = (value.as_ptr() as usize - bytes.as_ptr() as usize) + value.len();
bytes.get(end) == Some(&b'\n')
}
fn line_starts(bytes: &[u8]) -> impl Iterator<Item = usize> + '_ {
std::iter::once(0).chain(
bytes
.iter()
.enumerate()
.filter(|&(_, &byte)| byte == b'\n')
.map(|(index, _)| index + 1),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn tag(format: ObjectFormat, rest: &str) -> Tag {
let id = "1".repeat(format.digest_len() * 2);
let bytes = format!("object {id}\ntype commit\ntag t\n{rest}");
Tag::parse(format, bytes.as_bytes()).unwrap()
}
#[test]
fn last_armor_line_starts_the_tag_signature() {
let tag = tag(
ObjectFormat::Sha1,
"\nhi\n-----BEGIN SSH SIGNATURE-----\nA\n-----END SSH SIGNATURE-----\nmid\n-----BEGIN PGP SIGNATURE-----\nB\n",
);
let signed = tag.signed_payload().unwrap();
assert!(
signed
.payload()
.ends_with(b"-----END SSH SIGNATURE-----\nmid\n")
);
assert_eq!(signed.signature(), b"-----BEGIN PGP SIGNATURE-----\nB\n");
assert_eq!(signed.format(), Some(SignatureFormat::OpenPgp));
}
#[test]
fn armor_must_start_a_line_and_match_whole_marker() {
for message in [
"\nhi x-----BEGIN SSH SIGNATURE-----\n",
"\n-----BEGIN SSH SIGNATUREX-----\n",
] {
assert_eq!(tag(ObjectFormat::Sha1, message).signed_payload(), None);
}
let trailing = tag(ObjectFormat::Sha1, "\n-----BEGIN PGP SIGNATURE----- x\n");
assert_eq!(
trailing.signed_payload().unwrap().payload().last(),
Some(&b'\n')
);
}
#[test]
fn tag_payload_drops_last_signature_header_of_each_run() {
for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] {
let tag = tag(
format,
"gpgsig A\n a\ngpgsig-sha256 B\nx-mid m\ngpgsig C\n\nmsg\ngpgsig body\n-----BEGIN PGP SIGNATURE-----\n",
);
let payload = tag.signed_payload().unwrap().into_parts().0;
assert!(payload.ends_with(b"tag t\ngpgsig A\n a\nx-mid m\n\nmsg\ngpgsig body\n"));
}
}
#[test]
fn repeated_commit_signature_headers_are_joined() {
let bytes = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
gpgsig-sha256 other\ngpgsig -----BEGIN PGP SIGNATURE-----\n one\nx keep\ngpgsig two\n\nmsg\n";
let commit = Commit::parse(ObjectFormat::Sha1, bytes).unwrap();
let signed = commit.signed_payload().unwrap();
assert_eq!(
signed.signature(),
b"-----BEGIN PGP SIGNATURE-----\none\ntwo\n"
);
assert_eq!(
signed.payload(),
b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nx keep\n\nmsg\n"
);
}
#[test]
fn unterminated_final_headers_gain_no_line_feed() {
let bytes = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
gpgsig -----BEGIN PGP SIGNATURE-----\n A";
let commit = Commit::parse(ObjectFormat::Sha1, bytes).unwrap();
assert_eq!(
commit.signed_payload().unwrap().signature(),
b"-----BEGIN PGP SIGNATURE-----\nA"
);
let tag = format!(
"object {}\n type commit\n tag v1\n \n Release",
"1".repeat(40)
);
let bytes = format!("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nmergetag {tag}");
let commit = Commit::parse(ObjectFormat::Sha1, bytes.as_bytes()).unwrap();
let merged = commit.merge_tags().next().unwrap().unwrap();
assert_eq!(merged.message(), b"Release");
}
#[test]
fn signing_refuses_tags_with_signature_headers() {
for header in [&b"gpgsig old"[..], b"gpgsig-sha256 old"] {
let fields = TagFields {
target: "1".repeat(40).parse().unwrap(),
target_kind: crate::ObjectKind::Commit,
name: b"t".to_vec(),
tagger: None,
extra_headers: vec![header.to_vec()],
message: b"msg\n".to_vec(),
};
let signed = Tag::new_signed(fields, |_| Ok::<_, std::io::Error>(Vec::new()));
assert!(matches!(signed, Err(SignTagError::ExistingSignature)));
}
}
#[test]
fn other_format_header_alone_is_unsigned() {
let bytes = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\ngpgsig-sha256 s\n\nmsg\n";
let commit = Commit::parse(ObjectFormat::Sha1, bytes).unwrap();
assert_eq!(commit.signed_payload(), None);
}
#[test]
fn unknown_armor_has_no_format() {
let bytes = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\ngpgsig hello\n\nmsg\n";
let commit = Commit::parse(ObjectFormat::Sha1, bytes).unwrap();
assert_eq!(commit.signed_payload().unwrap().format(), None);
}
}