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 <noreply@anthropic.com>
This commit is contained in:
@@ -239,6 +239,22 @@ fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
|
|||||||
};
|
};
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
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).
|
// 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 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)) {
|
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||||
|
|||||||
@@ -150,3 +150,80 @@ fn owner_promoting_an_admin_is_accepted() {
|
|||||||
.assert()
|
.assert()
|
||||||
.success();
|
.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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user