From aace6f132a05e19964827de399db46adc3ba49ff Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 12:36:04 -0400 Subject: [PATCH] harden(server): explicit verify-commit success gate + non-member/genesis hook tests - 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 --- crates/relicario-server/src/main.rs | 16 ++++ .../relicario-server/tests/org_hook_signed.rs | 77 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/crates/relicario-server/src/main.rs b/crates/relicario-server/src/main.rs index c9dcbb0..8d7c40e 100644 --- a/crates/relicario-server/src/main.rs +++ b/crates/relicario-server/src/main.rs @@ -239,6 +239,22 @@ fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember { }; let stderr = String::from_utf8_lossy(&output.stderr); + // The org hook builds allowed_signers from EVERY current member, so a clean + // `git verify-commit` exit IS the security gate: a non-zero exit means the + // commit was unsigned, tampered, or signed by a non-member. Make that + // property explicit rather than relying on the stderr regex alone (regex + // output is fragile across git versions). The fingerprint parse + member + // mapping below then identifies WHICH member signed. + if !output.status.success() { + eprintln!( + "REJECT: org commit {commit} — signature did not verify against current members \ + (git verify-commit exit {}): {}", + output.status.code().unwrap_or(-1), + stderr.trim() + ); + std::process::exit(1); + } + // Parse the SHA-256 fingerprint from stderr (same regex as verify_commit). 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)) { diff --git a/crates/relicario-server/tests/org_hook_signed.rs b/crates/relicario-server/tests/org_hook_signed.rs index ad304af..6bd5ba7 100644 --- a/crates/relicario-server/tests/org_hook_signed.rs +++ b/crates/relicario-server/tests/org_hook_signed.rs @@ -150,3 +150,80 @@ fn owner_promoting_an_admin_is_accepted() { .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(); +}