Files
relicario/crates/relicario-server/src/main.rs
adlee-was-taken aace6f132a 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>
2026-06-20 12:36:04 -04:00

606 lines
22 KiB
Rust

//! relicario-server -- pre-receive hook for signature verification.
use std::fs;
use std::process::Command;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use relicario_core::device::{DeviceEntry, RevokedEntry};
use relicario_core::org::{OrgCollections, OrgMember, OrgMembers, OrgRole};
use relicario_server::{classify_path, extract_schema_version, PathClass};
#[derive(Parser)]
#[command(name = "relicario-server")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Verify a commit's signature against devices.json.
VerifyCommit {
/// The commit SHA to verify.
commit: String,
},
/// Generate a pre-receive hook script.
GenerateHook,
/// Verify a commit to an org vault: signature + role/path authorization.
VerifyOrgCommit {
/// The commit SHA to verify.
commit: String,
},
/// Generate an org pre-receive hook script.
GenerateOrgHook,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::VerifyCommit { commit } => verify_commit(&commit),
Commands::GenerateHook => generate_hook(),
Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
Commands::GenerateOrgHook => generate_org_hook(),
}
}
fn verify_commit(commit: &str) -> Result<()> {
let devices_json = match git_show(commit, ".relicario/devices.json") {
Ok(json) => json,
Err(_) => {
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
return Ok(());
}
};
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
.context("parse devices.json")?;
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
// True bootstrap: no devices ever registered and none revoked.
if devices.is_empty() && revoked.is_empty() {
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
return Ok(());
}
// Build temp allowed-signers file from registered devices.
let tmp = tempfile::tempdir().context("create tempdir")?;
let allowed_path = tmp.path().join("allowed_signers");
let mut allowed_body = String::new();
for d in &devices {
allowed_body.push_str("relicario ");
allowed_body.push_str(d.public_key.trim());
allowed_body.push('\n');
}
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
// Run git verify-commit --raw. Capture both exit code and stderr.
// NOTE: we do NOT short-circuit on non-zero exit here because even for
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
let output = Command::new("git")
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
.context("git verify-commit")?;
let stderr = String::from_utf8_lossy(&output.stderr);
// Parse the SHA-256 fingerprint from stderr.
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
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)) {
Some(m) => m.as_str().to_string(),
None => {
// No fingerprint in stderr = unsigned or completely malformed signature.
eprintln!(
"REJECT: commit {commit} — no valid signature found (stderr: {})",
stderr.trim()
);
std::process::exit(1);
}
};
// Build fingerprint → entry maps.
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
std::collections::HashMap::new();
for d in &devices {
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
device_by_fp.insert(fp, d);
}
}
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
std::collections::HashMap::new();
for r in &revoked {
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
revoked_by_fp.insert(fp, r);
}
}
// Get committer date (NOT author date).
let ct_out = Command::new("git")
.args(["show", "-s", "--format=%ct", commit])
.output()
.context("git show committer date")?;
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
.trim()
.parse()
.context("parse committer timestamp")?;
// Check revocation FIRST (revoked entries may not be in devices anymore).
if let Some(r) = revoked_by_fp.get(&signing_fp) {
if committer_ts >= r.revoked_at {
eprintln!(
"REJECT: commit {commit} — signed by revoked device '{}' \
(committer ts {committer_ts} >= revoked_at {})",
r.name, r.revoked_at
);
std::process::exit(1);
}
// Historical commit: committer_ts < revoked_at → was valid when signed.
eprintln!(
"OK: commit {commit} — historical commit signed by '{}' before revocation",
r.name
);
return Ok(());
}
// Not revoked — must be in active devices.
if !device_by_fp.contains_key(&signing_fp) {
eprintln!(
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
);
std::process::exit(1);
}
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
Ok(())
}
fn generate_hook() -> Result<()> {
print!(
r#"#!/bin/bash
# Relicario pre-receive hook -- verify all commits are signed by registered devices
while read oldrev newrev refname; do
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
commits=$(git rev-list "$newrev")
else
commits=$(git rev-list "$oldrev..$newrev")
fi
for commit in $commits; do
relicario-server verify-commit "$commit" || exit 1
done
done
"#
);
Ok(())
}
fn git_show(commit: &str, path: &str) -> Result<String> {
let output = Command::new("git")
.args(["show", &format!("{}:{}", commit, path)])
.output()
.context("git show")?;
if !output.status.success() {
anyhow::bail!("git show {}:{} failed", commit, path);
}
Ok(String::from_utf8(output.stdout)?)
}
/// Verify the SSH signature on `commit` against the given org members and return
/// the matching member. On any failure (unsigned, malformed, or unknown signer)
/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success.
fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
// Build a temp allowed-signers file from every current member's pubkey.
let tmp = match tempfile::tempdir() {
Ok(t) => t,
Err(e) => {
eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}");
std::process::exit(1);
}
};
let allowed_path = tmp.path().join("allowed_signers");
let mut allowed_body = String::new();
for m in &members.members {
allowed_body.push_str("relicario ");
allowed_body.push_str(m.ed25519_pubkey.trim());
allowed_body.push('\n');
}
if let Err(e) = fs::write(&allowed_path, &allowed_body) {
eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}");
std::process::exit(1);
}
// Run git verify-commit --raw with the allowed-signers file injected.
let output = match Command::new("git")
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
{
Ok(o) => o,
Err(e) => {
eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}");
std::process::exit(1);
}
};
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)) {
Some(m) => m.as_str().to_string(),
None => {
eprintln!(
"REJECT: org commit {commit} — no valid signature found (stderr: {})",
stderr.trim()
);
std::process::exit(1);
}
};
// Map fingerprint → member via relicario_core::fingerprint over each pubkey.
for m in &members.members {
if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) {
if fp == signing_fp {
return m.clone();
}
}
}
eprintln!(
"REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member"
);
std::process::exit(1);
}
fn verify_org_commit(commit: &str) -> Result<()> {
// Determine parent count from %P (space-separated parent SHAs; empty = root).
let parents_out = Command::new("git")
.args(["show", "-s", "--format=%P", commit])
.output()
.context("git show parents")?;
let parents_line = String::from_utf8_lossy(&parents_out.stdout);
let parents: Vec<&str> = parents_line.split_whitespace().collect();
// Merge commits are rejected. Org repos are linear (CLI uses pull --rebase).
if parents.len() > 1 {
eprintln!(
"REJECT: org commit {commit} — merge commits are not allowed in org vaults \
({} parents); rebase instead",
parents.len()
);
std::process::exit(1);
}
let is_root = parents.is_empty();
// Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself.
let members_json = match git_show(commit, "members.json") {
Ok(s) => s,
Err(_) => {
if is_root {
eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)");
return Ok(());
}
eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit");
std::process::exit(1);
}
};
let members: OrgMembers =
serde_json::from_str(&members_json).context("parse members.json")?;
if members.members.is_empty() {
if is_root {
eprintln!("OK: org commit {commit} (root bootstrap - empty member list)");
return Ok(());
}
eprintln!("REJECT: org commit {commit} — members.json has no members");
std::process::exit(1);
}
members
.validate()
.map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?;
// Verify the signature and resolve the signing member (exits on failure).
let signer = verify_org_signer(commit, &members);
// Enumerate changed paths. Root has no parent to diff, so use ls-tree.
let changed_paths: Vec<String> = if is_root {
let out = Command::new("git")
.args(["ls-tree", "-r", "--name-only", commit])
.output()
.context("git ls-tree")?;
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
} else {
let out = Command::new("git")
.args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
.output()
.context("git diff-tree")?;
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
};
// Authorize each changed path against the signing member's role/grants.
// collections.json (as of this commit) is loaded lazily on the first item
// path, for the L5 slug-existence check.
let mut collection_slugs: Option<Vec<String>> = None;
for path in &changed_paths {
match classify_path(path) {
PathClass::Rejected(why) => {
eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}");
std::process::exit(1);
}
PathClass::Protected => {
if !signer.role.can_manage_members() {
eprintln!(
"REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`",
signer.display_name, signer.role
);
std::process::exit(1);
}
// Privilege-escalation gate: only an Owner may INTRODUCE or
// ELEVATE an owner/admin. An Admin may write members.json but
// must not mint owners/admins server-side (spec §148/158/271).
if path == "members.json" {
enforce_owner_only_elevation(commit, is_root, &members, &signer);
}
}
PathClass::Item { collection } => {
// The signing member must hold an explicit grant for the slug.
if !signer.collections.iter().any(|c| c == &collection) {
eprintln!(
"REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)",
signer.display_name
);
std::process::exit(1);
}
// Slug-existence (L5): the collection must exist in
// collections.json AS OF THIS COMMIT. A write into a
// granted-but-deleted (or never-created) collection is rejected.
let known = collection_slugs.get_or_insert_with(|| {
git_show(commit, "collections.json")
.ok()
.and_then(|s| serde_json::from_str::<OrgCollections>(&s).ok())
.map(|c| c.collections.into_iter().map(|d| d.slug).collect::<Vec<_>>())
.unwrap_or_default()
});
if !known.iter().any(|s| s == &collection) {
eprintln!(
"REJECT: org commit {commit} — item write to collection `{collection}` whose slug is absent from collections.json (path `{path}`)"
);
std::process::exit(1);
}
}
PathClass::Unrestricted => {
// keys/<id>.enc, manifest.enc, etc. — signature check already passed.
}
}
}
// Schema-version monotonicity for the three JSON files (Task C2).
enforce_schema_monotonicity(commit, is_root, &changed_paths)?;
eprintln!(
"OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized",
signer.display_name,
signer.role,
changed_paths.len()
);
Ok(())
}
/// Reject the commit unless every newly-introduced or elevated owner/admin is
/// authorized. The signer's AUTHORITY is their role in the PARENT state — the role
/// they held BEFORE this commit — NOT the role this commit may grant them. Reading
/// `signer.role` (which is parsed from the post-change members.json) would let an
/// admin self-promote to owner and then pass this very gate with the owner role
/// they are minting — the exact escalation H-C1 exists to stop. We diff the new
/// members.json against the parent's by member_id and require an owner-authority
/// signer for any member that BECOMES owner/admin (new entry, or a role elevated
/// up to owner/admin). On genesis (root) the sole bootstrap owner is allowed.
///
/// `git_show_parent` is defined alongside `enforce_schema_monotonicity` below.
fn enforce_owner_only_elevation(
commit: &str,
is_root: bool,
new_members: &OrgMembers,
signer: &OrgMember,
) {
let is_privileged = |r: OrgRole| matches!(r, OrgRole::Owner | OrgRole::Admin);
// Genesis: the bootstrap commit introduces the sole owner; allow it.
if is_root {
return;
}
// Parent baseline. If members.json did not exist in the parent, every
// privileged member here is "new" and must be owner-signed.
let parent_members: Vec<(String, OrgRole)> = match git_show_parent(commit, "members.json") {
Ok(s) => serde_json::from_str::<OrgMembers>(&s)
.map(|m| {
m.members
.into_iter()
.map(|m| (m.member_id.0, m.role))
.collect()
})
.unwrap_or_default(),
Err(_) => Vec::new(),
};
let parent_role = |id: &str| -> Option<OrgRole> {
parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
};
// The signer's authority = their PARENT role. A member absent from the parent
// (brand new) has no prior authority and cannot mint owners/admins.
let signer_parent = parent_role(signer.member_id.as_str());
let signer_may_manage_owners = signer_parent.map_or(false, |r| r.can_manage_owners());
for m in &new_members.members {
if !is_privileged(m.role) {
continue;
}
// Skip ONLY if the role is unchanged from the parent (a no-op same-role
// entry). Any CHANGE into a privileged role — a new privileged member,
// Member→Admin/Owner, or Admin→Owner — must be owner-signed.
if parent_role(m.member_id.as_str()) == Some(m.role) {
continue;
}
// A new owner/admin, or a member elevated to owner/admin → owner-only,
// judged by the signer's PRE-commit authority.
if !signer_may_manage_owners {
eprintln!(
"REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
elevate owner/admin '{}' to {:?}; only an owner may",
signer.display_name, signer_parent, m.display_name, m.role
);
std::process::exit(1);
}
}
}
fn generate_org_hook() -> Result<()> {
print!(
r#"#!/bin/bash
# Relicario org pre-receive hook -- verify signatures + role/path authorization
while read oldrev newrev refname; do
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
commits=$(git rev-list "$newrev")
else
commits=$(git rev-list "$oldrev..$newrev")
fi
for commit in $commits; do
relicario-server verify-org-commit "$commit" || exit 1
done
done
"#
);
Ok(())
}
/// For each protected JSON file changed in this commit, ensure schema_version did
/// not decrease vs the parent commit, and re-validate collections.json structure.
fn enforce_schema_monotonicity(
commit: &str,
is_root: bool,
changed_paths: &[String],
) -> Result<()> {
const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"];
for file in VERSIONED {
if !changed_paths.iter().any(|p| p == file) {
continue;
}
// A deletion of a protected file is not allowed.
let new_content = match git_show(commit, file) {
Ok(s) => s,
Err(_) => {
eprintln!(
"REJECT: org commit {commit} — protected file `{file}` was deleted; \
org vaults never delete {file}"
);
std::process::exit(1);
}
};
let new_version = match extract_schema_version(&new_content) {
Ok(v) => v,
Err(e) => {
eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}");
std::process::exit(1);
}
};
// collections.json structural validation.
if file == "collections.json" {
match serde_json::from_str::<relicario_core::org::OrgCollections>(&new_content) {
Ok(c) => {
if let Err(e) = c.validate() {
eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}");
std::process::exit(1);
}
}
Err(e) => {
eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}");
std::process::exit(1);
}
}
}
// On the root commit there is no parent baseline; any starting version is fine.
if is_root {
continue;
}
// Parent version: if the file did not exist in the parent (newly added),
// there is no prior version to regress against — accept.
if let Ok(old_content) = git_show_parent(commit, file) {
let old_version = match extract_schema_version(&old_content) {
Ok(v) => v,
Err(_) => {
continue;
}
};
if new_version < old_version {
eprintln!(
"REJECT: org commit {commit} — `{file}` schema_version decreased \
({old_version} -> {new_version})"
);
std::process::exit(1);
}
}
}
Ok(())
}
/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`.
fn git_show_parent(commit: &str, path: &str) -> Result<String> {
let output = Command::new("git")
.args(["show", &format!("{}^:{}", commit, path)])
.output()
.context("git show parent")?;
if !output.status.success() {
anyhow::bail!("git show {}^:{} failed", commit, path);
}
Ok(String::from_utf8(output.stdout)?)
}