fix(server): real signature verification in pre-receive hook (audit S1)
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>
This commit is contained in:
230
crates/relicario-server/tests/verify_commit.rs
Normal file
230
crates/relicario-server/tests/verify_commit.rs
Normal file
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user