verify_commit previously loaded devices.json/revoked.json and threw both away, accepting any commit whose stderr contained "GOODSIG" or "Good signature". This left device registration and revocation as no-ops: unregistered keys could push, revoked keys kept working. The fix: - Build a temp gpg.ssh.allowedSignersFile from devices.json at the commit, passed via GIT_CONFIG_COUNT/KEY/VALUE env (no global git config mutation). - Run git verify-commit --raw and parse SHA256 fingerprint from stderr regardless of exit code (SSH git outputs the "Good" line even for keys not in allowed-signers, with "No principal matched" + exit 1). - Check revoked.json FIRST: reject if committer_ts >= revoked_at; accept historical commits (committer_ts < revoked_at). - Reject if fingerprint is not in active devices.json. - Bootstrap: accept only when BOTH devices.json AND revoked.json are empty/absent (not just devices.json alone). Acceptance: 4 integration tests covering the matrix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
190 lines
6.1 KiB
Rust
190 lines
6.1 KiB
Rust
//! 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<DeviceEntry> = serde_json::from_str(&devices_json)
|
|
.context("parse devices.json")?;
|
|
|
|
let revoked: Vec<RevokedEntry> = 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:<base64>"
|
|
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<String, &DeviceEntry> =
|
|
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<String, &RevokedEntry> =
|
|
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<String> {
|
|
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)?)
|
|
}
|