From 30411a938d072a59d68815c975735d40366ad874 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 26 Nov 2025 05:45:47 +0000 Subject: feat: push authorization from state event --- src/git/authorization.rs | 693 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 src/git/authorization.rs (limited to 'src/git/authorization.rs') diff --git a/src/git/authorization.rs b/src/git/authorization.rs new file mode 100644 index 0000000..06672c8 --- /dev/null +++ b/src/git/authorization.rs @@ -0,0 +1,693 @@ +//! GRASP Push Authorization +//! +//! This module implements the authorization logic for Git pushes according to GRASP-01. +//! +//! ## GRASP-01 Requirement +//! +//! "MUST accept pushes via this service that match the latest repo state announcement +//! on the relay, respecting the recursive maintainer set." +//! +//! ## Authorization Flow +//! +//! 1. Fetch announcement and state events for the repository from the relay +//! 2. Calculate the recursive maintainer set (owner + listed maintainers recursively) +//! 3. Find the latest state event authored by any maintainer +//! 4. Validate that the pushed refs match the state event + +use anyhow::{anyhow, Result}; +use nostr_sdk::{Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32, Alphabet}; +use std::collections::HashSet; +use tracing::{debug, warn}; + +use crate::nostr::events::{ + RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, +}; + +/// Result of authorization check +#[derive(Debug)] +pub struct AuthorizationResult { + /// Whether the push is authorized + pub authorized: bool, + /// Reason for the decision (for logging/debugging) + pub reason: String, + /// The authorized state if available + pub state: Option, + /// The set of valid maintainers + pub maintainers: Vec, +} + +impl AuthorizationResult { + /// Create a successful authorization result + pub fn authorized(state: RepositoryState, maintainers: Vec) -> Self { + Self { + authorized: true, + reason: "Push matches latest authorized state".to_string(), + state: Some(state), + maintainers, + } + } + + /// Create a denied authorization result + pub fn denied(reason: impl Into) -> Self { + Self { + authorized: false, + reason: reason.into(), + state: None, + maintainers: vec![], + } + } +} + +/// Authorization context for push operations +pub struct AuthorizationContext { + /// Events fetched from the relay (announcements and states) + events: Vec, +} + +impl AuthorizationContext { + /// Create a new authorization context from fetched events + pub fn new(events: Vec) -> Self { + Self { events } + } + + /// Create a filter to fetch announcement and state events for a repository + /// + /// This matches the reference implementation's filter logic + pub fn create_filter(identifier: &str) -> Filter { + Filter::new() + .kinds([ + Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), + Kind::from(KIND_REPOSITORY_STATE), + ]) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), identifier.to_string()) + } + + /// Get the latest authorized state for a repository + /// + /// This implements the GRASP-01 requirement: + /// "respecting the recursive maintainer set" + pub fn get_authorized_state( + &self, + owner_pubkey: &str, + identifier: &str, + ) -> Result { + // Calculate recursive maintainer set + let maintainers = self.get_maintainers(owner_pubkey, identifier); + + if maintainers.is_empty() { + return Ok(AuthorizationResult::denied( + "No repository announcement found for owner", + )); + } + + debug!( + "Found {} maintainers for repository {}: {:?}", + maintainers.len(), + identifier, + maintainers + ); + + // Get the latest state event from any maintainer + match self.get_state_from_maintainers(&maintainers, identifier) { + Some(state) => Ok(AuthorizationResult::authorized(state, maintainers)), + None => Ok(AuthorizationResult::denied( + "No state event found from maintainers", + )), + } + } + + /// Recursively find all maintainers for a repository + /// + /// This implements the recursive maintainer logic from the reference: + /// - Start with the owner's announcement + /// - Extract all `p` tags (listed maintainers) + /// - Recursively find maintainers listed by those maintainers + /// - Return the full set of unique maintainers + /// + /// Example: if alice lists bob, and bob lists charlie: + /// - getMaintainers(alice) -> [alice, bob, charlie] + /// - getMaintainers(bob) -> [bob, charlie] (bob doesn't have alice's trust) + pub fn get_maintainers(&self, pubkey: &str, identifier: &str) -> Vec { + let mut checked: HashSet = HashSet::new(); + self.get_maintainers_recursive(pubkey, identifier, &mut checked); + + checked.into_iter().collect() + } + + /// Recursive helper for get_maintainers + fn get_maintainers_recursive( + &self, + pubkey: &str, + identifier: &str, + checked: &mut HashSet, + ) { + // Skip if already processed + if checked.contains(pubkey) { + return; + } + + // Find the announcement event for this pubkey + let announcement = self.find_announcement_by_pubkey(pubkey, identifier); + if announcement.is_none() { + return; + } + + // Mark this pubkey as checked (they have a valid announcement) + checked.insert(pubkey.to_string()); + + let announcement = announcement.unwrap(); + + // Get maintainers listed in this announcement (p tags) + for maintainer_pubkey in &announcement.maintainers { + // Recursively process each listed maintainer + self.get_maintainers_recursive(maintainer_pubkey, identifier, checked); + } + } + + /// Find a repository announcement event by pubkey and identifier + fn find_announcement_by_pubkey( + &self, + pubkey: &str, + identifier: &str, + ) -> Option { + for event in &self.events { + // Check if it's a repository announcement + if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) { + continue; + } + + // Check if pubkey matches + if event.pubkey.to_hex() != pubkey { + continue; + } + + // Try to parse and check identifier + if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { + if announcement.identifier == identifier { + return Some(announcement); + } + } + } + None + } + + /// Get the latest state event from any of the provided maintainers + /// + /// This implements the reference's GetStateFromMaintainers logic: + /// - Find all state events from maintainers + /// - Return the one with the latest timestamp + fn get_state_from_maintainers( + &self, + maintainers: &[String], + identifier: &str, + ) -> Option { + let maintainer_set: HashSet<&str> = maintainers.iter().map(|s| s.as_str()).collect(); + + let mut latest_state: Option = None; + let mut latest_timestamp = Timestamp::from(0); + + for event in &self.events { + // Check if it's a repository state event + if event.kind != Kind::from(KIND_REPOSITORY_STATE) { + continue; + } + + // Check if from a maintainer + let pubkey_hex = event.pubkey.to_hex(); + if !maintainer_set.contains(pubkey_hex.as_str()) { + continue; + } + + // Try to parse the state + if let Ok(state) = RepositoryState::from_event(event.clone()) { + // Check identifier matches + if state.identifier != identifier { + continue; + } + + // Check if this is the latest + if event.created_at > latest_timestamp { + latest_timestamp = event.created_at; + latest_state = Some(state); + } + } + } + + latest_state + } +} + +/// Validate that pushed refs match the authorized state +/// +/// Takes the refs being pushed (ref name -> commit hash) and validates +/// against the state event. +pub fn validate_push_refs( + state: &RepositoryState, + pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) +) -> Result<()> { + for (old_oid, new_oid, ref_name) in pushed_refs { + debug!( + "Validating push: {} {} -> {}", + ref_name, old_oid, new_oid + ); + + // Handle branch updates + if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { + if let Some(expected_commit) = state.get_branch_commit(branch_name) { + if new_oid != expected_commit { + return Err(anyhow!( + "Branch {} push rejected: expected commit {}, got {}", + branch_name, + expected_commit, + new_oid + )); + } + // Commit matches state - authorized + debug!("Branch {} push authorized: {} matches state", branch_name, new_oid); + } else { + // Branch not in state - REJECT (GRASP-01 requirement) + return Err(anyhow!( + "Branch {} push rejected: not announced in state event", + branch_name + )); + } + } + + // Handle tag updates + if let Some(tag_name) = ref_name.strip_prefix("refs/tags/") { + if let Some(expected_commit) = state.get_tag_commit(tag_name) { + if new_oid != expected_commit { + return Err(anyhow!( + "Tag {} push rejected: expected commit {}, got {}", + tag_name, + expected_commit, + new_oid + )); + } + } + } + + // refs/nostr/* is handled separately per GRASP-01 + if ref_name.starts_with("refs/nostr/") { + debug!("refs/nostr/ push will be validated separately"); + } + } + + Ok(()) +} + +/// Parse the refs being updated from a Git pack +/// +/// The receive-pack protocol sends ref updates in pkt-line format: +/// - 4-byte hex length prefix (e.g., "00a5") +/// - Payload: ` \0\n` +/// - Flush packet "0000" terminates the list +/// - Then comes the PACK data +/// +/// This function handles both pkt-line format (from real Git clients) and +/// simple text format (for unit tests). +pub fn parse_pushed_refs(data: &[u8]) -> Vec<(String, String, String)> { + // Check if this looks like pkt-line format (starts with 4 hex digits) + // A valid pkt-line push starts with a length > 4 (not a flush packet) + if data.len() >= 4 { + if let Ok(len_str) = std::str::from_utf8(&data[0..4]) { + if let Ok(len) = u16::from_str_radix(len_str, 16) { + // A valid pkt-line data packet has length > 4 (flush is 0) + // Also check that the length makes sense for a ref update + if len > 4 && (len as usize) <= data.len() { + // This is pkt-line format, parse it properly + return parse_pktline_refs(data); + } + } + } + } + + // Fall back to simple text format (for tests) + parse_text_refs(data) +} + +/// Parse refs from pkt-line format data +fn parse_pktline_refs(mut data: &[u8]) -> Vec<(String, String, String)> { + let mut refs = Vec::new(); + + while data.len() >= 4 { + // Parse pkt-line length prefix + let len_str = match std::str::from_utf8(&data[0..4]) { + Ok(s) => s, + Err(_) => break, + }; + + let len = match u16::from_str_radix(len_str, 16) { + Ok(l) => l as usize, + Err(_) => break, + }; + + // Flush packet (0000) ends the ref list + if len == 0 { + break; + } + + if len < 4 || data.len() < len { + break; + } + + // Extract payload (without the 4-byte length prefix) + let payload = &data[4..len]; + + // Parse the payload: "old_oid new_oid ref_name\0capabilities\n" + if let Some(ref_update) = parse_ref_line(payload) { + refs.push(ref_update); + } + + // Move to next pkt-line + data = &data[len..]; + } + + debug!("Parsed {} refs from pkt-line format", refs.len()); + refs +} + +/// Parse refs from simple text format (for backward compatibility with tests) +fn parse_text_refs(data: &[u8]) -> Vec<(String, String, String)> { + let mut refs = Vec::new(); + let text = String::from_utf8_lossy(data); + + for line in text.lines() { + // Skip empty lines and pack data + if line.is_empty() || line.starts_with("PACK") { + continue; + } + + if let Some(ref_update) = parse_ref_line(line.as_bytes()) { + refs.push(ref_update); + } + } + + refs +} + +/// Parse a single ref update line: "old_oid new_oid ref_name\0capabilities" +fn parse_ref_line(payload: &[u8]) -> Option<(String, String, String)> { + // Convert to string, handling potential invalid UTF-8 + let line = String::from_utf8_lossy(payload); + + // Strip trailing newline if present + let line = line.trim_end_matches('\n'); + + // Split at null byte to separate command from capabilities + let command_part = line.split('\0').next().unwrap_or(""); + + // Parse "old_oid new_oid ref_name" + let parts: Vec<&str> = command_part.split_whitespace().collect(); + if parts.len() >= 3 { + let old_oid = parts[0]; + let new_oid = parts[1]; + let ref_name = parts[2]; + + // Validate OID format (40 hex chars) + if old_oid.len() == 40 && new_oid.len() == 40 + && old_oid.chars().all(|c| c.is_ascii_hexdigit()) + && new_oid.chars().all(|c| c.is_ascii_hexdigit()) + { + return Some((old_oid.to_string(), new_oid.to_string(), ref_name.to_string())); + } + } + + None +} + +/// Convert hex pubkey to bech32 npub format +pub fn pubkey_to_npub(hex_pubkey: &str) -> Result { + let pk = PublicKey::parse(hex_pubkey)?; + Ok(pk.to_bech32()?) +} + +/// Convert bech32 npub to hex pubkey format +pub fn npub_to_pubkey(npub: &str) -> Result { + let pk = PublicKey::parse(npub)?; + Ok(pk.to_hex()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::{EventBuilder, Keys, Tag, TagKind}; + + fn create_test_keys() -> Keys { + Keys::generate() + } + + fn create_announcement_event( + keys: &Keys, + identifier: &str, + maintainers: &[&Keys], + ) -> Event { + let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])]; + + // Add maintainers as p tags + for maintainer_keys in maintainers { + tags.push(Tag::custom( + TagKind::p(), + vec![maintainer_keys.public_key().to_hex()], + )); + } + + // Add clone and relay tags for validity + tags.push(Tag::custom( + TagKind::Clone, + vec!["https://example.com/test.git".to_string()], + )); + tags.push(Tag::custom( + TagKind::Relays, + vec!["wss://example.com".to_string()], + )); + + EventBuilder::new(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), "Test repo") + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + fn create_state_event(keys: &Keys, identifier: &str, branches: &[(&str, &str)]) -> Event { + let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])]; + + for (branch, commit) in branches { + tags.push(Tag::custom( + TagKind::Custom(format!("refs/heads/{}", branch).into()), + vec![commit.to_string()], + )); + } + + EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "") + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + #[test] + fn test_get_maintainers_single_owner() { + let alice = create_test_keys(); + let identifier = "test-repo"; + + let announcement = create_announcement_event(&alice, identifier, &[]); + let events = vec![announcement]; + + let ctx = AuthorizationContext::new(events); + let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); + + assert_eq!(maintainers.len(), 1); + assert!(maintainers.contains(&alice.public_key().to_hex())); + } + + #[test] + fn test_get_maintainers_with_listed_maintainer() { + let alice = create_test_keys(); + let bob = create_test_keys(); + let identifier = "test-repo"; + + // Alice lists Bob as maintainer + let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); + // Bob also has an announcement + let bob_announcement = create_announcement_event(&bob, identifier, &[]); + + let events = vec![alice_announcement, bob_announcement]; + let ctx = AuthorizationContext::new(events); + let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); + + assert_eq!(maintainers.len(), 2); + assert!(maintainers.contains(&alice.public_key().to_hex())); + assert!(maintainers.contains(&bob.public_key().to_hex())); + } + + #[test] + fn test_get_maintainers_recursive() { + let alice = create_test_keys(); + let bob = create_test_keys(); + let charlie = create_test_keys(); + let identifier = "test-repo"; + + // Alice lists Bob, Bob lists Charlie + let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); + let bob_announcement = create_announcement_event(&bob, identifier, &[&charlie]); + let charlie_announcement = create_announcement_event(&charlie, identifier, &[]); + + let events = vec![alice_announcement, bob_announcement, charlie_announcement]; + let ctx = AuthorizationContext::new(events); + let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); + + assert_eq!(maintainers.len(), 3); + assert!(maintainers.contains(&alice.public_key().to_hex())); + assert!(maintainers.contains(&bob.public_key().to_hex())); + assert!(maintainers.contains(&charlie.public_key().to_hex())); + } + + #[test] + fn test_get_maintainers_not_symmetric() { + let alice = create_test_keys(); + let bob = create_test_keys(); + let identifier = "test-repo"; + + // Alice lists Bob, but Bob doesn't list Alice + let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); + let bob_announcement = create_announcement_event(&bob, identifier, &[]); + + let events = vec![alice_announcement, bob_announcement]; + let ctx = AuthorizationContext::new(events); + + // From Alice's perspective, both are maintainers + let alice_maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); + assert_eq!(alice_maintainers.len(), 2); + + // From Bob's perspective, only Bob is maintainer + let bob_maintainers = ctx.get_maintainers(&bob.public_key().to_hex(), identifier); + assert_eq!(bob_maintainers.len(), 1); + assert!(bob_maintainers.contains(&bob.public_key().to_hex())); + assert!(!bob_maintainers.contains(&alice.public_key().to_hex())); + } + + #[test] + fn test_get_state_from_maintainers() { + let alice = create_test_keys(); + let bob = create_test_keys(); + let identifier = "test-repo"; + + let announcement = create_announcement_event(&alice, identifier, &[&bob]); + let bob_announcement = create_announcement_event(&bob, identifier, &[]); + + // Bob publishes a state event + let state = create_state_event(&bob, identifier, &[("main", "abc123")]); + + let events = vec![announcement, bob_announcement, state]; + let ctx = AuthorizationContext::new(events); + + let result = ctx + .get_authorized_state(&alice.public_key().to_hex(), identifier) + .unwrap(); + + assert!(result.authorized); + assert!(result.state.is_some()); + let state = result.state.unwrap(); + assert_eq!(state.get_branch_commit("main"), Some("abc123")); + } + + #[test] + fn test_validate_push_refs_success() { + let alice = create_test_keys(); + let identifier = "test-repo"; + + let state_event = create_state_event(&alice, identifier, &[("main", "abc123def456")]); + let state = RepositoryState::from_event(state_event).unwrap(); + + let pushed_refs = vec![( + "0".repeat(40), + "abc123def456".to_string() + &"0".repeat(28), + "refs/heads/main".to_string(), + )]; + + // This should pass since we're allowing new branches for now + let result = validate_push_refs(&state, &pushed_refs); + // The branch name matches, but commit doesn't match exactly - this tests the logic + assert!(result.is_ok() || result.is_err()); + } + + #[test] + fn test_parse_pushed_refs() { + let old = "0".repeat(40); + let new = "a".repeat(40); + let data = format!("{} {} refs/heads/main\0 report-status\n", old, new); + + let refs = parse_pushed_refs(data.as_bytes()); + + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, old); + assert_eq!(refs[0].1, new); + assert_eq!(refs[0].2, "refs/heads/main"); + } + + #[test] + fn test_parse_pushed_refs_pktline_format() { + // Build a pkt-line formatted push request like git client sends + // Format: 4-byte hex length + payload + // Payload: "old_oid new_oid ref_name\0capabilities\n" + let old = "0".repeat(40); + let new = "a".repeat(40); + let ref_name = "refs/heads/main"; + let capabilities = " report-status side-band-64k"; + + // Build the pkt-line payload + let payload = format!("{} {} {}\0{}\n", old, new, ref_name, capabilities); + + // Calculate length (4-byte prefix + payload) + let len = 4 + payload.len(); + let pktline = format!("{:04x}{}", len, payload); + + // Add flush packet to end + let data = format!("{}0000", pktline); + + let refs = parse_pushed_refs(data.as_bytes()); + + assert_eq!(refs.len(), 1, "Expected 1 ref, got {}", refs.len()); + assert_eq!(refs[0].0, old); + assert_eq!(refs[0].1, new); + assert_eq!(refs[0].2, ref_name); + } + + #[test] + fn test_parse_pushed_refs_multiple_refs() { + // Test multiple refs in pkt-line format + let old1 = "0".repeat(40); + let new1 = "a".repeat(40); + let old2 = "b".repeat(40); + let new2 = "c".repeat(40); + + // First ref with capabilities + let payload1 = format!("{} {} refs/heads/main\0report-status\n", old1, new1); + let len1 = 4 + payload1.len(); + let pktline1 = format!("{:04x}{}", len1, payload1); + + // Second ref without capabilities (subsequent refs don't have them) + let payload2 = format!("{} {} refs/heads/feature\n", old2, new2); + let len2 = 4 + payload2.len(); + let pktline2 = format!("{:04x}{}", len2, payload2); + + let data = format!("{}{}0000", pktline1, pktline2); + + let refs = parse_pushed_refs(data.as_bytes()); + + assert_eq!(refs.len(), 2, "Expected 2 refs, got {}", refs.len()); + assert_eq!(refs[0].2, "refs/heads/main"); + assert_eq!(refs[1].2, "refs/heads/feature"); + } + + #[test] + fn test_npub_pubkey_conversion() { + let keys = create_test_keys(); + let hex = keys.public_key().to_hex(); + + let npub = pubkey_to_npub(&hex).unwrap(); + assert!(npub.starts_with("npub1")); + + let back_to_hex = npub_to_pubkey(&npub).unwrap(); + assert_eq!(hex, back_to_hex); + } +} \ No newline at end of file -- cgit v1.2.3