diff --git a/.gitignore b/.gitignore index f04f168ed..e0c07ad92 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ typesense-data/ # Hermit (toolchain manager cache) .hermit/ doc/ +repos/ diff --git a/Cargo.lock b/Cargo.lock index 2a97b9d8e..148fd5db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/git-credential-nostr/src/main.rs b/crates/git-credential-nostr/src/main.rs index 83e1b97c1..59be42f51 100644 --- a/crates/git-credential-nostr/src/main.rs +++ b/crates/git-credential-nostr/src/main.rs @@ -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) diff --git a/crates/sprout-core/src/channel.rs b/crates/sprout-core/src/channel.rs index 74e1366ba..e2caffc2a 100644 --- a/crates/sprout-core/src/channel.rs +++ b/crates/sprout-core/src/channel.rs @@ -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 { diff --git a/crates/sprout-core/src/git_perms.rs b/crates/sprout-core/src/git_perms.rs new file mode 100644 index 000000000..865462e36 --- /dev/null +++ b/crates/sprout-core/src/git_perms.rs @@ -0,0 +1,1029 @@ +//! Git permission types — ref patterns, protection rules, and policy evaluation inputs. +//! +//! This module defines the core data types for the Sprout git permission system. +//! The permission model: channel role = repo role; `sprout-protect` tags on +//! kind:30617 add constraints that apply to everyone (including the owner). +//! +//! # Architecture +//! +//! ```text +//! kind:30617 tags → parse → Vec +//! ↓ +//! push arrives → classify refs → match patterns → union rules → enforce +//! ``` + +use crate::channel::MemberRole; +use std::fmt; + +// ── Limits (DoS prevention for untrusted kind:30617 input) ─────────────────── + +/// Maximum number of `sprout-protect` tags per repo. +pub const MAX_PROTECTION_RULES: usize = 50; +/// Maximum character length of a ref pattern. +pub const MAX_PATTERN_LENGTH: usize = 256; +/// Maximum number of wildcard segments per pattern. +pub const MAX_WILDCARDS_PER_PATTERN: usize = 3; + +// ── Ref Pattern ────────────────────────────────────────────────────────────── + +/// A validated ref pattern for matching git refs. +/// +/// Grammar: `segment ("/" segment)*` where segment is either a literal +/// `[a-zA-Z0-9._-]+` or `*` (matches exactly one path segment). +/// +/// Patterns MUST start with `refs/`. No `**`, `?`, `[...]`, or partial globs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefPattern { + /// The original pattern string (e.g., "refs/heads/*"). + raw: String, + /// Pre-split segments for matching. + segments: Vec, +} + +/// A single segment in a ref pattern. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PatternSegment { + /// Matches exactly this literal string. + Literal(String), + /// Matches any single path segment. + Wildcard, + /// Matches one or more path segments (recursive). Must be the last segment. + RecursiveWildcard, +} + +/// Errors from parsing a ref pattern. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PatternError { + /// Pattern is empty. + Empty, + /// Pattern exceeds maximum length. + TooLong, + /// Pattern doesn't start with `refs/`. + MissingRefsPrefix, + /// A segment contains invalid characters or is a partial glob. + InvalidSegment(String), + /// Too many wildcard segments. + TooManyWildcards, +} + +impl fmt::Display for PatternError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "pattern is empty"), + Self::TooLong => write!(f, "pattern exceeds {MAX_PATTERN_LENGTH} chars"), + Self::MissingRefsPrefix => write!(f, "pattern must start with 'refs/'"), + Self::InvalidSegment(s) => write!(f, "invalid segment: {s:?}"), + Self::TooManyWildcards => { + write!(f, "pattern exceeds {MAX_WILDCARDS_PER_PATTERN} wildcards") + } + } + } +} + +impl std::error::Error for PatternError {} + +impl RefPattern { + /// Parse and validate a ref pattern string. + pub fn parse(pattern: &str) -> Result { + if pattern.is_empty() { + return Err(PatternError::Empty); + } + if pattern.len() > MAX_PATTERN_LENGTH { + return Err(PatternError::TooLong); + } + if !pattern.starts_with("refs/") { + return Err(PatternError::MissingRefsPrefix); + } + + let mut segments = Vec::new(); + let mut wildcard_count = 0; + + let parts: Vec<&str> = pattern.split('/').collect(); + for (i, part) in parts.iter().enumerate() { + if *part == "**" { + // `**` must be the last segment (recursive match). + if i != parts.len() - 1 { + return Err(PatternError::InvalidSegment( + "** must be the last segment".to_string(), + )); + } + wildcard_count += 1; + if wildcard_count > MAX_WILDCARDS_PER_PATTERN { + return Err(PatternError::TooManyWildcards); + } + segments.push(PatternSegment::RecursiveWildcard); + } else if *part == "*" { + wildcard_count += 1; + if wildcard_count > MAX_WILDCARDS_PER_PATTERN { + return Err(PatternError::TooManyWildcards); + } + segments.push(PatternSegment::Wildcard); + } else if part.is_empty() { + return Err(PatternError::InvalidSegment(String::new())); + } else if part.contains('*') + || part.contains('?') + || part.contains('[') + || part.contains(']') + { + // Partial globs (e.g., "v*") are not allowed. + return Err(PatternError::InvalidSegment(part.to_string())); + } else if !part + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') + { + return Err(PatternError::InvalidSegment(part.to_string())); + } else { + segments.push(PatternSegment::Literal(part.to_string())); + } + } + + Ok(Self { + raw: pattern.to_string(), + segments, + }) + } + + /// Test whether this pattern matches a given ref name. + /// + /// Matching is segment-by-segment: + /// - `*` matches exactly one path segment + /// - `**` (must be last) matches one or more remaining segments + pub fn matches(&self, ref_name: &str) -> bool { + let ref_segments: Vec<&str> = ref_name.split('/').collect(); + + // Check for recursive wildcard (must be last segment). + if let Some(PatternSegment::RecursiveWildcard) = self.segments.last() { + let prefix_len = self.segments.len() - 1; + // Ref must have at least as many segments as the prefix (+ 1 for the **) + if ref_segments.len() <= prefix_len { + return false; + } + // All prefix segments must match. + return self.segments[..prefix_len] + .iter() + .zip(ref_segments[..prefix_len].iter()) + .all(|(pat, seg)| match pat { + PatternSegment::Wildcard => true, + PatternSegment::Literal(lit) => lit == *seg, + PatternSegment::RecursiveWildcard => unreachable!(), + }); + } + + // Non-recursive: exact segment count match required. + if ref_segments.len() != self.segments.len() { + return false; + } + self.segments + .iter() + .zip(ref_segments.iter()) + .all(|(pat, seg)| match pat { + PatternSegment::Wildcard => true, + PatternSegment::Literal(lit) => lit == *seg, + PatternSegment::RecursiveWildcard => unreachable!(), + }) + } + + /// The raw pattern string. + pub fn as_str(&self) -> &str { + &self.raw + } +} + +impl fmt::Display for RefPattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.raw) + } +} + +// ── Update Classification ──────────────────────────────────────────────────── + +/// The type of ref update in a push. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateKind { + /// New ref (old_oid is zero). + Create, + /// Existing ref updated, new commit is a descendant of old (fast-forward). + FastForward, + /// Existing ref updated, new commit is NOT a descendant of old. + NonFastForward, + /// Ref deleted (new_oid is zero). + Delete, +} + +impl UpdateKind { + /// Classify a ref update from old/new OIDs. + /// + /// `is_ancestor` should be the result of `git merge-base --is-ancestor old new`. + /// For creates/deletes, the value is ignored. + pub fn classify(old_oid: &str, new_oid: &str, is_ancestor: bool) -> Self { + const ZERO_OID: &str = "0000000000000000000000000000000000000000"; + if old_oid == ZERO_OID { + Self::Create + } else if new_oid == ZERO_OID { + Self::Delete + } else if is_ancestor { + Self::FastForward + } else { + Self::NonFastForward + } + } +} + +/// A single ref update within a push. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefUpdate { + /// The ref being updated (e.g., "refs/heads/main"). + pub ref_name: String, + /// The type of update. + pub kind: UpdateKind, + /// Old OID (hex, 40 chars). Zero OID for creates. + pub old_oid: String, + /// New OID (hex, 40 chars). Zero OID for deletes. + pub new_oid: String, +} + +// ── Protection Rules ───────────────────────────────────────────────────────── + +/// A single protection rule parsed from a `sprout-protect` tag on kind:30617. +/// +/// Format: `["sprout-protect", "", "", ...]` +/// Multiple rules per tag are allowed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtectionRule { + /// The ref pattern this rule applies to. + pub pattern: RefPattern, + /// Minimum role required to push (if specified). + pub push_role: Option, + /// Whether non-fast-forward updates are forbidden. + pub no_force_push: bool, + /// Whether ref deletion is forbidden. + pub no_delete: bool, + /// Whether direct push is denied (must use NIP-34 patch). + /// + /// NOTE: This blocks ALL ref update kinds (create, FF, NFF, delete) — not just + /// fast-forward pushes. If set on a ref pattern, that ref can only be modified + /// via the NIP-34 patch workflow. This is intentional: the ref is fully governed + /// by the patch review process. + pub require_patch: bool, +} + +/// Errors from parsing a `sprout-protect` tag. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleParseError { + /// Tag has fewer than 2 values (need at least pattern + one rule). + TooFewValues, + /// Too many protection rules on this repo. + TooManyRules, + /// Invalid ref pattern. + InvalidPattern(PatternError), + /// Unknown rule string. + UnknownRule(String), + /// Invalid role in `push:`. + InvalidRole(String), +} + +impl fmt::Display for RuleParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TooFewValues => write!(f, "sprout-protect tag needs pattern + at least one rule"), + Self::TooManyRules => write!(f, "exceeds max {MAX_PROTECTION_RULES} rules per repo"), + Self::InvalidPattern(e) => write!(f, "invalid pattern: {e}"), + Self::UnknownRule(r) => write!(f, "unknown rule: {r:?}"), + Self::InvalidRole(r) => write!(f, "invalid role in push rule: {r:?}"), + } + } +} + +impl std::error::Error for RuleParseError {} + +/// Parse a single `sprout-protect` tag into a `ProtectionRule`. +/// +/// Tag format: `["sprout-protect", "", "", "", ...]` +/// The first element ("sprout-protect") should already be stripped — pass +/// the remaining values starting with the pattern. +/// Parse a single `sprout-protect` tag (simple API, discards unknown rules). +pub fn parse_protection_tag(values: &[&str]) -> Result { + let (rule, _unknowns) = parse_protection_tag_with_warnings(values)?; + Ok(rule) +} + +/// Parse a single `sprout-protect` tag, returning unknown rules for logging. +pub fn parse_protection_tag_with_warnings( + values: &[&str], +) -> Result<(ProtectionRule, Vec), RuleParseError> { + if values.len() < 2 { + return Err(RuleParseError::TooFewValues); + } + + let pattern = RefPattern::parse(values[0]).map_err(RuleParseError::InvalidPattern)?; + + let mut push_role: Option = None; + let mut no_force_push = false; + let mut no_delete = false; + let mut require_patch = false; + let mut unknown_rules = Vec::new(); + + for &rule_str in &values[1..] { + if let Some(role_str) = rule_str.strip_prefix("push:") { + let role: MemberRole = role_str + .parse() + .map_err(|_| RuleParseError::InvalidRole(role_str.to_string()))?; + // Reject push:bot and push:guest — nonsensical rules. + // Bot is promoted to Member at the policy layer; push:bot is meaningless. + // Guest cannot push regardless; push:guest would be confusing. + if matches!(role, MemberRole::Bot | MemberRole::Guest) { + return Err(RuleParseError::InvalidRole(role_str.to_string())); + } + // Take the strictest (highest permission level). + push_role = Some(match push_role { + None => role, + Some(existing) => { + if role.permission_level() > existing.permission_level() { + role + } else { + existing + } + } + }); + } else { + match rule_str { + "no-force-push" => no_force_push = true, + "no-delete" => no_delete = true, + "require-patch" => require_patch = true, + // Forward-compatibility: unknown rules are skipped but reported. + other => unknown_rules.push(other.to_string()), + } + } + } + + Ok(( + ProtectionRule { + pattern, + push_role, + no_force_push, + no_delete, + require_patch, + }, + unknown_rules, + )) +} + +/// Result of parsing protection tags — includes rules and any warnings. +#[derive(Debug, Clone)] +pub struct ParsedProtection { + /// Successfully parsed protection rules. + pub rules: Vec, + /// Unknown rule strings that were skipped (potential typos or future rules). + /// Callers should log these as warnings. + pub unknown_rules: Vec, +} + +/// Parse all `sprout-protect` tags from a kind:30617 event's tag list. +/// +/// Returns an error if any `sprout-protect` tag is structurally malformed. +/// Unknown rule strings are skipped but reported in `ParsedProtection::unknown_rules` +/// so callers can log warnings (helps catch typos while maintaining forward-compat). +/// Enforces the per-repo rule count limit. +pub fn parse_protection_tags(tags: &[Vec]) -> Result { + let mut rules = Vec::new(); + let mut unknown_rules = Vec::new(); + + for tag in tags { + if tag.first().map(|s| s.as_str()) != Some("sprout-protect") { + continue; + } + if rules.len() >= MAX_PROTECTION_RULES { + return Err(RuleParseError::TooManyRules); + } + let values: Vec<&str> = tag[1..].iter().map(|s| s.as_str()).collect(); + let (rule, unknowns) = parse_protection_tag_with_warnings(&values)?; + rules.push(rule); + unknown_rules.extend(unknowns); + } + + Ok(ParsedProtection { + rules, + unknown_rules, + }) +} + +// ── Built-in Defaults ──────────────────────────────────────────────────────── + +/// Built-in default minimum role for an operation when no `sprout-protect` tag matches. +pub fn default_min_role(ref_name: &str, kind: UpdateKind) -> MemberRole { + let is_branch = ref_name.starts_with("refs/heads/"); + let is_tag = ref_name.starts_with("refs/tags/"); + + match kind { + UpdateKind::Create => { + if is_branch || is_tag { + MemberRole::Member + } else { + MemberRole::Admin + } + } + UpdateKind::FastForward => { + if is_branch { + MemberRole::Member + } else if is_tag { + // Tag "move" (overwrite) = Admin. + MemberRole::Admin + } else { + MemberRole::Admin + } + } + UpdateKind::NonFastForward => MemberRole::Admin, + UpdateKind::Delete => MemberRole::Admin, + } +} + +// ── Effective Rules (union of all matching patterns) ───────────────────────── + +/// The effective constraints for a ref after unioning all matching rules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveRules { + /// Strictest `push:` from all matching patterns (if any). + pub push_role: Option, + /// Whether non-fast-forward is forbidden (any match sets this). + pub no_force_push: bool, + /// Whether deletion is forbidden (any match sets this). + pub no_delete: bool, + /// Whether direct push is denied (any match sets this). + pub require_patch: bool, + /// Whether any explicit rule matched (vs. using defaults). + pub has_explicit_match: bool, +} + +impl EffectiveRules { + /// Compute effective rules by unioning all protection rules that match a ref. + pub fn for_ref(ref_name: &str, rules: &[ProtectionRule]) -> Self { + let mut push_role: Option = None; + let mut no_force_push = false; + let mut no_delete = false; + let mut require_patch = false; + let mut has_explicit_match = false; + + for rule in rules { + if !rule.pattern.matches(ref_name) { + continue; + } + has_explicit_match = true; + + // Union: take strictest push role. + if let Some(role) = rule.push_role { + push_role = Some(match push_role { + None => role, + Some(existing) => { + if role.permission_level() > existing.permission_level() { + role + } else { + existing + } + } + }); + } + + // Union: any match sets these flags. + no_force_push = no_force_push || rule.no_force_push; + no_delete = no_delete || rule.no_delete; + require_patch = require_patch || rule.require_patch; + } + + Self { + push_role, + no_force_push, + no_delete, + require_patch, + has_explicit_match, + } + } +} + +// ── Policy Denial ──────────────────────────────────────────────────────────── + +/// A single denial reason from the policy engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Denial { + /// The ref that was denied. + pub ref_name: String, + /// Human-readable reason. + pub reason: String, +} + +impl fmt::Display for Denial { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.ref_name, self.reason) + } +} + +/// Evaluate a single ref update against effective rules and the pusher's role. +/// +/// Returns `Ok(())` if allowed, `Err(Denial)` if denied. +pub fn evaluate_ref_update( + update: &RefUpdate, + role: MemberRole, + rules: &[ProtectionRule], +) -> Result<(), Denial> { + let effective = EffectiveRules::for_ref(&update.ref_name, rules); + + // If no explicit rules match, use built-in defaults. + if !effective.has_explicit_match { + let min_role = default_min_role(&update.ref_name, update.kind); + if !role.has_at_least(min_role) { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: format!( + "requires {} role (you have {}), using built-in defaults", + min_role, role + ), + }); + } + return Ok(()); + } + + // Check require-patch (blocks all direct pushes). + if effective.require_patch { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: "direct push denied: require-patch is set, submit a NIP-34 patch".to_string(), + }); + } + + // Check push role. + // Explicit push:role can NEVER weaken the built-in default. Always take the + // HIGHER of (explicit, default). This prevents `push:member` from accidentally + // allowing Members to force-push, delete, or overwrite tags. + let default_role = default_min_role(&update.ref_name, update.kind); + let min_role = match effective.push_role { + Some(explicit) => { + // Take the stricter (higher permission level) of explicit vs default. + if explicit.permission_level() >= default_role.permission_level() { + explicit + } else { + default_role + } + } + None => default_role, + }; + if !role.has_at_least(min_role) { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: format!("requires {} role (you have {})", min_role, role), + }); + } + + // Check no-force-push. + if effective.no_force_push && update.kind == UpdateKind::NonFastForward { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: "non-fast-forward update denied: no-force-push is set".to_string(), + }); + } + + // Check no-delete. + if effective.no_delete && update.kind == UpdateKind::Delete { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: "ref deletion denied: no-delete is set".to_string(), + }); + } + + Ok(()) +} + +/// Evaluate an entire push (multiple ref updates) against protection rules. +/// +/// Returns `Ok(())` if ALL refs are allowed, `Err(Vec)` if any are denied. +/// A push is atomic — if any ref fails, the entire push is rejected. +pub fn evaluate_push( + updates: &[RefUpdate], + role: MemberRole, + rules: &[ProtectionRule], +) -> Result<(), Vec> { + let denials: Vec = updates + .iter() + .filter_map(|update| evaluate_ref_update(update, role, rules).err()) + .collect(); + + if denials.is_empty() { + Ok(()) + } else { + Err(denials) + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── RefPattern tests ───────────────────────────────────────────────── + + #[test] + fn pattern_parse_valid() { + let p = RefPattern::parse("refs/heads/main").unwrap(); + assert_eq!(p.segments.len(), 3); + assert!(p.matches("refs/heads/main")); + assert!(!p.matches("refs/heads/develop")); + } + + #[test] + fn pattern_wildcard_matches_one_segment() { + let p = RefPattern::parse("refs/heads/*").unwrap(); + assert!(p.matches("refs/heads/main")); + assert!(p.matches("refs/heads/feature")); + assert!(!p.matches("refs/heads/feature/sub")); + assert!(!p.matches("refs/tags/v1")); + } + + #[test] + fn pattern_multi_wildcard() { + let p = RefPattern::parse("refs/*/release/*").unwrap(); + assert!(p.matches("refs/heads/release/v1")); + assert!(!p.matches("refs/heads/release/v1/hotfix")); + } + + #[test] + fn pattern_rejects_partial_glob() { + assert!(matches!( + RefPattern::parse("refs/tags/v*"), + Err(PatternError::InvalidSegment(_)) + )); + } + + #[test] + fn pattern_rejects_missing_refs_prefix() { + assert!(matches!( + RefPattern::parse("heads/main"), + Err(PatternError::MissingRefsPrefix) + )); + } + + #[test] + fn pattern_rejects_empty() { + assert!(matches!(RefPattern::parse(""), Err(PatternError::Empty))); + } + + #[test] + fn pattern_rejects_too_many_wildcards() { + assert!(matches!( + RefPattern::parse("refs/*/*/*/*"), + Err(PatternError::TooManyWildcards) + )); + } + + #[test] + fn pattern_recursive_wildcard_matches_nested() { + let p = RefPattern::parse("refs/heads/**").unwrap(); + assert!(p.matches("refs/heads/main")); + assert!(p.matches("refs/heads/feature/foo")); + assert!(p.matches("refs/heads/feature/foo/bar")); + assert!(!p.matches("refs/tags/v1")); + } + + #[test] + fn pattern_recursive_wildcard_must_be_last() { + assert!(matches!( + RefPattern::parse("refs/**/heads"), + Err(PatternError::InvalidSegment(_)) + )); + } + + #[test] + fn pattern_recursive_requires_at_least_one_segment() { + let p = RefPattern::parse("refs/heads/**").unwrap(); + // Must match at least one segment after prefix + assert!(!p.matches("refs/heads")); + } + + // ── UpdateKind tests ───────────────────────────────────────────────── + + #[test] + fn classify_create() { + let zero = "0000000000000000000000000000000000000000"; + assert_eq!( + UpdateKind::classify(zero, "abc123abc123abc123abc123abc123abc123abcd", false), + UpdateKind::Create + ); + } + + #[test] + fn classify_delete() { + let zero = "0000000000000000000000000000000000000000"; + assert_eq!( + UpdateKind::classify("abc123abc123abc123abc123abc123abc123abcd", zero, false), + UpdateKind::Delete + ); + } + + #[test] + fn classify_fast_forward() { + assert_eq!( + UpdateKind::classify("aaa", "bbb", true), + UpdateKind::FastForward + ); + } + + #[test] + fn classify_non_fast_forward() { + assert_eq!( + UpdateKind::classify("aaa", "bbb", false), + UpdateKind::NonFastForward + ); + } + + // ── Protection rule parsing ────────────────────────────────────────── + + #[test] + fn parse_protection_tag_basic() { + let rule = + parse_protection_tag(&["refs/heads/main", "push:admin", "no-force-push"]).unwrap(); + assert_eq!(rule.push_role, Some(MemberRole::Admin)); + assert!(rule.no_force_push); + assert!(!rule.no_delete); + assert!(!rule.require_patch); + } + + #[test] + fn parse_protection_tag_all_rules() { + let rule = parse_protection_tag(&[ + "refs/heads/main", + "push:owner", + "no-force-push", + "no-delete", + "require-patch", + ]) + .unwrap(); + assert_eq!(rule.push_role, Some(MemberRole::Owner)); + assert!(rule.no_force_push); + assert!(rule.no_delete); + assert!(rule.require_patch); + } + + #[test] + fn parse_protection_tag_unknown_rule_skipped() { + // Forward-compatibility: unknown rules are silently skipped. + let rule = parse_protection_tag(&["refs/heads/main", "yolo", "no-force-push"]).unwrap(); + // "yolo" was skipped, but "no-force-push" was still applied. + assert!(rule.no_force_push); + assert!(rule.push_role.is_none()); + } + + #[test] + fn parse_protection_tag_invalid_role() { + assert!(matches!( + parse_protection_tag(&["refs/heads/main", "push:superadmin"]), + Err(RuleParseError::InvalidRole(_)) + )); + } + + #[test] + fn parse_protection_tag_rejects_push_bot_and_guest() { + // push:bot and push:guest are rejected — they're almost certainly user errors. + assert!(matches!( + parse_protection_tag(&["refs/heads/main", "push:bot"]), + Err(RuleParseError::InvalidRole(_)) + )); + assert!(matches!( + parse_protection_tag(&["refs/heads/main", "push:guest"]), + Err(RuleParseError::InvalidRole(_)) + )); + } + + // ── Effective rules (union semantics) ──────────────────────────────── + + #[test] + fn effective_rules_union_strictest_role() { + let rules = vec![ + parse_protection_tag(&["refs/heads/*", "push:member", "no-force-push"]).unwrap(), + parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap(), + ]; + let eff = EffectiveRules::for_ref("refs/heads/main", &rules); + assert_eq!(eff.push_role, Some(MemberRole::Admin)); // strictest + assert!(eff.no_force_push); // from the wildcard rule + assert!(eff.has_explicit_match); + } + + #[test] + fn effective_rules_no_match_uses_defaults() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()]; + let eff = EffectiveRules::for_ref("refs/heads/develop", &rules); + assert!(!eff.has_explicit_match); + } + + // ── Policy evaluation ──────────────────────────────────────────────── + + #[test] + fn evaluate_owner_passes_push_role() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_ok()); + } + + #[test] + fn evaluate_member_denied_push_admin() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_err()); + } + + #[test] + fn evaluate_no_force_push_blocks_owner() { + let rules = + vec![ + parse_protection_tag(&["refs/heads/main", "push:member", "no-force-push"]).unwrap(), + ]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::NonFastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // Owner is blocked by no-force-push! + assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_err()); + } + + #[test] + fn evaluate_no_force_push_allows_fast_forward() { + let rules = + vec![ + parse_protection_tag(&["refs/heads/main", "push:member", "no-force-push"]).unwrap(), + ]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // no-force-push should NOT block fast-forward pushes. + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok()); + } + + #[test] + fn evaluate_no_delete_blocks_admin() { + let rules = + vec![parse_protection_tag(&["refs/heads/main", "push:member", "no-delete"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::Delete, + old_oid: "a".repeat(40), + new_oid: "0".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Admin, &rules).is_err()); + } + + #[test] + fn evaluate_require_patch_blocks_all() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "require-patch"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_err()); + } + + #[test] + fn evaluate_defaults_member_can_ff_branch() { + let rules = vec![]; // No explicit rules + let update = RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok()); + } + + #[test] + fn evaluate_defaults_member_cannot_force_push() { + let rules = vec![]; // No explicit rules — defaults apply + let update = RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::NonFastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // Default: non-fast-forward requires Admin + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_err()); + } + + #[test] + fn evaluate_defaults_guest_cannot_push() { + let rules = vec![]; + let update = RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Guest, &rules).is_err()); + } + + #[test] + fn evaluate_bot_cannot_push_without_explicit_grant() { + let rules = vec![]; + let update = RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // Bot has permission_level 0 at the core evaluator level. + // NOTE: The policy layer (policy.rs) promotes Bot → Member before calling + // evaluate_push, so bots in a channel CAN push in practice. This test + // verifies the raw evaluator behavior; the promotion is tested in policy. + assert!(evaluate_ref_update(&update, MemberRole::Bot, &rules).is_err()); + } + + #[test] + fn evaluate_guest_denied_even_with_only_no_force_push_rule() { + // Regression test: a rule that only sets no-force-push (no push:role) + // should NOT let a Guest bypass the built-in default (Member required). + let rules = vec![parse_protection_tag(&["refs/heads/main", "no-force-push"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // Guest should be denied — built-in default requires Member for FF push. + assert!(evaluate_ref_update(&update, MemberRole::Guest, &rules).is_err()); + // Member should be allowed (meets default requirement). + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok()); + } + + #[test] + fn evaluate_push_member_cannot_weaken_destructive_defaults() { + // push:member should NOT allow Members to force-push or delete. + // The built-in default for NFF/Delete is Admin — explicit push:member + // can't weaken that for destructive operations. + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:member"]).unwrap()]; + // Member can FF push (non-destructive, explicit overrides default). + let ff_update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&ff_update, MemberRole::Member, &rules).is_ok()); + + // Member CANNOT force-push (destructive, default Admin still enforced). + let nff_update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::NonFastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&nff_update, MemberRole::Member, &rules).is_err()); + + // Admin CAN force-push (meets the default Admin requirement). + assert!(evaluate_ref_update(&nff_update, MemberRole::Admin, &rules).is_ok()); + + // Member CANNOT delete (destructive, default Admin still enforced). + let del_update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::Delete, + old_oid: "a".repeat(40), + new_oid: "0".repeat(40), + }; + assert!(evaluate_ref_update(&del_update, MemberRole::Member, &rules).is_err()); + } + + #[test] + fn evaluate_push_multiple_refs_partial_deny() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()]; + let updates = vec![ + RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }, + RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "c".repeat(40), + new_oid: "d".repeat(40), + }, + ]; + // Member can push to feature but not main + let result = evaluate_push(&updates, MemberRole::Member, &rules); + assert!(result.is_err()); + let denials = result.unwrap_err(); + assert_eq!(denials.len(), 1); + assert_eq!(denials[0].ref_name, "refs/heads/main"); + } +} diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index fe9f5dfd2..d3eeecb72 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -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. diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml index ab56a601c..4c3cdaa2e 100644 --- a/crates/sprout-relay/Cargo.toml +++ b/crates/sprout-relay/Cargo.toml @@ -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 } diff --git a/crates/sprout-relay/src/api/git/hook.rs b/crates/sprout-relay/src/api/git/hook.rs new file mode 100644 index 000000000..2bc1133e6 --- /dev/null +++ b/crates/sprout-relay/src/api/git/hook.rs @@ -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(()) +} diff --git a/crates/sprout-relay/src/api/git/mod.rs b/crates/sprout-relay/src/api/git/mod.rs new file mode 100644 index 000000000..538122bc4 --- /dev/null +++ b/crates/sprout-relay/src/api/git/mod.rs @@ -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, next: Next) -> Response { + let is_loopback = req + .extensions() + .get::>() + .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) -> 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) +} diff --git a/crates/sprout-relay/src/api/git/policy.rs b/crates/sprout-relay/src/api/git/policy.rs new file mode 100644 index 000000000..6fc0a1a72 --- /dev/null +++ b/crates/sprout-relay/src/api/git/policy.rs @@ -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, + /// 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, +} + +/// 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 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 { + let mut mac = Hmac::::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>, + Json(req): Json, +) -> 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> = 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::() { + 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 = 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}" + ); + } +} diff --git a/crates/sprout-relay/src/api/git.rs b/crates/sprout-relay/src/api/git/transport.rs similarity index 76% rename from crates/sprout-relay/src/api/git.rs rename to crates/sprout-relay/src/api/git/transport.rs index 06821a225..cb73de8a7 100644 --- a/crates/sprout-relay/src/api/git.rs +++ b/crates/sprout-relay/src/api/git/transport.rs @@ -42,8 +42,9 @@ const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300 /// Validates the `Authorization: Nostr ` 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> 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 = ` (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 = (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::(&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>, auth: GitAuth, AxumPath(params): AxumPath, body: Body, ) -> Result { - // 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, ¶ms.owner, ¶ms.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()); + } + } + + // Resolve repo name (strip .git suffix if present). + let repo_name = params.repo.strip_suffix(".git").unwrap_or(¶ms.repo); - // Post-push: publish kind:30618 ref state event (fire-and-forget). + // 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, + ¶ms.owner, + ¶ms.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 { + 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, + owner: &str, + repo: &str, + service: &str, + body: Body, + extra_env: &[(&str, String)], ) -> Result { 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 diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 37323ffaf..ce1c5f78a 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -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, }) } } diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 5f0fbe124..4cf5b6d10 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -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) -> anyhow::Result<()> { use tokio::process::Command; @@ -1594,19 +1594,6 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc) -> 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) -> 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, diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 118d78690..ce5f9f890 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -53,6 +53,9 @@ pub fn build_router(state: Arc) -> 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) -> 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)) diff --git a/scripts/e2e-git-perms.sh b/scripts/e2e-git-perms.sh new file mode 100755 index 000000000..a63411ed4 --- /dev/null +++ b/scripts/e2e-git-perms.sh @@ -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' + + + + Sprout E2E Test Page + + + +

🌱 Sprout Collaborative Page

+

This page was created by two bots collaborating via Sprout's git server.

+
+ Bot 1 — Created the initial page structure +
+ + +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' +
+ Bot 2 — Added this section (pushing as bot role → promoted to member) +
+
+

Built with Sprout sovereign git hosting

+
+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 "" >> "$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"