- verify_org_signer now rejects on a non-zero git verify-commit exit instead of relying on the stderr fingerprint regex alone (PM hardening note 1). - org_hook_signed: add commit_signed_by_non_member_is_rejected (exercises the signature rejection path) and genesis_bootstrap_with_sole_owner_is_accepted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
8.6 KiB
Rust
230 lines
8.6 KiB
Rust
//! Integration tests for `relicario-server verify-org-commit` privilege gating.
|
|
//!
|
|
//! H-C1: only an Owner may introduce or elevate an owner/admin. An Admin who
|
|
//! writes members.json must not be able to mint owners/admins.
|
|
|
|
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;
|
|
use tempfile::TempDir;
|
|
|
|
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, String) {
|
|
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
|
let priv_path = dir.join(format!("{name}.key"));
|
|
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
|
}
|
|
(priv_path, pub_line)
|
|
}
|
|
|
|
fn git(repo: &Path, args: &[&str]) {
|
|
let status = Command::new("git").current_dir(repo).args(args).status().unwrap();
|
|
assert!(status.success(), "git {args:?} failed");
|
|
}
|
|
|
|
/// members.json content with two members; `member_id`s are fixed 16-hex.
|
|
fn members_json(owner_pub: &str, admin_pub: &str, admin_role: &str) -> String {
|
|
format!(
|
|
r#"{{
|
|
"schema_version": 1,
|
|
"members": [
|
|
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
|
|
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }},
|
|
{{ "member_id": "2222222222222222", "display_name": "Admin", "role": "{admin_role}",
|
|
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
|
|
]
|
|
}}"#,
|
|
owner_pub.trim(),
|
|
admin_pub.trim()
|
|
)
|
|
}
|
|
|
|
/// Stage members.json, sign the commit with `signing_key`, return its SHA.
|
|
fn signed_members_commit(
|
|
repo: &Path,
|
|
signing_key: &Path,
|
|
allowed: &Path,
|
|
msg: &str,
|
|
content: &str,
|
|
) -> String {
|
|
fs::write(repo.join("members.json"), content).unwrap();
|
|
git(repo, &["add", "members.json"]);
|
|
let status = Command::new("git")
|
|
.current_dir(repo)
|
|
.args([
|
|
"-c", "gpg.format=ssh",
|
|
"-c", &format!("user.signingkey={}", signing_key.display()),
|
|
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
|
|
"commit", "-S", "-q", "-m", msg,
|
|
])
|
|
.status()
|
|
.unwrap();
|
|
assert!(status.success());
|
|
let out = Command::new("git").current_dir(repo).args(["rev-parse", "HEAD"]).output().unwrap();
|
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
|
}
|
|
|
|
/// Set up an org repo whose root commit (signed by the owner) registers an
|
|
/// owner + an admin. Returns (repo tmp, owner priv, admin priv, allowed file).
|
|
fn bootstrap() -> (TempDir, PathBuf, PathBuf, PathBuf) {
|
|
let tmp = TempDir::new().unwrap();
|
|
let repo = tmp.path();
|
|
git(repo, &["init", "-q", "-b", "main"]);
|
|
git(repo, &["config", "user.email", "t@t"]);
|
|
git(repo, &["config", "user.name", "t"]);
|
|
|
|
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
|
|
let (admin_priv, admin_pub) = write_keypair(repo, "admin");
|
|
|
|
let allowed = repo.join("allowed_signers");
|
|
fs::write(
|
|
&allowed,
|
|
format!("relicario {}\nrelicario {}\n", owner_pub.trim(), admin_pub.trim()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Genesis: owner registers both members (admin starts as `admin`).
|
|
let genesis = members_json(&owner_pub, &admin_pub, "admin");
|
|
signed_members_commit(repo, &owner_priv, &allowed, "org-init", &genesis);
|
|
|
|
// also write org.json + collections.json so later commits are well-formed
|
|
fs::write(repo.join("org.json"),
|
|
r#"{"schema_version":1,"org_id":"abc0abc0abc0abc0","display_name":"Acme","created_at":0}"#).unwrap();
|
|
fs::write(repo.join("collections.json"), r#"{"schema_version":1,"collections":[]}"#).unwrap();
|
|
git(repo, &["add", "org.json", "collections.json"]);
|
|
// sign this housekeeping commit with the owner too
|
|
let _ = signed_members_commit(repo, &owner_priv, &allowed, "scaffold",
|
|
&members_json(&owner_pub, &admin_pub, "admin"));
|
|
|
|
(tmp, owner_priv, admin_priv, allowed)
|
|
}
|
|
|
|
#[test]
|
|
fn admin_self_promote_to_owner_is_rejected() {
|
|
let (tmp, owner_priv, admin_priv, allowed) = bootstrap();
|
|
let repo = tmp.path();
|
|
let owner_pub = fs::read_to_string(repo.join("allowed_signers")).unwrap();
|
|
// Reconstruct pubkeys from the allowed_signers file (two "relicario <pub>" lines).
|
|
let lines: Vec<String> = owner_pub.lines()
|
|
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
|
|
let (op, ap) = (lines[0].clone(), lines[1].clone());
|
|
let _ = owner_priv;
|
|
|
|
// Admin signs a members.json that elevates THEMSELVES to owner.
|
|
let escalated = members_json(&op, &ap, "owner");
|
|
let sha = signed_members_commit(repo, &admin_priv, &allowed, "self-promote", &escalated);
|
|
|
|
AssertCommand::cargo_bin("relicario-server")
|
|
.unwrap()
|
|
.current_dir(repo)
|
|
.args(["verify-org-commit", &sha])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("only an owner"));
|
|
}
|
|
|
|
#[test]
|
|
fn owner_promoting_an_admin_is_accepted() {
|
|
let (tmp, owner_priv, _admin_priv, allowed) = bootstrap();
|
|
let repo = tmp.path();
|
|
let allowed_body = fs::read_to_string(repo.join("allowed_signers")).unwrap();
|
|
let lines: Vec<String> = allowed_body.lines()
|
|
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
|
|
let (op, ap) = (lines[0].clone(), lines[1].clone());
|
|
|
|
// Owner signs a members.json that elevates the admin to owner — allowed.
|
|
let promoted = members_json(&op, &ap, "owner");
|
|
let sha = signed_members_commit(repo, &owner_priv, &allowed, "promote-admin", &promoted);
|
|
|
|
AssertCommand::cargo_bin("relicario-server")
|
|
.unwrap()
|
|
.current_dir(repo)
|
|
.args(["verify-org-commit", &sha])
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[test]
|
|
fn commit_signed_by_non_member_is_rejected() {
|
|
// A commit signed by a key that is NOT in members.json must be rejected:
|
|
// verify_org_signer rebuilds allowed_signers from the current members only,
|
|
// so a non-member signature fails `git verify-commit`.
|
|
let (tmp, _owner_priv, _admin_priv, allowed) = bootstrap();
|
|
let repo = tmp.path();
|
|
|
|
// A stranger key, never registered as a member.
|
|
let (stranger_priv, _stranger_pub) = write_keypair(repo, "stranger");
|
|
|
|
// Stranger signs a commit touching an UNRESTRICTED file (members.json stays
|
|
// owner+admin, so allowed_signers excludes the stranger).
|
|
fs::write(repo.join("manifest.enc"), b"\x02ciphertext").unwrap();
|
|
git(repo, &["add", "manifest.enc"]);
|
|
let status = Command::new("git")
|
|
.current_dir(repo)
|
|
.args([
|
|
"-c", "gpg.format=ssh",
|
|
"-c", &format!("user.signingkey={}", stranger_priv.display()),
|
|
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
|
|
"commit", "-S", "-q", "-m", "stranger-write",
|
|
])
|
|
.status()
|
|
.unwrap();
|
|
assert!(status.success());
|
|
let out = Command::new("git")
|
|
.current_dir(repo)
|
|
.args(["rev-parse", "HEAD"])
|
|
.output()
|
|
.unwrap();
|
|
let sha = String::from_utf8(out.stdout).unwrap().trim().to_string();
|
|
|
|
AssertCommand::cargo_bin("relicario-server")
|
|
.unwrap()
|
|
.current_dir(repo)
|
|
.args(["verify-org-commit", &sha])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("REJECT"));
|
|
}
|
|
|
|
#[test]
|
|
fn genesis_bootstrap_with_sole_owner_is_accepted() {
|
|
// A root (parent-less) commit registering the sole owner, signed by that
|
|
// owner, is the genesis bootstrap and must be accepted.
|
|
let tmp = TempDir::new().unwrap();
|
|
let repo = tmp.path();
|
|
git(repo, &["init", "-q", "-b", "main"]);
|
|
git(repo, &["config", "user.email", "t@t"]);
|
|
git(repo, &["config", "user.name", "t"]);
|
|
|
|
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
|
|
let allowed = repo.join("allowed_signers");
|
|
fs::write(&allowed, format!("relicario {}\n", owner_pub.trim())).unwrap();
|
|
|
|
let sole_owner = format!(
|
|
r#"{{
|
|
"schema_version": 1,
|
|
"members": [
|
|
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
|
|
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
|
|
]
|
|
}}"#,
|
|
owner_pub.trim()
|
|
);
|
|
// First commit in a fresh repo → root (is_root == true).
|
|
let sha = signed_members_commit(repo, &owner_priv, &allowed, "org-init", &sole_owner);
|
|
|
|
AssertCommand::cargo_bin("relicario-server")
|
|
.unwrap()
|
|
.current_dir(repo)
|
|
.args(["verify-org-commit", &sha])
|
|
.assert()
|
|
.success();
|
|
}
|