use std::io::Write as _;
use std::path::Path;
use std::sync::Arc;
use base64::Engine as _;
use sha2::Digest as _;
use crate::agent_identity::{AgentIdentity, SigningMode};
pub trait CommitSigner: Send + Sync {
fn sign(&self, payload: &[u8]) -> Result<String, String>;
}
#[must_use]
pub fn with_signature(commit: &[u8], armored: &str) -> Vec<u8> {
agent_toolchain::native_git::with_signature(commit, armored)
}
pub fn signer_for(identity: &AgentIdentity) -> Result<Option<Arc<dyn CommitSigner>>, String> {
match identity.git.signing {
SigningMode::Off => Ok(None),
SigningMode::Harness => {
let path = identity.signing_key_path().ok_or(
"harness signing needs `signing_key` in agent-identity.toml \
(/settings git-signing harness creates one)",
)?;
Ok(Some(Arc::new(HarnessSshSigner::load(&path)?)))
}
SigningMode::Operator => {
let listing = crate::git_hardening::ambient_git_config_listing()
.map_err(|e| format!("could not read your git config: {e}"))?;
Ok(Some(Arc::new(OperatorSigner::from_listing(&listing)?)))
}
}
}
#[must_use]
pub fn session_signer(identity: &AgentIdentity) -> Option<Arc<dyn CommitSigner>> {
match signer_for(identity) {
Ok(signer) => signer,
Err(reason) => Some(Arc::new(Refusing(reason))),
}
}
struct Refusing(String);
impl CommitSigner for Refusing {
fn sign(&self, _payload: &[u8]) -> Result<String, String> {
Err(self.0.clone())
}
}
pub fn generate_harness_key(path: &Path) -> Result<String, String> {
let key = agent_mesh_protocol::UserKey::generate();
key.save(path).map_err(|e| e.to_string())?;
Ok(openssh_public(&key.public().as_bytes()))
}
pub struct HarnessSshSigner {
key: agent_mesh_protocol::UserKey,
}
impl HarnessSshSigner {
pub fn load(path: &Path) -> Result<Self, String> {
agent_mesh_protocol::UserKey::load(path)
.map(|key| Self { key })
.map_err(|e| format!("signing key {}: {e}", path.display()))
}
#[must_use]
pub fn public_openssh(&self) -> String {
openssh_public(&self.key.public().as_bytes())
}
}
impl CommitSigner for HarnessSshSigner {
fn sign(&self, payload: &[u8]) -> Result<String, String> {
const NAMESPACE: &str = "git";
const HASH: &str = "sha512";
let public = ssh_ed25519_public_blob(&self.key.public().as_bytes());
let mut signed = b"SSHSIG".to_vec();
ssh_string(&mut signed, NAMESPACE.as_bytes());
ssh_string(&mut signed, b"");
ssh_string(&mut signed, HASH.as_bytes());
ssh_string(&mut signed, &sha2::Sha512::digest(payload));
let signature = self.key.sign(&signed).to_bytes();
let mut sig_blob = Vec::new();
ssh_string(&mut sig_blob, b"ssh-ed25519");
ssh_string(&mut sig_blob, &signature);
let mut blob = b"SSHSIG".to_vec();
blob.extend_from_slice(&1u32.to_be_bytes());
ssh_string(&mut blob, &public);
ssh_string(&mut blob, NAMESPACE.as_bytes());
ssh_string(&mut blob, b"");
ssh_string(&mut blob, HASH.as_bytes());
ssh_string(&mut blob, &sig_blob);
let encoded = base64::engine::general_purpose::STANDARD.encode(blob);
let mut armored = String::from("-----BEGIN SSH SIGNATURE-----\n");
for line in encoded.as_bytes().chunks(70) {
armored.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
armored.push('\n');
}
armored.push_str("-----END SSH SIGNATURE-----\n");
Ok(armored)
}
}
fn ssh_string(out: &mut Vec<u8>, bytes: &[u8]) {
let len = u32::try_from(bytes.len()).expect("SSH fields are far below 4 GiB");
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(bytes);
}
fn ssh_ed25519_public_blob(public: &[u8; 32]) -> Vec<u8> {
let mut blob = Vec::new();
ssh_string(&mut blob, b"ssh-ed25519");
ssh_string(&mut blob, public);
blob
}
fn openssh_public(public: &[u8; 32]) -> String {
format!(
"ssh-ed25519 {}",
base64::engine::general_purpose::STANDARD.encode(ssh_ed25519_public_blob(public))
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
Ssh,
OpenPgp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperatorSigner {
format: Format,
key: String,
program: String,
}
const SIGNER_ENV: &[&str] = &[
"PATH",
"HOME",
"USER",
"SSH_AUTH_SOCK",
"GNUPGHOME",
"GPG_TTY",
"TERM",
"DISPLAY",
];
impl OperatorSigner {
pub fn from_listing(listing: &str) -> Result<Self, String> {
let get = |key: &str| {
listing
.lines()
.filter_map(|line| line.split_once('='))
.filter(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v.to_owned())
.next_back()
};
let key = get("user.signingkey").ok_or(
"operator signing needs `user.signingkey` in your git config \
(or choose /settings git-signing harness)",
)?;
let format = match get("gpg.format").as_deref() {
None | Some("openpgp") => Format::OpenPgp,
Some("ssh") => Format::Ssh,
Some(other) => {
return Err(format!("gpg.format = {other} is not supported for signing"))
}
};
let program = match format {
Format::Ssh => get("gpg.ssh.program").unwrap_or_else(|| "ssh-keygen".to_owned()),
Format::OpenPgp => get("gpg.program").unwrap_or_else(|| "gpg".to_owned()),
};
Ok(Self {
format,
key,
program,
})
}
}
impl CommitSigner for OperatorSigner {
fn sign(&self, payload: &[u8]) -> Result<String, String> {
let literal_key = self
.format
.eq(&Format::Ssh)
.then(|| self.key.strip_prefix("key::"))
.flatten();
let key_file = literal_key
.map(|public| KeyFile::write(public.as_bytes()))
.transpose()?;
let mut args: Vec<String> = match self.format {
Format::Ssh => vec!["-Y".into(), "sign".into(), "-n".into(), "git".into()],
Format::OpenPgp => vec!["--status-fd=2".into(), "-bsau".into(), self.key.clone()],
};
if self.format == Format::Ssh {
match &key_file {
Some(file) => args.extend(["-U".into(), "-f".into(), path_arg(&file.0)]),
None => args.extend([
"-f".into(),
crate::config::expand_tilde(&self.key)
.to_string_lossy()
.into_owned(),
]),
}
}
let mut command = std::process::Command::new(&self.program);
command
.args(&args)
.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for name in SIGNER_ENV {
if let Some(value) = std::env::var_os(name) {
command.env(name, value);
}
}
let mut child = command
.spawn()
.map_err(|e| format!("could not run {}: {e}", self.program))?;
child
.stdin
.take()
.expect("stdin is piped")
.write_all(payload)
.map_err(|e| e.to_string())?;
let output = child.wait_with_output().map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(format!(
"{} could not sign: {}",
self.program,
String::from_utf8_lossy(&output.stderr).trim()
));
}
String::from_utf8(output.stdout).map_err(|e| e.to_string())
}
}
struct KeyFile(std::path::PathBuf);
impl KeyFile {
fn write(bytes: &[u8]) -> Result<Self, String> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let path = std::env::temp_dir().join(format!(
"newt-signing-key-{}-{nanos}.pub",
std::process::id()
));
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
Ok(Self(path))
}
}
impl Drop for KeyFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn path_arg(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
const COMMIT: &[u8] =
b"tree abc\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n\nsubject\n\nbody\n";
#[test]
fn the_signature_is_the_last_header_with_continuation_lines() {
let signed = with_signature(COMMIT, "-----BEGIN X-----\nabc\n-----END X-----\n");
let text = String::from_utf8(signed).unwrap();
assert_eq!(
text,
"tree abc\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n\
gpgsig -----BEGIN X-----\n abc\n -----END X-----\n\nsubject\n\nbody\n"
);
}
#[test]
fn a_harness_signature_is_armored_sshsig_over_the_git_preimage() {
let signer = HarnessSshSigner {
key: agent_mesh_protocol::UserKey::generate(),
};
let armored = signer.sign(COMMIT).unwrap();
assert!(armored.starts_with("-----BEGIN SSH SIGNATURE-----\n"));
assert!(armored.ends_with("-----END SSH SIGNATURE-----\n"));
let body: String = armored
.lines()
.filter(|l| !l.starts_with("-----"))
.collect();
let blob = base64::engine::general_purpose::STANDARD
.decode(body)
.unwrap();
assert_eq!(&blob[..6], b"SSHSIG");
let mut preimage = b"SSHSIG".to_vec();
ssh_string(&mut preimage, b"git");
ssh_string(&mut preimage, b"");
ssh_string(&mut preimage, b"sha512");
ssh_string(&mut preimage, &sha2::Sha512::digest(COMMIT));
assert_eq!(
blob[blob.len() - 64..],
signer.key.sign(&preimage).to_bytes()
);
assert!(signer
.public_openssh()
.starts_with("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5"));
}
#[test]
fn the_operator_setup_follows_git_config() {
let ssh = OperatorSigner::from_listing(
"user.name=Op\ngpg.format=ssh\nuser.signingkey=~/.ssh/id_ed25519.pub\n",
)
.unwrap();
assert_eq!(ssh.format, Format::Ssh);
assert_eq!(ssh.program, "ssh-keygen");
let gpg =
OperatorSigner::from_listing("user.signingkey=ABCD1234\ngpg.program=gpg2\n").unwrap();
assert_eq!(gpg.format, Format::OpenPgp);
assert_eq!(gpg.program, "gpg2");
assert!(OperatorSigner::from_listing("user.name=Op\n").is_err());
assert!(OperatorSigner::from_listing("user.signingkey=k\ngpg.format=x509\n").is_err());
}
#[test]
fn signing_off_needs_no_key() {
assert!(signer_for(&AgentIdentity::default()).unwrap().is_none());
assert!(session_signer(&AgentIdentity::default()).is_none());
}
#[test]
fn signing_asked_for_but_unavailable_refuses_rather_than_skipping() {
let mut identity = AgentIdentity::default();
identity.git.signing = SigningMode::Harness; let signer = session_signer(&identity).expect("a refusing signer, not None");
let refusal = signer.sign(COMMIT).unwrap_err();
assert!(refusal.contains("signing_key"), "{refusal}");
}
}