diff --git a/crates/relicario-server/Cargo.toml b/crates/relicario-server/Cargo.toml index c773000..6455bbc 100644 --- a/crates/relicario-server/Cargo.toml +++ b/crates/relicario-server/Cargo.toml @@ -9,6 +9,8 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" +regex = "1" [dev-dependencies] assert_cmd = "2" diff --git a/crates/relicario-server/src/main.rs b/crates/relicario-server/src/main.rs index 06dcef6..91d861a 100644 --- a/crates/relicario-server/src/main.rs +++ b/crates/relicario-server/src/main.rs @@ -1,5 +1,6 @@ //! relicario-server -- pre-receive hook for signature verification. +use std::fs; use std::process::Command; use anyhow::{Context, Result}; @@ -34,49 +35,120 @@ fn main() -> Result<()> { } fn verify_commit(commit: &str) -> Result<()> { - // Get devices.json at this commit let devices_json = match git_show(commit, ".relicario/devices.json") { Ok(json) => json, Err(_) => { - // No devices.json yet -- bootstrap mode, allow unsigned - eprintln!("OK: commit {} (bootstrap - no devices.json)", commit); + eprintln!("OK: commit {commit} (bootstrap - no devices.json)"); return Ok(()); } }; let devices: Vec = serde_json::from_str(&devices_json) .context("parse devices.json")?; - // Bootstrap: if devices.json is empty, allow unsigned - if devices.is_empty() { - eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit); - return Ok(()); - } - - // Get revoked.json (may not exist) let revoked: Vec = git_show(commit, ".relicario/revoked.json") .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); - // Get commit signature + // 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")?; - // Check if signed let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") { - eprintln!("REJECT: commit {} is not signed by a registered device", commit); + + // 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); } - // Ensure the signing key is not revoked. - // The allowed-signers file approach means git verify-commit already checks - // against the list; we additionally guard against revoked.json entries. - let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers - - eprintln!("OK: commit {} verified", commit); + eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name); Ok(()) } diff --git a/crates/relicario-server/tests/verify_commit.rs b/crates/relicario-server/tests/verify_commit.rs new file mode 100644 index 0000000..ce6e72f --- /dev/null +++ b/crates/relicario-server/tests/verify_commit.rs @@ -0,0 +1,230 @@ +//! Acceptance tests for `relicario-server verify-commit`. +//! +//! Four scenarios from audit S1: +//! 1. Registered non-revoked key → exit 0 +//! 2. Unregistered key → exit 1 (stderr contains "unregistered") +//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked") +//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use assert_cmd::Command as AssertCommand; +use predicates::prelude::*; +use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry}; +use tempfile::TempDir; + +fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) { + let (priv_pem, pub_line) = generate_keypair().expect("generate keypair"); + let priv_path = dir.join(format!("{name}.key")); + let pub_path = dir.join(format!("{name}.pub")); + fs::write(&priv_path, priv_pem.as_str()).unwrap(); + fs::write(&pub_path, &pub_line).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap(); + } + (priv_path, pub_path, pub_line) +} + +fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) { + let mut cmd = Command::new("git"); + cmd.current_dir(repo).args(args); + for (k, v) in extra_env { + cmd.env(k, v); + } + let status = cmd.status().expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); +} + +fn init_repo(repo: &Path) { + git(repo, &["init", "-q", "-b", "main"], &[]); + git(repo, &["config", "user.email", "test@test"], &[]); + git(repo, &["config", "user.name", "test"], &[]); + git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]); +} + +fn sign_commit( + repo: &Path, + signing_key: &Path, + allowed_signers: &Path, + committer_unix: i64, + msg: &str, + file_path: &str, + file_content: &str, +) -> String { + fs::write(repo.join(file_path), file_content).unwrap(); + git(repo, &["add", file_path], &[]); + let date = format!("@{committer_unix} +0000"); + git( + repo, + &[ + "-c", "gpg.format=ssh", + "-c", &format!("user.signingkey={}", signing_key.display()), + "-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()), + "commit", "-S", "-q", "-m", msg, + ], + &[ + ("GIT_AUTHOR_DATE", &date), + ("GIT_COMMITTER_DATE", &date), + ], + ); + let out = Command::new("git") + .current_dir(repo) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) { + let dir = repo.join(".relicario"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap(); + fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap(); + git(repo, &["add", ".relicario"], &[]); + git(repo, &["commit", "-q", "-m", "device files"], &[]); +} + +#[test] +fn registered_non_revoked_key_accepted() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + write_device_files( + repo, + &[DeviceEntry { + name: "alice".into(), + public_key: pub_a.clone(), + added_at: 1_700_000_000, + added_by: "bootstrap".into(), + }], + &[], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .success(); +} + +#[test] +fn unregistered_key_rejected() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (_, _, pub_a) = write_keypair(repo, "alice"); + let (priv_evil, _, pub_evil) = write_keypair(repo, "evil"); + + // Only Alice is registered. + write_device_files( + repo, + &[DeviceEntry { + name: "alice".into(), + public_key: pub_a.clone(), + added_at: 1_700_000_000, + added_by: "bootstrap".into(), + }], + &[], + ); + + // Evil signs against a file containing both keys so git commit signing works, + // but the binary's allowed-signers (from devices.json) only has Alice. + let allowed = repo.join("test_allowed_signers"); + fs::write( + &allowed, + format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()), + ) + .unwrap(); + + let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .failure() + .stderr(predicate::str::contains("unregistered")); +} + +#[test] +fn revoked_key_after_revoked_at_rejected() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + + // Alice's entry is only in revoked.json (was removed from devices.json after revocation). + write_device_files( + repo, + &[], + &[RevokedEntry { + name: "alice".into(), + public_key: pub_a.clone(), + revoked_at: 1_705_000_000, + revoked_by: "admin".into(), + }], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + // Commit dated AFTER revocation. + let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .failure() + .stderr(predicate::str::contains("revoked")); +} + +#[test] +fn revoked_key_before_revoked_at_accepted_historical() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + + // Same as above: Alice only in revoked.json. + write_device_files( + repo, + &[], + &[RevokedEntry { + name: "alice".into(), + public_key: pub_a.clone(), + revoked_at: 1_705_000_000, + revoked_by: "admin".into(), + }], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + // Commit dated BEFORE revocation -- historical case must pass. + let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .success(); +}