//! 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(); }