From ccb58d8bb5cf7e6ff43bf5e9a447b6cedea722fd Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 10:21:15 -0400 Subject: [PATCH] =?UTF-8?q?feat(server):=20verify-org-commit=20=E2=80=94?= =?UTF-8?q?=20signature=20+=20path-scoped=20role/grant=20auth=20+=20owner-?= =?UTF-8?q?only=20elevation=20(parent-role=20authority)=20+=20schema=20mon?= =?UTF-8?q?otonicity=20+=20generate-org-hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- crates/relicario-server/src/main.rs | 400 ++++++++++++++++++ .../relicario-server/tests/org_hook_signed.rs | 152 +++++++ 2 files changed, 552 insertions(+) create mode 100644 crates/relicario-server/tests/org_hook_signed.rs diff --git a/crates/relicario-server/src/main.rs b/crates/relicario-server/src/main.rs index 91d861a..c9dcbb0 100644 --- a/crates/relicario-server/src/main.rs +++ b/crates/relicario-server/src/main.rs @@ -6,6 +6,8 @@ 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")] @@ -23,6 +25,13 @@ enum Commands { }, /// 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<()> { @@ -31,6 +40,8 @@ fn main() -> Result<()> { 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(), } } @@ -187,3 +198,392 @@ fn git_show(commit: &str, path: &str) -> Result { 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); + + // 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)?) +} diff --git a/crates/relicario-server/tests/org_hook_signed.rs b/crates/relicario-server/tests/org_hook_signed.rs new file mode 100644 index 0000000..ad304af --- /dev/null +++ b/crates/relicario-server/tests/org_hook_signed.rs @@ -0,0 +1,152 @@ +//! 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 " lines). + let lines: Vec = 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 = 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(); +}