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>
231 lines
7.0 KiB
Rust
231 lines
7.0 KiB
Rust
//! 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();
|
|
}
|