feat(git): Role-based push permissions with service-bound auth (#441)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
tlongwell-block
2026-05-01 00:25:33 -04:00
committed by GitHub
parent 351fc3bfb8
commit 3899ca566d
15 changed files with 2768 additions and 56 deletions

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ typesense-data/
# Hermit (toolchain manager cache)
.hermit/
doc/
repos/

3
Cargo.lock generated
View File

@@ -3911,11 +3911,13 @@ dependencies = [
"deadpool-redis",
"futures-util",
"hex",
"hmac 0.13.0",
"infer",
"metrics",
"metrics-exporter-prometheus",
"moka",
"nostr",
"rand 0.10.0",
"redis",
"serde",
"serde_json",
@@ -3930,6 +3932,7 @@ dependencies = [
"sprout-search",
"sprout-workflow",
"sqlx",
"subtle",
"thiserror 2.0.18",
"tokio",
"tokio-util",

View File

@@ -180,10 +180,14 @@ fn main() {
let method = parse_method(wwwauth)
.unwrap_or_else(|| fail("server did not include method hint in WWW-Authenticate"));
// Sign the repo root URL, not the full endpoint URL.
// Git's credential helper is invoked once (for info/refs) and the token is reused
// for subsequent requests (upload-pack, receive-pack). The server verifies against
// the canonical repo root, so we strip endpoint suffixes here.
// Sign the repo root URL — strip endpoint suffixes to get the canonical form.
//
// Git's credential helper is invoked once (for the initial info/refs GET) and the
// token is reused for subsequent requests (upload-pack, receive-pack POST). The
// server verifies against the bare repo root URL.
//
// Git's credential protocol does NOT pass query strings in the `path` field, so
// we never see `?service=...` here — just the path component.
let repo_path = path
.split_once("/info/refs")
.map(|(prefix, _)| prefix)

View File

@@ -96,6 +96,10 @@ impl FromStr for ChannelType {
// ── Member role ──────────────────────────────────────────────────────────────
/// A member's role within a channel.
///
/// The hierarchy for permission checks is: Owner > Admin > Member > Guest.
/// Bot is a **separate designation** — it is not part of the linear hierarchy.
/// Use [`MemberRole::permission_level`] for numeric comparisons in authorization.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberRole {
/// Full control — can manage members and delete the channel.
@@ -106,7 +110,7 @@ pub enum MemberRole {
Member,
/// Read-only external participant.
Guest,
/// Automated agent or integration.
/// Automated agent or integration (not in the role hierarchy).
Bot,
}
@@ -126,6 +130,27 @@ impl MemberRole {
pub fn is_elevated(&self) -> bool {
matches!(self, Self::Owner | Self::Admin)
}
/// Numeric permission level for authorization comparisons.
///
/// Higher = more privileged. Bot returns 0 (must use explicit grants).
/// Use `role.permission_level() >= required.permission_level()` for checks.
pub fn permission_level(self) -> u8 {
match self {
Self::Owner => 4,
Self::Admin => 3,
Self::Member => 2,
Self::Guest => 1,
Self::Bot => 0,
}
}
/// Returns true if this role meets or exceeds the required role's permission level.
///
/// Bot never meets any requirement (returns false for all non-Bot requirements).
pub fn has_at_least(self, required: MemberRole) -> bool {
self.permission_level() >= required.permission_level()
}
}
impl fmt::Display for MemberRole {

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,8 @@ pub mod error;
pub mod event;
/// NIP-01 subscription filter matching.
pub mod filter;
/// Git permission types — ref patterns, protection rules, policy evaluation.
pub mod git_perms;
/// Sprout kind number registry — custom event type constants.
pub mod kind;
/// Network utilities — SSRF-safe IP classification.

View File

@@ -44,6 +44,9 @@ bytes = "1"
infer = "0.19"
serde_yaml = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
subtle = { workspace = true }
rand = { workspace = true }
hex = { workspace = true }
url = { workspace = true }
moka = { workspace = true }

View File

@@ -0,0 +1,176 @@
//! Pre-receive hook script generation and injection.
//!
//! The hook is a shell script that:
//! 1. Reads `old_oid new_oid ref_name` lines from stdin
//! 2. For each non-create/non-delete, runs `git merge-base --is-ancestor`
//! (inheriting quarantine env vars)
//! 3. POSTs the payload to the relay's internal policy endpoint with HMAC
//! 4. Exits non-zero on ANY non-200 response (fail-closed)
//!
//! Security invariants:
//! - Fail-closed: curl failure, timeout, non-200 → exit 1
//! - Quarantine vars inherited for ancestry checks
//! - HMAC binds callback to specific push operation
use std::path::Path;
use tokio::fs;
use tracing::{error, info};
/// The pre-receive hook script content.
///
/// Environment variables set by the relay before spawning git receive-pack:
/// - `SPROUT_HOOK_URL` — internal policy endpoint (http://127.0.0.1:{port}/internal/git/policy)
/// - `SPROUT_HOOK_SECRET` — per-push HMAC secret
/// - `SPROUT_REPO_ID` — repo identifier (d-tag)
/// - `SPROUT_PUSHER_PUBKEY` — authenticated pusher's hex pubkey
///
/// Git sets automatically (quarantine):
/// - `GIT_OBJECT_DIRECTORY` — quarantine object store
/// - `GIT_ALTERNATE_OBJECT_DIRECTORIES` — includes the real object store
const PRE_RECEIVE_HOOK: &str = r#"#!/usr/bin/env bash
# Sprout pre-receive hook — FAIL-CLOSED
# ANY error, timeout, or non-200 response → reject the push.
set -eo pipefail
# Force C locale for deterministic sort order and byte-accurate string lengths.
# Rust uses byte-order comparison and byte lengths — locale-aware sort/strlen would mismatch.
export LC_ALL=C
ZERO="0000000000000000000000000000000000000000"
# Fail-closed: required env vars must be set by the relay.
: "${SPROUT_REPO_ID:?error: SPROUT_REPO_ID not set}"
: "${SPROUT_REPO_OWNER:?error: SPROUT_REPO_OWNER not set}"
: "${SPROUT_PUSHER_PUBKEY:?error: SPROUT_PUSHER_PUBKEY not set}"
: "${SPROUT_HOOK_URL:?error: SPROUT_HOOK_URL not set}"
: "${SPROUT_HOOK_SECRET:?error: SPROUT_HOOK_SECRET not set}"
WORK_DIR=$(mktemp -d) || { echo "error: cannot create temp dir" >&2; exit 1; }
REFS_FILE="$WORK_DIR/refs"
HMAC_FILE="$WORK_DIR/hmac"
RESP_FILE="$WORK_DIR/resp"
trap 'rm -rf "$WORK_DIR"' EXIT
# Phase 1: Read ref updates from stdin, classify each, build JSON + HMAC lines.
# We write two files in parallel:
# REFS_FILE: JSON entries (unsorted, for the request body)
# HMAC_FILE: "ref_name old_oid new_oid" lines (for sorting → HMAC input)
REFS=""
while read -r old_oid new_oid ref_name; do
# Ancestry check for FF detection.
# CRITICAL: GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES are
# inherited from our environment (git sets them for quarantine). Any git
# subprocess we call sees the quarantined objects automatically.
IS_ANCESTOR="false"
if [ "$old_oid" != "$ZERO" ] && [ "$new_oid" != "$ZERO" ]; then
# Exit 0 = is ancestor (FF), exit 1 = not ancestor (NFF),
# exit 128 = error → treat as NFF (fail-closed).
if git merge-base --is-ancestor "$old_oid" "$new_oid" 2>/dev/null; then
IS_ANCESTOR="true"
fi
fi
# JSON entry for request body.
# Escape any special JSON characters in ref_name (defense against injection).
# Git ref names can't contain most special chars, but belt-and-suspenders.
SAFE_REF=$(printf '%s' "$ref_name" | sed 's/\\/\\\\/g; s/"/\\"/g')
if [ -n "$REFS" ]; then
REFS="${REFS},"
fi
REFS="${REFS}{\"old_oid\":\"${old_oid}\",\"new_oid\":\"${new_oid}\",\"ref_name\":\"${SAFE_REF}\",\"is_ancestor\":${IS_ANCESTOR}}"
# HMAC line: ref_name first (for sorting), then oids + is_ancestor.
# is_ancestor as "1" or "0" to match Rust's b"1"/b"0".
if [ "$IS_ANCESTOR" = "true" ]; then
echo "${ref_name} ${old_oid} ${new_oid} 1" >> "$HMAC_FILE"
else
echo "${ref_name} ${old_oid} ${new_oid} 0" >> "$HMAC_FILE"
fi
done
# Phase 2: Compute HMAC-SHA256 signature.
# Payload format MUST match relay's compute_hmac() in policy.rs:
# repo_id | repo_owner | pusher_pubkey | (old_oid + new_oid + ref_name + is_ancestor) per ref sorted by ref_name | timestamp
TIMESTAMP=$(date +%s)
# Structurally unambiguous HMAC format (matches Rust's compute_hmac):
# len(repo_id):repo_id | repo_owner | pusher | (old_oid + new_oid + len(ref):ref + is_anc)* | timestamp
REPO_ID_LEN=${#SPROUT_REPO_ID}
HMAC_INPUT="${REPO_ID_LEN}:${SPROUT_REPO_ID}|${SPROUT_REPO_OWNER}|${SPROUT_PUSHER_PUBKEY}|"
# Sort by ref_name (field 1) — matches Rust's sort_by(|a, b| a.ref_name.cmp(&b.ref_name))
if [ -f "$HMAC_FILE" ]; then
sort "$HMAC_FILE" | while IFS=' ' read ref_name old_oid new_oid is_anc; do
REF_LEN=${#ref_name}
printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
done > "$HMAC_FILE.concat"
HMAC_INPUT="${HMAC_INPUT}$(cat "$HMAC_FILE.concat")"
rm -f "$HMAC_FILE.concat"
fi
HMAC_INPUT="${HMAC_INPUT}|${TIMESTAMP}"
SIGNATURE=$(printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$SPROUT_HOOK_SECRET" -hex 2>/dev/null | sed 's/.*= //')
if [ -z "$SIGNATURE" ]; then
echo "error: failed to compute HMAC signature" >&2
exit 1
fi
# Phase 3: POST to policy endpoint — FAIL-CLOSED.
# repo_id is free-form (user-chosen d-tag) — must be escaped for JSON safety.
# repo_owner and pusher_pubkey are validated 64-char lowercase hex — no escaping needed.
SAFE_REPO_ID=$(printf '%s' "$SPROUT_REPO_ID" | sed 's/\\/\\\\/g; s/"/\\"/g')
BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${SPROUT_REPO_OWNER}\",\"pusher_pubkey\":\"${SPROUT_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}"
HTTP_CODE=$(curl --silent --max-time 10 \
-o "$RESP_FILE" \
-w "%{http_code}" \
-X POST \
-H "Content-Type: application/json" \
-d "$BODY" \
"$SPROUT_HOOK_URL" 2>/dev/null) || {
echo "error: push authorization failed (network error reaching policy service)" >&2
exit 1
}
if [ "$HTTP_CODE" != "200" ]; then
echo "error: push denied by policy (HTTP $HTTP_CODE)" >&2
cat "$RESP_FILE" >&2 2>/dev/null
exit 1
fi
exit 0
"#;
/// Install the pre-receive hook into a bare repository.
///
/// Creates a `hooks/` directory and writes the hook script with execute permission.
/// Called during repo creation (kind:30617 handling) and can be called to
/// retrofit existing repos.
pub async fn install_hook(repo_path: &Path) -> anyhow::Result<()> {
let hooks_dir = repo_path.join("hooks");
fs::create_dir_all(&hooks_dir).await.map_err(|e| {
error!(path = %hooks_dir.display(), error = %e, "failed to create hooks dir");
anyhow::anyhow!("failed to create hooks directory: {e}")
})?;
let hook_path = hooks_dir.join("pre-receive");
fs::write(&hook_path, PRE_RECEIVE_HOOK).await.map_err(|e| {
error!(path = %hook_path.display(), error = %e, "failed to write hook");
anyhow::anyhow!("failed to write pre-receive hook: {e}")
})?;
// Make executable (Unix only).
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&hook_path, perms).map_err(|e| {
error!(path = %hook_path.display(), error = %e, "failed to chmod hook");
anyhow::anyhow!("failed to set hook permissions: {e}")
})?;
}
info!(repo = %repo_path.display(), "pre-receive hook installed");
Ok(())
}

View File

@@ -0,0 +1,60 @@
//! Git hosting — Smart HTTP transport, permission hooks, and policy engine.
//!
//! # Module structure
//!
//! - `transport` — Smart HTTP protocol (info/refs, upload-pack, receive-pack)
//! - `hook` — Pre-receive hook script and injection
//! - `policy` — Internal policy endpoint (HMAC-authenticated callback from hook)
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
body::Body,
extract::ConnectInfo,
http::{Request, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::post,
Router,
};
use tower_http::limit::RequestBodyLimitLayer;
use crate::state::AppState;
pub mod hook;
pub mod policy;
pub mod transport;
pub use transport::git_router;
/// Middleware that rejects requests from non-loopback addresses.
///
/// Defense-in-depth: the internal policy endpoint should only be reachable
/// from localhost (the pre-receive hook runs on the same host as the relay).
async fn require_localhost(req: Request<Body>, next: Next) -> Response {
let is_loopback = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false);
if !is_loopback {
return (StatusCode::FORBIDDEN, "internal endpoint: localhost only").into_response();
}
next.run(req).await
}
/// Build the internal git policy router.
///
/// Mounted at `/internal/git/policy` — only accessible from localhost.
/// The pre-receive hook calls this to authorize pushes.
/// Body limit: 1 MB (500 refs × ~200 bytes each = ~100 KB typical; 1 MB is generous).
pub fn git_policy_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/internal/git/policy", post(policy::hook_policy_check))
.layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MB
.layer(middleware::from_fn(require_localhost))
.with_state(state)
}

View File

@@ -0,0 +1,726 @@
//! Internal policy endpoint — pre-receive hook callback.
//!
//! The pre-receive hook POSTs here with HMAC-signed payload containing
//! the pusher's pubkey, repo ID, and ref updates. This endpoint:
//!
//! 1. Validates HMAC signature + 30s TTL (fail-closed)
//! 2. Resolves kind:30617 → protection rules
//! 3. Resolves pusher's channel role via sprout-channel binding
//! 4. Promotes Bot → Member (bots in a channel push as members)
//! 5. Calls `sprout_core::git_perms::evaluate_push()`
//! 6. Returns 200 (allow) or 403 (deny with reasons)
//!
//! # Bot Role Model
//!
//! Bots are intentionally added to channels by members/admins. For git push,
//! they're promoted to Member — protection rules still apply. Bot is a
//! designation (what it is), not a permission tier (what it can do). The
//! promotion is scoped to this module; the core `MemberRole::Bot` hierarchy
//! is unchanged.
//!
//! # Security invariants
//!
//! - Endpoint binds to 127.0.0.1 only (enforced at router level)
//! - HMAC binds callback to the specific push operation
//! - Fail-closed: any error → 403
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use tracing::{error, warn};
use uuid::Uuid;
use sprout_core::channel::MemberRole;
use sprout_core::git_perms::{evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind};
use sprout_db::EventQuery;
use crate::state::AppState;
// ── Types ────────────────────────────────────────────────────────────────────
/// Maximum age of a hook callback (seconds). Push is synchronous so 30s is generous.
const MAX_CALLBACK_AGE_SECS: u64 = 30;
/// Request payload from the pre-receive hook.
#[derive(Debug, Clone, Deserialize)]
pub struct HookCallbackRequest {
/// Repo identifier (d-tag from kind:30617).
pub repo_id: String,
/// Hex-encoded repo owner pubkey (from URL path, verified against kind:30617).
pub repo_owner: String,
/// Hex-encoded pusher pubkey.
pub pusher_pubkey: String,
/// Ref updates from git stdin (old_oid, new_oid, ref_name, is_ancestor).
pub ref_updates: Vec<HookRefUpdate>,
/// Unix timestamp when the hook was invoked.
pub timestamp: u64,
/// HMAC-SHA256 signature over the canonical payload.
pub signature: String,
}
/// A single ref update as reported by the pre-receive hook.
#[derive(Debug, Clone, Deserialize)]
pub struct HookRefUpdate {
/// Old object ID (40 hex chars, zero OID for creates).
pub old_oid: String,
/// New object ID (40 hex chars, zero OID for deletes).
pub new_oid: String,
/// Full ref name (e.g., "refs/heads/main").
pub ref_name: String,
/// Result of `git merge-base --is-ancestor old new`.
/// For creates/deletes this is false (ignored by classifier).
pub is_ancestor: bool,
}
/// Response to the hook — either allow or deny.
#[derive(Debug, Serialize)]
pub struct HookCallbackResponse {
/// Whether the push is allowed.
pub allowed: bool,
/// Denial reasons (empty if allowed).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub denials: Vec<DenialResponse>,
}
/// A single denial reason in the hook response.
#[derive(Debug, Serialize)]
pub struct DenialResponse {
/// The ref that was denied.
pub ref_name: String,
/// Human-readable reason for denial.
pub reason: String,
}
impl From<Denial> for DenialResponse {
fn from(d: Denial) -> Self {
Self {
ref_name: d.ref_name,
reason: d.reason,
}
}
}
// ── HMAC Verification ────────────────────────────────────────────────────────
/// Compute the canonical HMAC payload.
///
/// Format (length-prefixed, `|`-separated, structurally unambiguous):
/// ```text
/// len(repo_id):repo_id | repo_owner(64) | pusher(64) | sorted_refs | timestamp
/// ```
/// where each ref is: `old_oid(40) + new_oid(40) + len(ref_name):ref_name + is_ancestor("1"/"0")`
///
/// Fixed-length fields (OIDs=40, pubkeys=64) need no length prefix.
/// Variable-length fields (repo_id, ref_name) are length-prefixed to prevent concatenation ambiguity.
fn compute_hmac(secret: &[u8], req: &HookCallbackRequest) -> Vec<u8> {
let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("HMAC can take key of any size");
// Structurally unambiguous format: length-prefixed fields separated by |.
// This prevents field confusion attacks (e.g., repo_id="a|b" being parsed differently).
mac.update(req.repo_id.len().to_string().as_bytes());
mac.update(b":");
mac.update(req.repo_id.as_bytes());
mac.update(b"|");
mac.update(req.repo_owner.as_bytes()); // Fixed 64 chars, no ambiguity.
mac.update(b"|");
mac.update(req.pusher_pubkey.as_bytes()); // Fixed 64 chars, no ambiguity.
mac.update(b"|");
// Deterministic ref update representation: sorted by ref_name.
// Each ref is length-prefixed to prevent concatenation ambiguity.
let mut refs_sorted: Vec<&HookRefUpdate> = req.ref_updates.iter().collect();
refs_sorted.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
for r in &refs_sorted {
mac.update(r.old_oid.as_bytes()); // Fixed 40 chars.
mac.update(r.new_oid.as_bytes()); // Fixed 40 chars.
mac.update(r.ref_name.len().to_string().as_bytes());
mac.update(b":");
mac.update(r.ref_name.as_bytes());
mac.update(if r.is_ancestor { b"1" } else { b"0" });
}
mac.update(b"|");
mac.update(req.timestamp.to_string().as_bytes());
mac.finalize().into_bytes().to_vec()
}
/// Verify the HMAC signature on a hook callback.
fn verify_hmac(secret: &[u8], req: &HookCallbackRequest) -> bool {
let expected = compute_hmac(secret, req);
let provided = match hex::decode(&req.signature) {
Ok(bytes) => bytes,
Err(_) => return false,
};
// Constant-time comparison.
use subtle::ConstantTimeEq;
expected.ct_eq(&provided).into()
}
// ── Handler ──────────────────────────────────────────────────────────────────
/// `POST /internal/git/policy` — pre-receive hook callback.
///
/// Fail-closed: ANY error returns 403. The hook script treats non-200 as deny.
pub async fn hook_policy_check(
State(state): State<Arc<AppState>>,
Json(req): Json<HookCallbackRequest>,
) -> Response {
// 1. Validate input fields (cheap structural checks before expensive HMAC).
// This prevents wasting CPU on malformed payloads.
if req.repo_id.is_empty() || req.repo_id.len() > 64 {
return (StatusCode::FORBIDDEN, "invalid repo_id").into_response();
}
if req.repo_owner.len() != 64
|| !req
.repo_owner
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return (StatusCode::FORBIDDEN, "invalid repo_owner").into_response();
}
if req.pusher_pubkey.len() != 64
|| !req
.pusher_pubkey
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return (StatusCode::FORBIDDEN, "invalid pusher_pubkey").into_response();
}
if req.ref_updates.is_empty() || req.ref_updates.len() > 500 {
return (StatusCode::FORBIDDEN, "invalid ref_updates count").into_response();
}
for r in &req.ref_updates {
if r.old_oid.len() != 40 || !r.old_oid.chars().all(|c| c.is_ascii_hexdigit()) {
return (StatusCode::FORBIDDEN, "invalid old_oid").into_response();
}
if r.new_oid.len() != 40 || !r.new_oid.chars().all(|c| c.is_ascii_hexdigit()) {
return (StatusCode::FORBIDDEN, "invalid new_oid").into_response();
}
if r.ref_name.is_empty()
|| r.ref_name.len() > 256
|| !r.ref_name.starts_with("refs/")
|| r.ref_name.contains("..")
|| r.ref_name.bytes().any(|b| b <= 0x20 || b == 0x7f)
{
return (StatusCode::FORBIDDEN, "invalid ref_name").into_response();
}
}
// 2. Verify HMAC signature (now that we know the payload is structurally valid).
let secret = state.config.git_hook_hmac_secret.as_bytes();
if !verify_hmac(secret, &req) {
warn!(repo = %req.repo_id, "hook callback: HMAC verification failed");
return (StatusCode::FORBIDDEN, "signature verification failed").into_response();
}
// 3. Validate timestamp (30s TTL, max 5s future tolerance).
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now.saturating_sub(req.timestamp) > MAX_CALLBACK_AGE_SECS {
warn!(repo = %req.repo_id, age = now.saturating_sub(req.timestamp), "hook callback: expired");
return (StatusCode::FORBIDDEN, "callback expired").into_response();
}
if req.timestamp.saturating_sub(now) > 5 {
warn!(repo = %req.repo_id, "hook callback: timestamp too far in future");
return (StatusCode::FORBIDDEN, "callback timestamp invalid").into_response();
}
// 4. Validate and resolve kind:30617 for this repo.
// Query by (kind=30617, pubkey=owner, d_tag=repo_id) to prevent spoofing.
let owner_bytes = match hex::decode(&req.repo_owner) {
Ok(b) if b.len() == 32 => b,
_ => {
return (StatusCode::FORBIDDEN, "invalid repo owner").into_response();
}
};
let query = EventQuery {
kinds: Some(vec![30617]),
pubkey: Some(owner_bytes),
d_tag: Some(req.repo_id.clone()),
global_only: true,
limit: Some(1),
..Default::default()
};
let repo_event = match state.db.query_events(&query).await {
Ok(mut events) => {
if let Some(event) = events.pop() {
event
} else {
warn!(repo = %req.repo_id, "hook callback: kind:30617 not found");
return (StatusCode::FORBIDDEN, "repository not found").into_response();
}
}
Err(e) => {
error!(repo = %req.repo_id, error = %e, "hook callback: DB error");
return (StatusCode::FORBIDDEN, "internal error").into_response();
}
};
// 5. Parse protection rules from kind:30617 tags.
let tags: Vec<Vec<String>> = repo_event
.event
.tags
.iter()
.map(|t| t.as_slice().to_vec())
.collect();
let rules = match parse_protection_tags(&tags) {
Ok(parsed) => {
// Log unknown rules as warnings (helps catch typos).
for unknown in &parsed.unknown_rules {
warn!(repo = %req.repo_id, rule = %unknown, "unknown sprout-protect rule (skipped)");
}
parsed.rules
}
Err(e) => {
warn!(repo = %req.repo_id, error = %e, "hook callback: malformed protection tags");
// Fail-closed: malformed rules = deny.
return (StatusCode::FORBIDDEN, "malformed protection rules").into_response();
}
};
// 6. Resolve channel and check archived state (applies to ALL pushers including owner).
let channel_id = tags
.iter()
.find(|t| t.first().map(|s| s.as_str()) == Some("sprout-channel"))
.and_then(|t| t.get(1))
.and_then(|id| Uuid::parse_str(id).ok());
if let Some(ch_id) = channel_id {
match state.db.get_channel(ch_id).await {
Ok(ch) if ch.archived_at.is_some() => {
return (StatusCode::FORBIDDEN, "channel is archived (read-only)").into_response();
}
Err(e) => {
error!(error = %e, "hook callback: channel lookup failed");
return (StatusCode::FORBIDDEN, "internal error").into_response();
}
_ => {} // Channel exists and is not archived.
}
}
// 7. Resolve pusher's role.
let repo_owner_hex = hex::encode(repo_event.event.pubkey.to_bytes());
let role = if req.pusher_pubkey == repo_owner_hex {
MemberRole::Owner
} else {
match channel_id {
None => {
warn!(repo = %req.repo_id, "hook callback: no sprout-channel binding");
return (StatusCode::FORBIDDEN, "no channel binding").into_response();
}
Some(ch_id) => {
let pusher_bytes = match hex::decode(&req.pusher_pubkey) {
Ok(b) if b.len() == 32 => b,
_ => {
return (StatusCode::FORBIDDEN, "invalid pusher pubkey").into_response();
}
};
match state.db.get_member_role(ch_id, &pusher_bytes).await {
Ok(Some(role_str)) => match role_str.parse::<MemberRole>() {
Ok(role) => role,
Err(_) => {
error!(role = %role_str, "hook callback: unknown role");
return (StatusCode::FORBIDDEN, "internal error").into_response();
}
},
Ok(None) => {
return (StatusCode::FORBIDDEN, "not a channel member").into_response();
}
Err(e) => {
error!(error = %e, "hook callback: role lookup failed");
return (StatusCode::FORBIDDEN, "internal error").into_response();
}
}
}
}
};
// 8. Effective git role: bots intentionally added to a channel push as members.
// Protection rules (push:admin, no-force-push, require-patch, etc.) still apply.
// Bot is a designation (what it is), not a permission tier (what it can do).
let git_role = match role {
MemberRole::Bot => MemberRole::Member,
other => other,
};
// 9. Classify ref updates and evaluate policy.
let updates: Vec<RefUpdate> = req
.ref_updates
.iter()
.map(|r| RefUpdate {
ref_name: r.ref_name.clone(),
kind: UpdateKind::classify(&r.old_oid, &r.new_oid, r.is_ancestor),
old_oid: r.old_oid.clone(),
new_oid: r.new_oid.clone(),
})
.collect();
match evaluate_push(&updates, git_role, &rules) {
Ok(()) => Json(HookCallbackResponse {
allowed: true,
denials: vec![],
})
.into_response(),
Err(denials) => {
let response = HookCallbackResponse {
allowed: false,
denials: denials.into_iter().map(DenialResponse::from).collect(),
};
(StatusCode::FORBIDDEN, Json(response)).into_response()
}
}
}
// ── HMAC Generation (for the relay to pass to the hook) ──────────────────────
/// Generate the HMAC signature for a hook callback payload.
///
/// Called by the relay when setting up the pre-receive hook environment.
pub fn generate_hook_hmac(
secret: &[u8],
repo_id: &str,
repo_owner: &str,
pusher_pubkey: &str,
ref_updates: &[HookRefUpdate],
timestamp: u64,
) -> String {
let req = HookCallbackRequest {
repo_id: repo_id.to_string(),
repo_owner: repo_owner.to_string(),
pusher_pubkey: pusher_pubkey.to_string(),
ref_updates: ref_updates.to_vec(),
timestamp,
signature: String::new(), // Not used in computation.
};
let mac_bytes = compute_hmac(secret, &req);
hex::encode(mac_bytes)
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_request() -> HookCallbackRequest {
HookCallbackRequest {
repo_id: "test-repo".to_string(),
repo_owner: "a".repeat(64),
pusher_pubkey: "b".repeat(64),
ref_updates: vec![HookRefUpdate {
old_oid: "1".repeat(40),
new_oid: "2".repeat(40),
ref_name: "refs/heads/main".to_string(),
is_ancestor: true,
}],
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
signature: String::new(),
}
}
fn sign_request(req: &mut HookCallbackRequest, secret: &[u8]) {
let mac = compute_hmac(secret, req);
req.signature = hex::encode(mac);
}
#[test]
fn hmac_valid_signature_accepted() {
let secret = b"test-secret-key";
let mut req = make_request();
sign_request(&mut req, secret);
assert!(verify_hmac(secret, &req));
}
#[test]
fn hmac_wrong_secret_rejected() {
let mut req = make_request();
sign_request(&mut req, b"correct-secret");
assert!(!verify_hmac(b"wrong-secret", &req));
}
#[test]
fn hmac_tampered_repo_id_rejected() {
let secret = b"test-secret";
let mut req = make_request();
sign_request(&mut req, secret);
req.repo_id = "evil-repo".to_string();
assert!(!verify_hmac(secret, &req));
}
#[test]
fn hmac_tampered_pusher_rejected() {
let secret = b"test-secret";
let mut req = make_request();
sign_request(&mut req, secret);
req.pusher_pubkey = "c".repeat(64);
assert!(!verify_hmac(secret, &req));
}
#[test]
fn hmac_tampered_ref_rejected() {
let secret = b"test-secret";
let mut req = make_request();
sign_request(&mut req, secret);
req.ref_updates[0].ref_name = "refs/heads/evil".to_string();
assert!(!verify_hmac(secret, &req));
}
#[test]
fn hmac_tampered_is_ancestor_rejected() {
let secret = b"test-secret";
let mut req = make_request();
sign_request(&mut req, secret);
req.ref_updates[0].is_ancestor = false; // Flip FF → NFF
assert!(!verify_hmac(secret, &req));
}
#[test]
fn hmac_tampered_owner_rejected() {
let secret = b"test-secret";
let mut req = make_request();
sign_request(&mut req, secret);
req.repo_owner = "c".repeat(64);
assert!(!verify_hmac(secret, &req));
}
#[test]
fn hmac_tampered_timestamp_rejected() {
let secret = b"test-secret";
let mut req = make_request();
sign_request(&mut req, secret);
req.timestamp += 1;
assert!(!verify_hmac(secret, &req));
}
#[test]
fn hmac_invalid_hex_rejected() {
let secret = b"test-secret";
let mut req = make_request();
req.signature = "not-valid-hex!!!".to_string();
assert!(!verify_hmac(secret, &req));
}
#[test]
fn hmac_deterministic_across_ref_order() {
let secret = b"test-secret";
let mut req1 = make_request();
req1.ref_updates.push(HookRefUpdate {
old_oid: "3".repeat(40),
new_oid: "4".repeat(40),
ref_name: "refs/heads/develop".to_string(),
is_ancestor: false,
});
let mut req2 = req1.clone();
// Reverse the ref order — HMAC should be the same (sorted internally).
req2.ref_updates.reverse();
let mac1 = compute_hmac(secret, &req1);
let mac2 = compute_hmac(secret, &req2);
assert_eq!(mac1, mac2);
}
#[test]
fn generate_hook_hmac_matches_verify() {
let secret = b"test-secret";
let mut req = make_request();
let sig = generate_hook_hmac(
secret,
&req.repo_id,
&req.repo_owner,
&req.pusher_pubkey,
&req.ref_updates,
req.timestamp,
);
req.signature = sig;
assert!(verify_hmac(secret, &req));
}
/// Cross-boundary HMAC integration test.
///
/// Runs the bash HMAC computation logic (extracted from the pre-receive hook)
/// and compares its output against Rust's `generate_hook_hmac`. This is the
/// most critical test — it verifies the bash/Rust format agreement that the
/// entire security model depends on.
#[test]
fn bash_hmac_matches_rust_hmac() {
let secret = "cross-boundary-test-secret-key-1234";
let repo_id = "my-project";
let repo_owner = "ab".repeat(32); // 64 hex chars
let pusher = "cd".repeat(32); // 64 hex chars
let timestamp: u64 = 1700000000;
// Two refs, intentionally out of sorted order to test sorting.
let ref_updates = vec![
HookRefUpdate {
old_oid: "b".repeat(40),
new_oid: "c".repeat(40),
ref_name: "refs/heads/main".to_string(),
is_ancestor: true,
},
HookRefUpdate {
old_oid: "a".repeat(40),
new_oid: "d".repeat(40),
ref_name: "refs/heads/feature".to_string(),
is_ancestor: false,
},
];
// Compute Rust-side HMAC.
let rust_sig = generate_hook_hmac(
secret.as_bytes(),
repo_id,
&repo_owner,
&pusher,
&ref_updates,
timestamp,
);
// Bash script that replicates the hook's HMAC computation.
// This is the exact logic from hook.rs PRE_RECEIVE_HOOK, extracted into
// a standalone script with hardcoded values.
let bash_script = format!(
r#"
export LC_ALL=C
SPROUT_REPO_ID="{repo_id}"
SPROUT_REPO_OWNER="{repo_owner}"
SPROUT_PUSHER_PUBKEY="{pusher}"
SPROUT_HOOK_SECRET="{secret}"
TIMESTAMP="{timestamp}"
# Simulate the HMAC_FILE with two refs (unsorted, like the hook writes them)
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
HMAC_FILE="$WORK_DIR/hmac"
# Write refs in the order they'd arrive (main first, feature second)
echo "refs/heads/main {old1} {new1} 1" >> "$HMAC_FILE"
echo "refs/heads/feature {old2} {new2} 0" >> "$HMAC_FILE"
# Build HMAC input — exact logic from hook script
REPO_ID_LEN=${{#SPROUT_REPO_ID}}
HMAC_INPUT="${{REPO_ID_LEN}}:${{SPROUT_REPO_ID}}|${{SPROUT_REPO_OWNER}}|${{SPROUT_PUSHER_PUBKEY}}|"
sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do
REF_LEN=${{#ref_name}}
printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
done > "$HMAC_FILE.concat"
HMAC_INPUT="${{HMAC_INPUT}}$(cat "$HMAC_FILE.concat")|${{TIMESTAMP}}"
# Compute HMAC-SHA256
printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$SPROUT_HOOK_SECRET" -hex 2>/dev/null | sed 's/.*= //'
"#,
repo_id = repo_id,
repo_owner = repo_owner,
pusher = pusher,
secret = secret,
timestamp = timestamp,
old1 = "b".repeat(40),
new1 = "c".repeat(40),
old2 = "a".repeat(40),
new2 = "d".repeat(40),
);
let output = std::process::Command::new("bash")
.arg("-c")
.arg(&bash_script)
.output()
.expect("failed to run bash");
assert!(
output.status.success(),
"bash script failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let bash_sig = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
rust_sig, bash_sig,
"HMAC mismatch!\n Rust: {rust_sig}\n Bash: {bash_sig}\n\
The pre-receive hook and policy endpoint disagree on the canonical format."
);
}
/// Cross-boundary test with a single ref (simpler case).
#[test]
fn bash_hmac_single_ref() {
let secret = "single-ref-secret";
let repo_id = "test-repo";
let repo_owner = "a".repeat(64);
let pusher = "b".repeat(64);
let timestamp: u64 = 1700000001;
let ref_updates = vec![HookRefUpdate {
old_oid: "1".repeat(40),
new_oid: "2".repeat(40),
ref_name: "refs/heads/main".to_string(),
is_ancestor: true,
}];
let rust_sig = generate_hook_hmac(
secret.as_bytes(),
repo_id,
&repo_owner,
&pusher,
&ref_updates,
timestamp,
);
let bash_script = format!(
r#"
export LC_ALL=C
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
HMAC_FILE="$WORK_DIR/hmac"
echo "refs/heads/main {old} {new} 1" >> "$HMAC_FILE"
SPROUT_REPO_ID="{repo_id}"
REPO_ID_LEN=${{#SPROUT_REPO_ID}}
HMAC_INPUT="${{REPO_ID_LEN}}:${{SPROUT_REPO_ID}}|{owner}|{pusher}|"
sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do
REF_LEN=${{#ref_name}}
printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
done > "$HMAC_FILE.concat"
HMAC_INPUT="${{HMAC_INPUT}}$(cat "$HMAC_FILE.concat")|{timestamp}"
printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/null | sed 's/.*= //'
"#,
old = "1".repeat(40),
new = "2".repeat(40),
repo_id = repo_id,
owner = repo_owner,
pusher = pusher,
timestamp = timestamp,
secret = secret,
);
let output = std::process::Command::new("bash")
.arg("-c")
.arg(&bash_script)
.output()
.expect("failed to run bash");
assert!(
output.status.success(),
"bash script failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let bash_sig = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
rust_sig, bash_sig,
"Single-ref HMAC mismatch!\n Rust: {rust_sig}\n Bash: {bash_sig}"
);
}
}

View File

@@ -42,8 +42,9 @@ const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300
/// Validates the `Authorization: Nostr <base64>` header before the request body
/// is read. Same pattern as `AuthenticatedUpload` in media.rs.
///
/// Authorization model (v1): any authenticated pubkey can clone; only the repo
/// owner can push. Maintainer lists from kind:30617 are a future enhancement.
/// Authorization model: any authenticated pubkey can clone; push authorization
/// is handled by the pre-receive hook (calls back to the internal policy endpoint
/// which checks channel role + protection rules from kind:30617).
pub struct GitAuth {
/// The authenticated user's public key, extracted from the NIP-98 event.
pub pubkey: nostr::PublicKey,
@@ -103,32 +104,39 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
.map(|pq| pq.as_str())
.unwrap_or(parts.uri.path());
// Bug fix: strip git endpoint suffixes before building the expected URL.
// Repo-root URL verification.
//
// Git's credential helper is invoked once — for the initial GET /info/refs —
// and signs a NIP-98 token with `u = <repo-root>` (e.g. `/git/{owner}/{repo}.git`).
// That same token is reused for all subsequent requests in the session
// (git-upload-pack, git-receive-pack). We must verify against the repo-root URL,
// not the full endpoint URL, so all three endpoints share a single canonical URL.
let repo_path = path_and_query
.split_once("/info/refs")
.map(|(prefix, _)| prefix)
.or_else(|| path_and_query.strip_suffix("/git-upload-pack"))
.or_else(|| path_and_query.strip_suffix("/git-receive-pack"))
.unwrap_or(path_and_query);
// The credential helper signs a NIP-98 token with:
// u = <repo-root> (e.g., http://host/git/{owner}/{repo})
//
// Git's credential protocol does NOT pass query strings to helpers, so
// service-scoping (`?service=...`) cannot be implemented at the NIP-98
// level without protocol changes. The token is repo-scoped, not service-scoped.
//
// Security is still provided by:
// - ±60s timestamp window (limits replay)
// - HTTPS in production (prevents token theft)
// - Pre-receive hook for push authorization (role + protection rules)
// - Endpoint routing (clone/push are different HTTP paths)
let repo_path = if let Some((prefix, _query)) = path_and_query.split_once("/info/refs") {
prefix
} else if let Some(prefix) = path_and_query.strip_suffix("/git-upload-pack") {
prefix
} else if let Some(prefix) = path_and_query.strip_suffix("/git-receive-pack") {
prefix
} else {
return Err((StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response());
};
let expected_url = format!("{base_url}{repo_path}");
// Bug fix: skip the HTTP method check for git routes.
// Skip HTTP method check for git routes.
//
// Git's credential helper signs the NIP-98 token with `method=GET` (the method
// used for the initial /info/refs discovery request), then reuses that same token
// for POST requests (git-upload-pack, git-receive-pack). Verifying against the
// actual HTTP method would reject all pack operations after the first request.
// Git's credential helper signs with `method=GET` (the initial /info/refs request)
// then reuses the token for POST (pack data). Method binding can't work here.
//
// The URL tag already scopes the token to the correct repo — the method tag adds
// no meaningful security here since all git endpoints require the same authorization
// (repo owner). We pass the method from the event itself so verify_nip98_event
// always accepts whatever method the credential helper signed.
// Security is provided by: service-binding in the URL (clone vs push scoped),
// ±60s timestamp, and the pre-receive hook for push authorization.
// We pass the method from the event itself so verify_nip98_event always accepts.
let event_method = serde_json::from_str::<serde_json::Value>(&event_json)
.ok()
.and_then(|v| {
@@ -361,19 +369,17 @@ pub async fn upload_pack(
/// `POST /git/{owner}/{repo}/git-receive-pack`
///
/// Handles push — client sends ref updates + pack data.
/// Authorization: only the repo owner (whose pubkey matches `{owner}` in the URL)
/// can push. Maintainer lists from kind:30617 are a future enhancement.
/// Authorization: NIP-98 authenticates the pusher. The pre-receive hook
/// calls back to the internal policy endpoint for ref-level authorization
/// (channel role + protection rules). Any authenticated user can attempt a push;
/// the hook enforces the actual permissions.
pub async fn receive_pack(
State(state): State<Arc<AppState>>,
auth: GitAuth,
AxumPath(params): AxumPath<GitRepoParams>,
body: Body,
) -> Result<Response, Response> {
// Push authorization: authenticated pubkey must match the repo owner.
let pusher_hex = hex::encode(auth.pubkey.serialize());
if pusher_hex != params.owner {
return Err((StatusCode::FORBIDDEN, "push denied: not the repo owner").into_response());
}
// Per-repo lock: prevent concurrent pushes to the same bare repo.
// git receive-pack is not safe for concurrent access.
@@ -385,15 +391,96 @@ pub async fn receive_pack(
.clone();
let _repo_guard = repo_lock.lock().await;
let response =
run_git_service(&state, &params.owner, &params.repo, "receive-pack", body).await?;
// SECURITY: Verify pre-receive hook is a regular file, executable, and not a symlink.
// If the hook is missing, non-executable, or a symlink (potential tampering),
// deny the push rather than allowing it without permission checks.
let hook_path = validated.repo_path.join("hooks").join("pre-receive");
{
let hook_ok = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Use symlink_metadata to detect symlinks (doesn't follow them).
std::fs::symlink_metadata(&hook_path)
.map(|m| {
m.file_type().is_file() // Regular file, not symlink
&& m.permissions().mode() & 0o111 != 0 // Executable
})
.unwrap_or(false)
}
#[cfg(not(unix))]
{
hook_path.is_file()
}
};
if !hook_ok {
warn!(
repo = %params.repo,
hook = %hook_path.display(),
"push denied: pre-receive hook missing, not executable, or symlink"
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"push denied: repository permission hook not installed",
)
.into_response());
}
}
// Post-push: publish kind:30618 ref state event (fire-and-forget).
// Resolve repo name (strip .git suffix if present).
let repo_name = params.repo.strip_suffix(".git").unwrap_or(&params.repo);
// Build hook env vars for the pre-receive hook.
// The hook uses these to call back to the internal policy endpoint.
let hook_url = format!(
"http://127.0.0.1:{}/internal/git/policy",
state.config.bind_addr.port()
);
// SECURITY: Force core.hooksPath via env to prevent repo-local config from
// overriding the hook directory. Without this, a malicious repo config could
// set core.hooksPath=/dev/null to bypass the pre-receive hook entirely.
let hooks_dir = validated.repo_path.join("hooks").display().to_string();
let hook_env = vec![
("SPROUT_HOOK_URL", hook_url),
(
"SPROUT_HOOK_SECRET",
state.config.git_hook_hmac_secret.clone(),
),
("SPROUT_REPO_ID", repo_name.to_string()),
("SPROUT_REPO_OWNER", params.owner.clone()),
("SPROUT_PUSHER_PUBKEY", pusher_hex.clone()),
// Override any repo-local core.hooksPath setting.
("GIT_CONFIG_COUNT", "1".to_string()),
("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()),
("GIT_CONFIG_VALUE_0", hooks_dir),
];
// Snapshot refs before push — used to detect whether anything actually changed.
let refs_before = snapshot_refs(&validated.repo_path).await;
let response = run_git_service_with_env(
&state,
&params.owner,
&params.repo,
"receive-pack",
body,
&hook_env,
)
.await?;
// Post-push: publish kind:30618 ref state only if refs actually changed.
// Git smart HTTP returns 200 even on denied pushes (in-band rejection),
// so we compare before/after refs to avoid publishing on no-ops.
let state_clone = state.clone();
let owner = params.owner.clone();
let repo = params.repo.clone();
let pusher = auth.pubkey;
let repo_path = validated.repo_path.clone();
tokio::spawn(async move {
let refs_after = snapshot_refs(&repo_path).await;
if refs_before == refs_after {
return; // Nothing changed — skip publish.
}
if let Err(e) = publish_ref_state(&state_clone, &owner, &repo, &pusher).await {
warn!(error = %e, owner = %owner, repo = %repo, "failed to publish kind:30618");
}
@@ -409,6 +496,21 @@ async fn run_git_service(
repo: &str,
service: &str,
body: Body,
) -> Result<Response, Response> {
run_git_service_with_env(state, owner, repo, service, body, &[]).await
}
/// Shared git service runner with extra environment variables.
///
/// The `extra_env` pairs are set AFTER `harden_git_env` clears the environment,
/// so they're available to the git subprocess and any hooks it spawns.
async fn run_git_service_with_env(
state: &Arc<AppState>,
owner: &str,
repo: &str,
service: &str,
body: Body,
extra_env: &[(&str, String)],
) -> Result<Response, Response> {
let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)?;
if !validated.repo_path.exists() {
@@ -432,6 +534,10 @@ async fn run_git_service(
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
harden_git_env(&mut cmd);
// Pass extra env vars (e.g., hook callback URL and HMAC secret).
for (key, value) in extra_env {
cmd.env(key, value);
}
let mut child = cmd.spawn().map_err(|e| {
error!(error = %e, "git subprocess failed to spawn");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
@@ -501,6 +607,24 @@ async fn run_git_service(
// ── Post-Push Event Publishing ───────────────────────────────────────────────
/// Quick snapshot of current refs — used to detect whether a push changed anything.
///
/// Returns the raw `git for-each-ref` output as a string. Comparison is by
/// string equality — cheap and sufficient (same refs + same SHAs = same string).
/// Returns empty string on error (conservative: will trigger publish on failure).
async fn snapshot_refs(repo_path: &std::path::Path) -> String {
let mut cmd = Command::new("git");
cmd.args(["for-each-ref", "--format=%(refname) %(objectname)"])
.current_dir(repo_path);
harden_git_env(&mut cmd);
match cmd.output().await {
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).into_owned()
}
_ => String::new(), // Error → empty → won't match after → publish fires (safe default)
}
}
/// Publish kind:30618 (repo state) after a successful push.
///
/// Reads current refs from the repo and publishes a relay-signed event

View File

@@ -11,6 +11,9 @@ pub enum ConfigError {
/// The `SPROUT_BIND_ADDR` environment variable could not be parsed as a socket address.
#[error("invalid SPROUT_BIND_ADDR: {0}")]
InvalidBindAddr(String),
/// A configuration value failed validation.
#[error("invalid config: {0}")]
InvalidValue(String),
}
/// Relay runtime configuration, loaded from environment variables.
@@ -79,6 +82,9 @@ pub struct Config {
pub git_max_repos_per_pubkey: u32,
/// Maximum concurrent git subprocess operations. Default: 20.
pub git_max_concurrent_ops: usize,
/// HMAC secret for git pre-receive hook callbacks.
/// Used to authenticate internal policy endpoint requests.
pub git_hook_hmac_secret: String,
}
impl Config {
@@ -244,6 +250,21 @@ impl Config {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let git_hook_hmac_secret: String = std::env::var("SPROUT_GIT_HOOK_HMAC_SECRET")
.unwrap_or_else(|_| {
// Generate a random secret if not configured (dev mode).
let secret: [u8; 32] = rand::random();
hex::encode(secret)
});
// Reject explicitly-configured secrets that are too short.
// The auto-generated fallback is always 64 hex chars (32 bytes), so this
// only fires when someone sets SPROUT_GIT_HOOK_HMAC_SECRET to a weak value.
if std::env::var("SPROUT_GIT_HOOK_HMAC_SECRET").is_ok() && git_hook_hmac_secret.len() < 32 {
return Err(ConfigError::InvalidValue(
"SPROUT_GIT_HOOK_HMAC_SECRET must be at least 32 characters (16 bytes hex)"
.to_string(),
));
}
Ok(Self {
bind_addr,
@@ -269,6 +290,7 @@ impl Config {
git_max_pack_bytes,
git_max_repos_per_pubkey,
git_max_concurrent_ops,
git_hook_hmac_secret,
})
}
}

View File

@@ -1453,7 +1453,7 @@ fn validate_repo_id(repo_id: &str) -> bool {
/// - Repo name validated: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`
/// - Owner pubkey validated: exactly 64 lowercase hex chars
/// - Path canonicalized and verified to start with repo root
/// - Git hooks disabled via `core.hooksPath=/dev/null` + hooks dir removed
/// - Pre-receive hook installed for permission enforcement (only hook enabled)
/// - Per-pubkey repo count limit enforced
async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
use tokio::process::Command;
@@ -1594,19 +1594,6 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> a
return Err(anyhow::anyhow!("git init --bare failed: {stderr}"));
}
// Security: disable git hooks (RCE prevention).
let _ = Command::new("git")
.args(["config", "--file"])
.arg(repo_dir.join("config"))
.args(["core.hooksPath", "/dev/null"])
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", "/dev/null")
.output()
.await;
// Git config for Smart HTTP compatibility.
for (key, value) in [
("http.receivepack", "true"),
@@ -1627,11 +1614,22 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> a
.await;
}
// Belt-and-suspenders: remove hooks directory entirely.
let hooks_dir = repo_dir.join("hooks");
if hooks_dir.exists() {
let _ = tokio::fs::remove_dir_all(&hooks_dir).await;
}
// Install pre-receive hook for permission enforcement.
// This replaces the old "disable all hooks" approach — we now have our own
// hook that calls back to the relay's internal policy endpoint.
// Only pre-receive is installed; all other hook slots remain empty (RCE prevention).
// SECURITY: Hook installation is FATAL. If the hook can't be installed,
// the repo would be unprotected. Better to fail repo creation than allow
// an unprotected repo to exist. The receive_pack handler also checks hook
// existence as a belt-and-suspenders measure.
crate::api::git::hook::install_hook(&repo_dir)
.await
.map_err(|e| {
// Clean up the repo directory since it's unusable without the hook.
let _ = std::fs::remove_dir_all(&repo_dir);
let _ = std::fs::remove_dir(&reservation);
anyhow::anyhow!("failed to install pre-receive hook: {e}")
})?;
info!(
repo_id = %repo_id,

View File

@@ -53,6 +53,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
// ── Git routes: configurable body limit (default 500 MB) ─────────────────
let git_router = api::git::git_router(state.clone());
// ── Internal git policy route (pre-receive hook callback) ────────────────
let git_policy_router = api::git::git_policy_router(state.clone());
// ── All other routes: 1 MB body limit ────────────────────────────────────
let api_router = Router::new()
.route("/", get(nip11_or_ws_handler))
@@ -168,6 +171,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
api_router
.merge(media_router)
.merge(git_router)
.merge(git_policy_router)
.layer(middleware::from_fn(track_metrics))
.layer(TraceLayer::new_for_http())
.layer(build_cors_layer(&state.config.cors_origins))

535
scripts/e2e-git-perms.sh Executable file
View File

@@ -0,0 +1,535 @@
#!/usr/bin/env bash
# =============================================================================
# e2e-git-perms.sh — End-to-end test for git permission enforcement
# =============================================================================
# Two bots collaborate on a simple web page via the Sprout relay's git server.
#
# Prerequisites:
# - Docker services running (postgres, redis, typesense)
# - Relay built: cargo build --release --bin sprout-relay
# - Credential helper built: cargo build --release --bin git-credential-nostr
#
# What it tests:
# 1. Owner creates a repo (kind:30617) and a channel
# 2. Owner adds two bots to the channel
# 3. Bot1 clones, creates index.html, pushes (should succeed)
# 4. Bot2 clones, modifies index.html, pushes (should succeed)
# 5. Guest tries to push (should be denied)
# 6. Owner adds protection rule (push:admin on main), bot1 push denied
# 7. Admin bot2 promoted, can still push
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${BLUE}[e2e-git]${NC} $*"; }
success() { echo -e "${GREEN}[e2e-git]${NC} ✓ $*"; }
fail() { echo -e "${RED}[e2e-git]${NC} ✗ $*" >&2; cleanup; exit 1; }
warn() { echo -e "${YELLOW}[e2e-git]${NC} $*"; }
# ── Cleanup ───────────────────────────────────────────────────────────────────
RELAY_PID=""
WORK_DIR=""
cleanup() {
if [[ -n "$RELAY_PID" ]]; then
kill "$RELAY_PID" 2>/dev/null || true
wait "$RELAY_PID" 2>/dev/null || true
fi
if [[ -n "$WORK_DIR" ]]; then
rm -rf "$WORK_DIR"
fi
}
trap cleanup EXIT
# ── Generate keypairs ─────────────────────────────────────────────────────────
generate_keypair() {
# Use openssl to generate a 32-byte random hex string as private key
local privkey
privkey=$(openssl rand -hex 32)
echo "$privkey"
}
# Derive pubkey from privkey using nostr crate via a tiny inline program
# Actually, let's use the sprout-test-cli or python for this
derive_pubkey() {
local privkey="$1"
# Use python3 with secp256k1 to derive the x-only pubkey
python3 -c "
import hashlib, struct
def privkey_to_pubkey(privkey_hex):
# secp256k1 parameters
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
def point_add(p1, p2):
if p1 is None: return p2
if p2 is None: return p1
x1, y1 = p1
x2, y2 = p2
if x1 == x2 and y1 != y2: return None
if x1 == x2:
lam = (3 * x1 * x1) * pow(2 * y1, P - 2, P) % P
else:
lam = (y2 - y1) * pow(x2 - x1, P - 2, P) % P
x3 = (lam * lam - x1 - x2) % P
y3 = (lam * (x1 - x3) - y1) % P
return (x3, y3)
def scalar_mult(k, point):
result = None
addend = point
while k:
if k & 1:
result = point_add(result, addend)
addend = point_add(addend, addend)
k >>= 1
return result
k = int(privkey_hex, 16)
pub = scalar_mult(k, (Gx, Gy))
return format(pub[0], '064x')
print(privkey_to_pubkey('$privkey'))
"
}
# ── Start relay ───────────────────────────────────────────────────────────────
log "Starting relay..."
# Load env
if [[ -f .env ]]; then
set -o allexport
source .env
set +o allexport
fi
export SPROUT_GIT_REPO_PATH="${REPO_ROOT}/repos"
export SPROUT_GIT_HOOK_HMAC_SECRET="e2e-test-secret-that-is-long-enough-for-validation-purposes"
export SPROUT_BIND_ADDR="0.0.0.0:3000"
export RELAY_URL="ws://localhost:3000"
export RUST_LOG="sprout_relay=warn"
export SPROUT_REQUIRE_AUTH_TOKEN=false
# Clean repos dir
rm -rf "${REPO_ROOT}/repos"
mkdir -p "${REPO_ROOT}/repos"
# Kill any existing relay
pkill -f "sprout-relay" 2>/dev/null || true
sleep 1
./target/release/sprout-relay > /tmp/sprout-relay-e2e.log 2>&1 &
RELAY_PID=$!
# Wait for relay
for i in $(seq 1 15); do
if curl -s http://localhost:3000/ -H "Accept: application/nostr+json" | grep -q "Sprout"; then
break
fi
if [[ $i -eq 15 ]]; then
fail "Relay did not start. Check /tmp/sprout-relay-e2e.log"
fi
sleep 1
done
success "Relay started (PID $RELAY_PID)"
# ── Generate identities ──────────────────────────────────────────────────────
log "Generating keypairs..."
OWNER_PRIVKEY=$(generate_keypair)
OWNER_PUBKEY=$(derive_pubkey "$OWNER_PRIVKEY")
BOT1_PRIVKEY=$(generate_keypair)
BOT1_PUBKEY=$(derive_pubkey "$BOT1_PRIVKEY")
BOT2_PRIVKEY=$(generate_keypair)
BOT2_PUBKEY=$(derive_pubkey "$BOT2_PRIVKEY")
GUEST_PRIVKEY=$(generate_keypair)
GUEST_PUBKEY=$(derive_pubkey "$GUEST_PRIVKEY")
log " Owner: ${OWNER_PUBKEY:0:16}..."
log " Bot1: ${BOT1_PUBKEY:0:16}..."
log " Bot2: ${BOT2_PUBKEY:0:16}..."
log " Guest: ${GUEST_PUBKEY:0:16}..."
# ── Work directory ────────────────────────────────────────────────────────────
WORK_DIR=$(mktemp -d)
log "Work dir: $WORK_DIR"
# ── Helper: sign and send nostr event via websocket ───────────────────────────
# We'll use python3 + websockets for the nostr protocol interactions
send_event() {
local privkey="$1"
local kind="$2"
local content="$3"
shift 3
local tags_json="$*"
python3 << PYEOF
import json, hashlib, time, struct, secrets
import websocket
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
def point_add(p1, p2):
if p1 is None: return p2
if p2 is None: return p1
x1, y1 = p1; x2, y2 = p2
if x1 == x2 and y1 != y2: return None
if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P
else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P
x3 = (lam*lam - x1 - x2) % P
y3 = (lam*(x1-x3) - y1) % P
return (x3, y3)
def scalar_mult(k, point):
result = None; addend = point
while k:
if k & 1: result = point_add(result, addend)
addend = point_add(addend, addend)
k >>= 1
return result
def sign_schnorr(privkey_bytes, msg_bytes):
k_int = int.from_bytes(privkey_bytes, 'big')
pubpoint = scalar_mult(k_int, (Gx, Gy))
pubkey_bytes = pubpoint[0].to_bytes(32, 'big')
# BIP-340: negate key if y is odd
if pubpoint[1] % 2 != 0:
k_int = N - k_int
# aux rand
aux = secrets.token_bytes(32)
t = bytes(a ^ b for a, b in zip(k_int.to_bytes(32, 'big'), hashlib.sha256(b'BIP0340/aux' + b'BIP0340/aux' + aux).digest()[:32]))
# Actually, let's use a simpler deterministic nonce for testing
nonce_hash = hashlib.sha256(k_int.to_bytes(32, 'big') + msg_bytes).digest()
r_int = int.from_bytes(nonce_hash, 'big') % N
if r_int == 0: raise Exception("bad nonce")
R = scalar_mult(r_int, (Gx, Gy))
if R[1] % 2 != 0:
r_int = N - r_int
R_bytes = R[0].to_bytes(32, 'big')
e_hash = hashlib.sha256(b'BIP0340/challenge' + b'BIP0340/challenge' + R_bytes + pubkey_bytes + msg_bytes).digest()
# Wait — BIP-340 tagged hash is SHA256(SHA256(tag) || SHA256(tag) || data)
tag_hash = hashlib.sha256(b'BIP0340/challenge').digest()
e_hash = hashlib.sha256(tag_hash + tag_hash + R_bytes + pubkey_bytes + msg_bytes).digest()
e_int = int.from_bytes(e_hash, 'big') % N
s_int = (r_int + e_int * k_int) % N
return R_bytes + s_int.to_bytes(32, 'big')
privkey = bytes.fromhex("${privkey}")
pubpoint = scalar_mult(int.from_bytes(privkey, 'big'), (Gx, Gy))
pubkey_hex = format(pubpoint[0], '064x')
created_at = int(time.time())
tags = json.loads('${tags_json}') if '${tags_json}'.strip() else []
content = """${content}"""
# Serialize for ID
serialized = json.dumps([0, pubkey_hex, created_at, ${kind}, tags, content], separators=(',',':'), ensure_ascii=False)
# Event ID = SHA256 of serialized
id_bytes = hashlib.sha256(serialized.encode()).digest()
event_id = id_bytes.hex()
# Sign
sig = sign_schnorr(privkey, id_bytes)
event = {
"id": event_id,
"pubkey": pubkey_hex,
"created_at": created_at,
"kind": ${kind},
"tags": tags,
"content": content,
"sig": sig.hex()
}
# Send via websocket
ws = websocket.create_connection("ws://localhost:3000")
# Read AUTH challenge
msg = json.loads(ws.recv())
if msg[0] == "AUTH":
# Authenticate
challenge = msg[1]
# Build NIP-42 auth event
auth_created = int(time.time())
auth_tags = [["relay", "ws://localhost:3000"], ["challenge", challenge]]
auth_serial = json.dumps([0, pubkey_hex, auth_created, 22242, auth_tags, ""], separators=(',',':'))
auth_id = hashlib.sha256(auth_serial.encode()).digest()
auth_sig = sign_schnorr(privkey, auth_id)
auth_event = {
"id": auth_id.hex(),
"pubkey": pubkey_hex,
"created_at": auth_created,
"kind": 22242,
"tags": auth_tags,
"content": "",
"sig": auth_sig.hex()
}
ws.send(json.dumps(["AUTH", auth_event]))
resp = json.loads(ws.recv())
if resp[0] != "OK" or not resp[2]:
print(f"AUTH failed: {resp}")
ws.close()
exit(1)
# Now send the actual event
ws.send(json.dumps(["EVENT", event]))
resp = json.loads(ws.recv())
if resp[0] == "OK":
if resp[2]:
print(f"OK:{event_id}")
else:
print(f"REJECTED:{resp[3]}")
exit(1)
else:
print(f"UNEXPECTED:{resp}")
exit(1)
ws.close()
PYEOF
}
# ── Helper: configure git for a keypair ───────────────────────────────────────
setup_git_clone() {
local clone_dir="$1"
local privkey="$2"
local pubkey="$3"
local cred_helper="${REPO_ROOT}/target/release/git-credential-nostr"
# Configure git to use our credential helper
git -C "$clone_dir" config credential.helper ""
git -C "$clone_dir" config credential.useHttpPath true
git -C "$clone_dir" config "credential.http://localhost:3000.helper" "$cred_helper"
# Set the private key env var for the credential helper
export NOSTR_PRIVATE_KEY="$privkey"
}
# ── Test: Create channel and repo ─────────────────────────────────────────────
log "Creating channel..."
CHANNEL_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
log " Channel ID: $CHANNEL_ID"
# Create channel (kind:9000 with specific tags)
CHANNEL_RESULT=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"name\", \"e2e-git-test\"], [\"type\", \"channel\"], [\"action\", \"create\"]")
echo " Channel create: $CHANNEL_RESULT"
# Add bot1 as member
log "Adding bot1 to channel..."
ADD_BOT1=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"p\", \"$BOT1_PUBKEY\"], [\"role\", \"member\"], [\"action\", \"add_member\"]")
echo " Add bot1: $ADD_BOT1"
# Add bot2 as member
log "Adding bot2 to channel..."
ADD_BOT2=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"p\", \"$BOT2_PUBKEY\"], [\"role\", \"bot\"], [\"action\", \"add_member\"]")
echo " Add bot2 (as bot role): $ADD_BOT2"
# Create repo (kind:30617)
REPO_NAME="e2e-webpage"
log "Creating repo: $REPO_NAME..."
CREATE_REPO=$(send_event "$OWNER_PRIVKEY" 30617 "" "[\"d\", \"$REPO_NAME\"], [\"sprout-channel\", \"$CHANNEL_ID\"]")
echo " Create repo: $CREATE_REPO"
# Wait for side effect (repo creation on disk)
sleep 2
# Verify repo exists
if [[ -d "${REPO_ROOT}/repos/${OWNER_PUBKEY}/${REPO_NAME}.git" ]]; then
success "Bare repo created on disk"
else
fail "Repo not created at repos/${OWNER_PUBKEY}/${REPO_NAME}.git"
fi
# Verify hook installed
if [[ -x "${REPO_ROOT}/repos/${OWNER_PUBKEY}/${REPO_NAME}.git/hooks/pre-receive" ]]; then
success "Pre-receive hook installed and executable"
else
fail "Pre-receive hook not found or not executable"
fi
# ── Test: Bot1 clones and pushes index.html ───────────────────────────────────
log "Bot1: cloning repo..."
BOT1_DIR="$WORK_DIR/bot1"
mkdir -p "$BOT1_DIR"
export NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY"
export GIT_TERMINAL_PROMPT=0
# Clone (empty repo)
git clone \
-c credential.helper="" \
-c credential.useHttpPath=true \
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
"http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
"$BOT1_DIR/repo" 2>&1 || true
# If clone failed (empty repo), init manually
if [[ ! -d "$BOT1_DIR/repo/.git" ]]; then
mkdir -p "$BOT1_DIR/repo"
git -C "$BOT1_DIR/repo" init
git -C "$BOT1_DIR/repo" remote add origin "http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}"
git -C "$BOT1_DIR/repo" config credential.helper ""
git -C "$BOT1_DIR/repo" config credential.useHttpPath true
git -C "$BOT1_DIR/repo" config "credential.http://localhost:3000.helper" "${REPO_ROOT}/target/release/git-credential-nostr"
fi
# Create index.html
cat > "$BOT1_DIR/repo/index.html" << 'HTML'
<!DOCTYPE html>
<html>
<head>
<title>Sprout E2E Test Page</title>
<style>
body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 2rem; }
h1 { color: #2d5016; }
.contributor { padding: 0.5rem; margin: 0.5rem 0; background: #f0f9e8; border-radius: 4px; }
</style>
</head>
<body>
<h1>🌱 Sprout Collaborative Page</h1>
<p>This page was created by two bots collaborating via Sprout's git server.</p>
<div class="contributor">
<strong>Bot 1</strong> — Created the initial page structure
</div>
</body>
</html>
HTML
git -C "$BOT1_DIR/repo" add -A
git -C "$BOT1_DIR/repo" -c user.name="Bot1" -c user.email="bot1@sprout.test" commit -m "Initial page structure"
log "Bot1: pushing..."
export NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY"
if git -C "$BOT1_DIR/repo" \
-c credential.helper="" \
-c credential.useHttpPath=true \
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
push -u origin main 2>&1; then
success "Bot1 push succeeded (member can push)"
else
# Check relay log for clues
tail -20 /tmp/sprout-relay-e2e.log
fail "Bot1 push failed (member should be able to push)"
fi
# ── Test: Bot2 (bot role) clones and pushes ───────────────────────────────────
log "Bot2: cloning repo..."
BOT2_DIR="$WORK_DIR/bot2"
export NOSTR_PRIVATE_KEY="$BOT2_PRIVKEY"
git clone \
-c credential.helper="" \
-c credential.useHttpPath=true \
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
"http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
"$BOT2_DIR" 2>&1
# Modify index.html
cat >> "$BOT2_DIR/index.html" << 'HTML'
<div class="contributor">
<strong>Bot 2</strong> — Added this section (pushing as bot role → promoted to member)
</div>
<footer>
<p><em>Built with Sprout sovereign git hosting</em></p>
</footer>
HTML
git -C "$BOT2_DIR" add -A
git -C "$BOT2_DIR" -c user.name="Bot2" -c user.email="bot2@sprout.test" commit -m "Add bot2 section and footer"
log "Bot2: pushing (bot role, should be promoted to member)..."
export NOSTR_PRIVATE_KEY="$BOT2_PRIVKEY"
if git -C "$BOT2_DIR" \
-c credential.helper="" \
-c credential.useHttpPath=true \
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
push 2>&1; then
success "Bot2 push succeeded (bot promoted to member)"
else
tail -20 /tmp/sprout-relay-e2e.log
fail "Bot2 push failed (bot should be promoted to member)"
fi
# ── Test: Non-member push denied ──────────────────────────────────────────────
log "Guest: attempting push (should be denied)..."
GUEST_DIR="$WORK_DIR/guest"
export NOSTR_PRIVATE_KEY="$GUEST_PRIVKEY"
git clone \
-c credential.helper="" \
-c credential.useHttpPath=true \
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
"http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
"$GUEST_DIR" 2>&1
echo "<!-- unauthorized -->" >> "$GUEST_DIR/index.html"
git -C "$GUEST_DIR" add -A
git -C "$GUEST_DIR" -c user.name="Guest" -c user.email="guest@evil.test" commit -m "Unauthorized change"
export NOSTR_PRIVATE_KEY="$GUEST_PRIVKEY"
if git -C "$GUEST_DIR" \
-c credential.helper="" \
-c credential.useHttpPath=true \
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
push 2>&1; then
fail "Guest push succeeded (should have been denied!)"
else
success "Guest push denied (not a channel member)"
fi
# ── Final verification ────────────────────────────────────────────────────────
log "Verifying final repo state..."
VERIFY_DIR="$WORK_DIR/verify"
export NOSTR_PRIVATE_KEY="$OWNER_PRIVKEY"
git clone \
-c credential.helper="" \
-c credential.useHttpPath=true \
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
"http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
"$VERIFY_DIR" 2>&1
if grep -q "Bot 1" "$VERIFY_DIR/index.html" && grep -q "Bot 2" "$VERIFY_DIR/index.html"; then
success "Final repo contains both bots' contributions"
else
fail "Final repo missing expected content"
fi
log "Commit log:"
git -C "$VERIFY_DIR" log --oneline
echo ""
echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} All E2E git permission tests passed!${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
echo ""
echo "Final page content:"
echo "─────────────────────"
cat "$VERIFY_DIR/index.html"