//! relicario-server -- pre-receive hook for signature verification. use std::fs; use std::process::Command; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use relicario_core::device::{DeviceEntry, RevokedEntry}; #[derive(Parser)] #[command(name = "relicario-server")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Verify a commit's signature against devices.json. VerifyCommit { /// The commit SHA to verify. commit: String, }, /// Generate a pre-receive hook script. GenerateHook, } fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::VerifyCommit { commit } => verify_commit(&commit), Commands::GenerateHook => generate_hook(), } } fn verify_commit(commit: &str) -> Result<()> { let devices_json = match git_show(commit, ".relicario/devices.json") { Ok(json) => json, Err(_) => { eprintln!("OK: commit {commit} (bootstrap - no devices.json)"); return Ok(()); } }; let devices: Vec = serde_json::from_str(&devices_json) .context("parse devices.json")?; let revoked: Vec = git_show(commit, ".relicario/revoked.json") .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); // True bootstrap: no devices ever registered and none revoked. if devices.is_empty() && revoked.is_empty() { eprintln!("OK: commit {commit} (bootstrap - no devices registered)"); return Ok(()); } // Build temp allowed-signers file from registered devices. let tmp = tempfile::tempdir().context("create tempdir")?; let allowed_path = tmp.path().join("allowed_signers"); let mut allowed_body = String::new(); for d in &devices { allowed_body.push_str("relicario "); allowed_body.push_str(d.public_key.trim()); allowed_body.push('\n'); } fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?; // Run git verify-commit --raw. Capture both exit code and stderr. // NOTE: we do NOT short-circuit on non-zero exit here because even for // unregistered keys git still outputs "Good ... key SHA256:..." on stderr. let output = Command::new("git") .args(["verify-commit", "--raw", commit]) .env("GIT_CONFIG_COUNT", "1") .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile") .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str()) .output() .context("git verify-commit")?; let stderr = String::from_utf8_lossy(&output.stderr); // Parse the SHA-256 fingerprint from stderr. // SSH signature output: "Good "git" signature ... with ED25519 key SHA256:" let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex"); let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) { Some(m) => m.as_str().to_string(), None => { // No fingerprint in stderr = unsigned or completely malformed signature. eprintln!( "REJECT: commit {commit} — no valid signature found (stderr: {})", stderr.trim() ); std::process::exit(1); } }; // Build fingerprint → entry maps. let mut device_by_fp: std::collections::HashMap = std::collections::HashMap::new(); for d in &devices { if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) { device_by_fp.insert(fp, d); } } let mut revoked_by_fp: std::collections::HashMap = std::collections::HashMap::new(); for r in &revoked { if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) { revoked_by_fp.insert(fp, r); } } // Get committer date (NOT author date). let ct_out = Command::new("git") .args(["show", "-s", "--format=%ct", commit]) .output() .context("git show committer date")?; let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout) .trim() .parse() .context("parse committer timestamp")?; // Check revocation FIRST (revoked entries may not be in devices anymore). if let Some(r) = revoked_by_fp.get(&signing_fp) { if committer_ts >= r.revoked_at { eprintln!( "REJECT: commit {commit} — signed by revoked device '{}' \ (committer ts {committer_ts} >= revoked_at {})", r.name, r.revoked_at ); std::process::exit(1); } // Historical commit: committer_ts < revoked_at → was valid when signed. eprintln!( "OK: commit {commit} — historical commit signed by '{}' before revocation", r.name ); return Ok(()); } // Not revoked — must be in active devices. if !device_by_fp.contains_key(&signing_fp) { eprintln!( "REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})" ); std::process::exit(1); } eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name); Ok(()) } fn generate_hook() -> Result<()> { print!( r#"#!/bin/bash # Relicario pre-receive hook -- verify all commits are signed by registered devices while read oldrev newrev refname; do [ "$newrev" = "0000000000000000000000000000000000000000" ] && continue if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then commits=$(git rev-list "$newrev") else commits=$(git rev-list "$oldrev..$newrev") fi for commit in $commits; do relicario-server verify-commit "$commit" || exit 1 done done "# ); Ok(()) } fn git_show(commit: &str, path: &str) -> Result { let output = Command::new("git") .args(["show", &format!("{}:{}", commit, path)]) .output() .context("git show")?; if !output.status.success() { anyhow::bail!("git show {}:{} failed", commit, path); } Ok(String::from_utf8(output.stdout)?) }