//! 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 = serde_json::from_str(&devices_json) .context("parse devices.json")?; let revoked: Vec = 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:" 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 = 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 = 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 { 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 = 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> = 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::(&s).ok()) .map(|c| c.collections.into_iter().map(|d| d.slug).collect::>()) .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/.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::(&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 { 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::(&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 { 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)?) }