#![allow(
clippy::print_stdout,
clippy::print_stderr,
clippy::disallowed_methods,
clippy::exit,
clippy::unwrap_used,
clippy::expect_used
)]
use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand};
use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use tempfile::NamedTempFile;
#[derive(Parser, Debug)]
#[command(name = "auths-verify")]
#[command(version)]
struct Args {
#[command(subcommand)]
command: Option<VerifySubcommand>,
#[arg(short = 'Y', global = true)]
operation: Option<String>,
#[arg(short = 'n', global = true)]
namespace: Option<String>,
#[arg(short = 'f', global = true)]
allowed_signers: Option<PathBuf>,
#[arg(short = 'I', global = true)]
identity: Option<String>,
#[arg(short = 's', global = true)]
signature_file: Option<PathBuf>,
}
#[derive(Subcommand, Debug)]
enum VerifySubcommand {
File {
#[arg(long)]
file: PathBuf,
#[arg(long)]
signature: PathBuf,
#[arg(long, default_value = ".auths/allowed_signers")]
allowed_signers: PathBuf,
#[arg(long, default_value = "file")]
namespace: String,
},
}
fn main() {
if let Err(e) = run() {
eprintln!("error: {:#}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let args = Args::parse();
if args.operation.is_some() {
return run_ssh_keygen_compat(args);
}
match args.command {
Some(VerifySubcommand::File {
file,
signature,
allowed_signers,
namespace,
}) => verify_file(&file, &signature, &allowed_signers, &namespace),
None => {
bail!(
"No operation specified.\n\n\
Usage:\n\
auths-verify -Y verify -f <allowed_signers> -I <identity> -n <namespace> -s <sig_file>\n\
auths-verify file --file <path> --signature <sig_file> --allowed-signers <file>"
);
}
}
}
fn run_ssh_keygen_compat(args: Args) -> Result<()> {
let operation = args.operation.as_deref().unwrap_or("");
if operation != "verify" {
bail!(
"Unsupported operation: '{}'. Only 'verify' is supported.",
operation
);
}
let allowed_signers = args
.allowed_signers
.ok_or_else(|| anyhow!("Missing required argument: -f <allowed_signers>"))?;
let signature_file = args
.signature_file
.ok_or_else(|| anyhow!("Missing required argument: -s <signature_file>"))?;
let namespace = args.namespace.unwrap_or_else(|| "file".to_string());
let identity = args.identity.unwrap_or_else(|| "*".to_string());
let mut data = Vec::new();
io::stdin()
.read_to_end(&mut data)
.context("Failed to read data from stdin")?;
verify_with_ssh_keygen(
&data,
&signature_file,
&allowed_signers,
&namespace,
&identity,
)
}
fn verify_file(
file: &std::path::Path,
signature: &std::path::Path,
allowed_signers: &std::path::Path,
namespace: &str,
) -> Result<()> {
if !allowed_signers.exists() {
bail!(
"Allowed signers file not found: {:?}\n\n\
Create it with:\n \
auths git allowed-signers > {:?}",
allowed_signers,
allowed_signers
);
}
let data =
fs::read(file).with_context(|| format!("Failed to read file: {}", file.display()))?;
verify_with_ssh_keygen(&data, signature, allowed_signers, namespace, "*")
}
fn verify_with_ssh_keygen(
data: &[u8],
signature_file: &std::path::Path,
allowed_signers: &std::path::Path,
namespace: &str,
identity: &str,
) -> Result<()> {
check_ssh_keygen()?;
let mut data_file = NamedTempFile::new().context("Failed to create temp file for data")?;
data_file
.write_all(data)
.context("Failed to write data to temp file")?;
data_file.flush()?;
let output = Command::new("ssh-keygen")
.args([
"-Y",
"verify",
"-f",
allowed_signers.to_str().unwrap(),
"-I",
identity,
"-n",
namespace,
"-s",
signature_file.to_str().unwrap(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to run ssh-keygen")?;
let mut child = output;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(data)?;
}
let output = child
.wait_with_output()
.context("Failed to wait for ssh-keygen")?;
if output.status.success() {
let signer = find_signer(signature_file, allowed_signers)?;
println!(
"Good signature from: {}",
signer.unwrap_or_else(|| "allowed signer".to_string())
);
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no principal matched") || stderr.contains("NONE_ACCEPTED") {
bail!("Signature from non-allowed signer");
}
bail!("Signature verification failed: {}", stderr.trim());
}
}
fn find_signer(
signature_file: &std::path::Path,
allowed_signers: &std::path::Path,
) -> Result<Option<String>> {
let output = Command::new("ssh-keygen")
.args([
"-Y",
"find-principals",
"-f",
allowed_signers.to_str().unwrap(),
"-s",
signature_file.to_str().unwrap(),
])
.output();
if let Ok(out) = output
&& out.status.success()
{
let signer = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !signer.is_empty() {
return Ok(Some(signer));
}
}
Ok(None)
}
fn check_ssh_keygen() -> Result<()> {
let output = Command::new("ssh-keygen")
.arg("-?")
.stderr(Stdio::piped())
.output()
.context("ssh-keygen not found in PATH. Install OpenSSH to use auths-verify.")?;
if output.stderr.is_empty() && output.stdout.is_empty() {
bail!("ssh-keygen not functioning properly");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::tempdir;
#[test]
fn test_check_ssh_keygen() {
let result = check_ssh_keygen();
assert!(
result.is_ok(),
"ssh-keygen should be available: {:?}",
result.err()
);
}
#[test]
fn test_find_signer_nonexistent_file() {
let dir = tempdir().unwrap();
let sig_path = dir.path().join("nonexistent.sig");
let allowed_path = dir.path().join("allowed_signers");
File::create(&allowed_path).unwrap();
let result = find_signer(&sig_path, &allowed_path);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
}