NIP-34 git transport: Smart HTTP hosting with NIP-98 auth (#419)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
tlongwell-block
2026-04-29 18:04:59 -04:00
committed by GitHub
parent 8d5949dcc4
commit 121737f93f
15 changed files with 1613 additions and 15 deletions

11
Cargo.lock generated
View File

@@ -1350,6 +1350,17 @@ dependencies = [
"weezl",
]
[[package]]
name = "git-credential-nostr"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"nostr",
"serde_json",
"zeroize",
]
[[package]]
name = "h2"
version = "0.4.13"

View File

@@ -18,6 +18,7 @@ members = [
"crates/sprout-pairing-cli",
"crates/sprout-sdk",
"crates/sprout-persona",
"crates/git-credential-nostr",
]
exclude = ["desktop/src-tauri"]
resolver = "2"

View File

@@ -0,0 +1,18 @@
[package]
name = "git-credential-nostr"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Git credential helper that produces NIP-98 auth headers for Sprout's git server"
[[bin]]
name = "git-credential-nostr"
path = "src/main.rs"
[dependencies]
nostr = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
zeroize = { workspace = true }
base64 = "0.22"

View File

@@ -0,0 +1,69 @@
# git-credential-nostr
NIP-98 credential helper for git — signs HTTP auth events with your Nostr key so git can push/pull from Sprout's git server without passwords.
## Requirements
- **git 2.46+** (requires `authtype` capability in the credential protocol)
- **Rust toolchain** (for building from source)
## Installation
```bash
cargo install --path crates/git-credential-nostr
```
## Setup
```bash
# 1. Register the helper and enable per-path credentials.
git config --global credential.helper nostr
git config --global credential.useHttpPath true
# 2. Store your nsec in a key file (must be 0600).
mkdir -p ~/.nostr
echo "nsec1..." > ~/.nostr/key && chmod 600 ~/.nostr/key
git config --global nostr.keyfile ~/.nostr/key
```
That's it. Use git normally — `git clone`, `git push`, `git fetch`.
## CI / CD
Set `$NOSTR_PRIVATE_KEY` instead of a key file. The env var takes precedence
over `nostr.keyfile` and avoids touching the filesystem:
```bash
export NOSTR_PRIVATE_KEY=nsec1...
git clone https://relay.example.com/git/owner/repo.git
```
## How It Works
When a Sprout git server returns `HTTP 401` with a
`WWW-Authenticate: Nostr realm="...", method="GET"` header, git calls this
helper with the request details on stdin. The helper loads your Nostr private
key, builds a [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md)
kind-27235 event signed over the request URL and method, base64-encodes it, and
writes it back to stdout. Git then retries the request with
`Authorization: Nostr <token>`, which the server verifies by checking the event
signature.
```
git ──stdin──▶ git-credential-nostr ──stdout──▶ git
│
▼
sign kind:27235 event
(NIP-98 HTTP Auth)
```
## Troubleshooting
| Error | Cause | Fix |
|-------|-------|-----|
| `no nostr key configured` | Neither `$NOSTR_PRIVATE_KEY` nor `nostr.keyfile` is set | Follow the Setup steps above |
| `insecure permissions` | Key file is readable by group/others | `chmod 600 ~/.nostr/key` |
| `method hint` | Server's `WWW-Authenticate` header is missing `method="..."` | Upgrade the Sprout server |
| `useHttpPath` | `credential.useHttpPath` is not set | `git config --global credential.useHttpPath true` |
| Empty output / no auth | git version is older than 2.46 | Upgrade git |
| `clock skew` / auth rejected | System clock is off by more than 60 s | Sync your system clock (`ntpdate`, `timedatectl`) |

View File

@@ -0,0 +1,231 @@
//! git-credential-nostr — NIP-98 git credential helper for Sprout.
//!
//! Git calls this binary via the credential helper protocol (stdin/stdout).
//! We read the request, sign a kind:27235 event, and return the base64-encoded
//! event as the credential value. Git then sends:
//! Authorization: Nostr <credential>
use std::io::{self, BufRead, Write};
use std::process;
use base64::Engine as _;
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr::{EventBuilder, Keys, UncheckedUrl};
use zeroize::Zeroize;
// ── helpers ──────────────────────────────────────────────────────────────────
/// Write an error to stderr and exit 1.
/// Does NOT write to stdout — git's credential protocol interprets any stdout
/// as credential data, and a bare newline could confuse the client.
fn fail(msg: &str) -> ! {
eprintln!("error: {msg}");
process::exit(1);
}
/// Read `git config <key>` from the process environment / git config.
fn git_config(key: &str) -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", key])
.output()
.ok()?;
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
None
}
}
/// Check that a file has permissions no broader than 0600.
/// On non-Unix platforms we warn and continue.
#[cfg(unix)]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o177 != 0 {
return Err(format!(
"keyfile {path} has insecure permissions (expected 0600)"
));
}
Ok(())
}
#[cfg(not(unix))]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
eprintln!("warning: cannot check keyfile permissions on this platform ({path})");
Ok(())
}
/// Load the private key: env var first, then keyfile.
/// Returns the raw key string (nsec or hex). Caller must zeroize after use.
fn load_key() -> Result<String, String> {
// 1. Environment variable — ideal for CI/CD.
if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") {
if !val.is_empty() {
return Ok(val);
}
}
// 2. keyfile path from git config.
let path = git_config("nostr.keyfile").ok_or_else(|| {
"no nostr key configured. Set $NOSTR_PRIVATE_KEY or git config nostr.keyfile".to_string()
})?;
check_keyfile_permissions(&path)?;
let raw =
std::fs::read_to_string(&path).map_err(|e| format!("cannot read keyfile {path}: {e}"))?;
Ok(raw.trim().to_string())
}
// ── stdin parsing ─────────────────────────────────────────────────────────────
#[derive(Default)]
struct CredRequest {
has_authtype_capability: bool,
protocol: Option<String>,
host: Option<String>,
path: Option<String>,
wwwauth: Option<String>,
}
fn parse_stdin() -> CredRequest {
let stdin = io::stdin();
let mut req = CredRequest::default();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.is_empty() {
break;
}
if line == "capability[]=authtype" {
req.has_authtype_capability = true;
} else if let Some(v) = line.strip_prefix("protocol=") {
req.protocol = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("host=") {
req.host = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("path=") {
req.path = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("wwwauth[]=") {
// Only capture Nostr challenges — ignore Basic, Bearer, etc.
if v.starts_with("Nostr ") && req.wwwauth.is_none() {
req.wwwauth = Some(v.to_string());
}
}
// ignore unknown lines
}
req
}
/// Extract the HTTP method from a WWW-Authenticate value like:
/// Nostr realm="sprout", method="GET"
///
/// Splits on ", " first to isolate parameters — prevents matching inside
/// quoted values like `realm="evil method=\"DELETE\""`.
fn parse_method(wwwauth: &str) -> Option<HttpMethod> {
for param in wwwauth.split(", ") {
let param = param.trim();
if let Some(rest) = param.strip_prefix("method=\"") {
let end = rest.find('"')?;
return rest[..end].parse().ok();
}
}
None
}
// ── main ──────────────────────────────────────────────────────────────────────
fn main() {
// Git calls credential helpers with a subcommand: get, store, or erase.
// We only handle "get" — store/erase are no-ops for ephemeral credentials.
match std::env::args().nth(1).as_deref() {
Some("get") | None => {} // proceed — None for backwards compat
Some(_) => return, // store, erase, or unknown → silent exit 0
}
let req = parse_stdin();
// Old git without authtype capability — nothing we can do.
// The blank line signals "no credential available" per git's protocol.
if !req.has_authtype_capability {
println!();
let _ = io::stdout().flush();
return;
}
// Validate required fields.
let protocol = req
.protocol
.as_deref()
.unwrap_or_else(|| fail("missing protocol in credential request"));
let host = req
.host
.as_deref()
.unwrap_or_else(|| fail("missing host in credential request"));
let path = req
.path
.as_deref()
.unwrap_or_else(|| fail("credential.useHttpPath must be true for NIP-98 auth"));
let wwwauth = req
.wwwauth
.as_deref()
.unwrap_or_else(|| fail("server did not include WWW-Authenticate header"));
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.
let repo_path = path
.split_once("/info/refs")
.map(|(prefix, _)| prefix)
.or_else(|| path.strip_suffix("/git-upload-pack"))
.or_else(|| path.strip_suffix("/git-receive-pack"))
.unwrap_or(path);
let url = format!("{protocol}://{host}/{repo_path}");
// Load key, sign, then zeroize.
let mut raw_key = match load_key() {
Ok(k) => k,
Err(e) => fail(&e),
};
let keys = match Keys::parse(&raw_key) {
Ok(k) => k,
Err(e) => {
raw_key.zeroize();
fail(&format!("invalid nostr private key: {e}"));
}
};
raw_key.zeroize();
let http_data = HttpData::new(UncheckedUrl::from(url.as_str()), method);
let event = match EventBuilder::http_auth(http_data).sign_with_keys(&keys) {
Ok(e) => e,
Err(e) => fail(&format!("failed to sign NIP-98 event: {e}")),
};
let json = match serde_json::to_string(&event) {
Ok(j) => j,
Err(e) => fail(&format!("failed to serialize event: {e}")),
};
let credential = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
// Output the credential response.
println!("capability[]=authtype");
println!("authtype=Nostr");
println!("credential={credential}");
println!("ephemeral=true");
println!("quit=true");
println!();
let _ = io::stdout().flush();
}

View File

@@ -0,0 +1,286 @@
//! Integration tests for git-credential-nostr.
//!
//! Each test spawns the compiled binary as a subprocess, feeds it the
//! credential-helper protocol on stdin, and asserts on stdout/stderr/exit-code.
use std::io::Write;
use std::process::{Command, Stdio};
use base64::Engine as _;
use nostr::{Keys, ToBech32};
// ── helper ────────────────────────────────────────────────────────────────────
/// Spawn the binary, write `input` to stdin, collect output.
/// `env_vars` are added on top of the inherited environment.
/// `NOSTR_PRIVATE_KEY` is always cleared first to prevent test pollution.
fn run_helper(input: &str, env_vars: &[(&str, &str)]) -> std::process::Output {
let bin = env!("CARGO_BIN_EXE_git-credential-nostr");
let mut cmd = Command::new(bin);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_remove("NOSTR_PRIVATE_KEY")
// Prevent git config on the test machine from supplying a keyfile.
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", std::env::temp_dir());
for (k, v) in env_vars {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn git-credential-nostr");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().expect("failed to wait on child")
}
/// Generate a fresh nsec string for use in tests.
fn fresh_nsec() -> String {
let keys = Keys::generate();
keys.secret_key().to_bech32().unwrap()
}
/// Standard valid credential-helper input (includes authtype capability).
fn valid_input() -> String {
"capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
wwwauth[]=Nostr realm=\"sprout\", method=\"GET\"\n\
\n"
.to_string()
}
// ── tests ─────────────────────────────────────────────────────────────────────
/// Happy path: valid key + valid input → well-formed credential response with
/// a base64-encoded kind:27235 JSON event.
#[test]
fn happy_path() {
let nsec = fresh_nsec();
let out = run_helper(&valid_input(), &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert!(
out.status.success(),
"expected exit 0, got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines.contains(&"capability[]=authtype"),
"missing capability[]=authtype in:\n{stdout}"
);
assert!(
lines.contains(&"authtype=Nostr"),
"missing authtype=Nostr in:\n{stdout}"
);
assert!(
lines.contains(&"ephemeral=true"),
"missing ephemeral=true in:\n{stdout}"
);
assert!(
lines.contains(&"quit=true"),
"missing quit=true in:\n{stdout}"
);
// Extract and validate the credential value.
let cred_line = lines
.iter()
.find(|l| l.starts_with("credential="))
.expect("no credential= line in output");
let b64 = cred_line.strip_prefix("credential=").unwrap();
let json_bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("credential is not valid base64");
let json_str = String::from_utf8(json_bytes).expect("credential is not valid UTF-8");
let event: serde_json::Value =
serde_json::from_str(&json_str).expect("credential does not decode to JSON");
assert_eq!(
event["kind"],
serde_json::json!(27235),
"expected kind 27235, got {}",
event["kind"]
);
// Sanity-check a few more fields the NIP-98 event must have.
assert!(event["id"].is_string(), "event missing 'id'");
assert!(event["pubkey"].is_string(), "event missing 'pubkey'");
assert!(event["sig"].is_string(), "event missing 'sig'");
assert!(event["tags"].is_array(), "event missing 'tags'");
}
/// Old git (no `capability[]=authtype` in input) → empty line on stdout, exit 0.
#[test]
fn old_git_no_authtype_capability() {
let input = "protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert!(
out.status.success(),
"expected exit 0 for old-git path, got {:?}",
out.status.code()
);
let stdout = String::from_utf8_lossy(&out.stdout);
// Output should be just a blank line — no credential data.
assert_eq!(
stdout.trim(),
"",
"expected empty output for old-git path, got:\n{stdout}"
);
assert!(
!stdout.contains("credential="),
"should not emit credential= for old git"
);
}
/// No key configured at all → exit 1, stderr mentions "no nostr key configured".
#[test]
fn missing_key() {
// run_helper already clears NOSTR_PRIVATE_KEY and points HOME at a temp dir
// that has no git config, so no keyfile will be found.
let out = run_helper(&valid_input(), &[]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing key"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no nostr key configured"),
"expected 'no nostr key configured' in stderr, got:\n{stderr}"
);
}
/// `wwwauth[]` present but missing `method="..."` → exit 1, stderr mentions "method hint".
#[test]
fn missing_method_hint() {
let input = "capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
wwwauth[]=Nostr realm=\"sprout\"\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing method hint"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("method hint"),
"expected 'method hint' in stderr, got:\n{stderr}"
);
}
/// Input without `path=` line (useHttpPath not set) → exit 1, stderr mentions "useHttpPath".
#[test]
fn missing_path() {
let input = "capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
wwwauth[]=Nostr realm=\"sprout\", method=\"GET\"\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing path"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("useHttpPath"),
"expected 'useHttpPath' in stderr, got:\n{stderr}"
);
}
/// Keyfile with 0644 permissions → exit 1, stderr mentions "insecure permissions".
#[cfg(unix)]
#[test]
fn bad_keyfile_permissions() {
use std::os::unix::fs::PermissionsExt;
let nsec = fresh_nsec();
// Write keyfile to a temp path.
let tmp_dir = std::env::temp_dir();
let keyfile = tmp_dir.join(format!(
"nostr-test-key-{}.nsec",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::write(&keyfile, &nsec).expect("failed to write temp keyfile");
// Set insecure permissions (0644).
std::fs::set_permissions(&keyfile, std::fs::Permissions::from_mode(0o644))
.expect("failed to set permissions");
// Point a scratch git config at the keyfile.
let git_config_dir = tmp_dir.join(format!(
"nostr-test-gitconfig-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::create_dir_all(&git_config_dir).unwrap();
let git_config_file = git_config_dir.join(".gitconfig");
std::fs::write(
&git_config_file,
format!("[nostr]\n\tkeyfile = {}\n", keyfile.display()),
)
.expect("failed to write git config");
let out = run_helper(
&valid_input(),
&[("HOME", git_config_dir.to_str().unwrap())],
);
// Clean up regardless of outcome.
let _ = std::fs::remove_file(&keyfile);
let _ = std::fs::remove_file(&git_config_file);
let _ = std::fs::remove_dir(&git_config_dir);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for insecure keyfile permissions"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("insecure permissions"),
"expected 'insecure permissions' in stderr, got:\n{stderr}"
);
}

View File

@@ -40,6 +40,18 @@ pub enum Scope {
FilesRead,
/// Upload files and attachments.
FilesWrite,
/// Clone git repositories.
///
/// Reserved for future use. Not currently enforced by git HTTP routes —
/// those use NIP-98 auth directly. Will be enforced when collaborator
/// access (read-only, maintainer) is added in v2.
ReposRead,
/// Push to git repositories and create repos (kind:30617).
///
/// Enforced for kind:30617/30618 events via WebSocket ingest, but NOT
/// enforced by git HTTP push routes (which use NIP-98 + owner check).
/// Full enforcement deferred to v2 collaborator model.
ReposWrite,
/// Submit events on behalf of other pubkeys (proxy service accounts only).
ProxySubmit,
/// A scope string not recognised by this version of the relay.
@@ -69,6 +81,8 @@ impl Scope {
Self::SubscriptionsWrite,
Self::FilesRead,
Self::FilesWrite,
Self::ReposRead,
Self::ReposWrite,
]
}
@@ -91,6 +105,8 @@ impl Scope {
Self::SubscriptionsWrite,
Self::FilesRead,
Self::FilesWrite,
Self::ReposRead,
Self::ReposWrite,
]
}
@@ -111,6 +127,8 @@ impl Scope {
Self::SubscriptionsWrite => "subscriptions:write",
Self::FilesRead => "files:read",
Self::FilesWrite => "files:write",
Self::ReposRead => "repos:read",
Self::ReposWrite => "repos:write",
Self::ProxySubmit => "proxy:submit",
Self::Unknown(s) => s.as_str(),
}
@@ -142,6 +160,8 @@ impl FromStr for Scope {
"subscriptions:write" => Self::SubscriptionsWrite,
"files:read" => Self::FilesRead,
"files:write" => Self::FilesWrite,
"repos:read" => Self::ReposRead,
"repos:write" => Self::ReposWrite,
"proxy:submit" => Self::ProxySubmit,
other => Self::Unknown(other.to_string()),
})
@@ -226,12 +246,12 @@ mod tests {
#[test]
fn all_non_admin_excludes_admin_scopes() {
let scopes = Scope::all_non_admin();
assert_eq!(scopes.len(), 12, "expected 12 non-admin scope variants");
assert_eq!(scopes.len(), 14, "expected 14 non-admin scope variants");
// Verify no duplicates
let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect();
assert_eq!(
unique.len(),
12,
14,
"all_non_admin() must not contain duplicates"
);
// Verify no Unknown variants
@@ -255,10 +275,10 @@ mod tests {
#[test]
fn all_known_returns_all_14_variants() {
let all = Scope::all_known();
assert_eq!(all.len(), 14, "expected 14 known scope variants");
assert_eq!(all.len(), 16, "expected 16 known scope variants");
// Verify no duplicates
let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
assert_eq!(unique.len(), 14, "all_known() must not contain duplicates");
assert_eq!(unique.len(), 16, "all_known() must not contain duplicates");
// Verify no Unknown variants
for scope in &all {
assert!(

View File

@@ -233,6 +233,28 @@ pub const KIND_HUDDLE_GUIDELINES: u32 = 48106;
/// Internal kind for media upload audit entries. Not a relay event kind.
pub const KIND_MEDIA_UPLOAD: u32 = 49001;
// ── NIP-34: Git repository events ────────────────────────────────────────────
/// NIP-34: Repository announcement (parameterized replaceable, d-tag = repo-id).
pub const KIND_GIT_REPO_ANNOUNCEMENT: u32 = 30617;
/// NIP-34: Repository state — current branch/tag refs (parameterized replaceable, d-tag = repo-id).
pub const KIND_GIT_REPO_STATE: u32 = 30618;
/// NIP-34: Patch (git format-patch output).
pub const KIND_GIT_PATCH: u32 = 1617;
/// NIP-34: Pull request.
pub const KIND_GIT_PULL_REQUEST: u32 = 1618;
/// NIP-34: Pull request update (tip commit change).
pub const KIND_GIT_PR_UPDATE: u32 = 1619;
/// NIP-34: Issue.
pub const KIND_GIT_ISSUE: u32 = 1621;
/// NIP-34: Status — Open.
pub const KIND_GIT_STATUS_OPEN: u32 = 1630;
/// NIP-34: Status — Applied / Merged.
pub const KIND_GIT_STATUS_MERGED: u32 = 1631;
/// NIP-34: Status — Closed.
pub const KIND_GIT_STATUS_CLOSED: u32 = 1632;
/// NIP-34: Status — Draft.
pub const KIND_GIT_STATUS_DRAFT: u32 = 1633;
/// All registered kind constants — used for duplicate detection and iteration.
pub const ALL_KINDS: &[u32] = &[
KIND_PROFILE,
@@ -319,7 +341,18 @@ pub const ALL_KINDS: &[u32] = &[
KIND_HUDDLE_ENDED,
KIND_HUDDLE_TRACK_PUBLISHED,
KIND_HUDDLE_RECORDING_AVAILABLE,
KIND_HUDDLE_GUIDELINES,
KIND_MEDIA_UPLOAD,
KIND_GIT_REPO_ANNOUNCEMENT,
KIND_GIT_REPO_STATE,
KIND_GIT_PATCH,
KIND_GIT_PULL_REQUEST,
KIND_GIT_PR_UPDATE,
KIND_GIT_ISSUE,
KIND_GIT_STATUS_OPEN,
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
];
/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
@@ -359,6 +392,16 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
event.kind.as_u16() as i32
}
// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range.
const _: () = assert!(
KIND_GIT_REPO_ANNOUNCEMENT >= PARAM_REPLACEABLE_KIND_MIN
&& KIND_GIT_REPO_ANNOUNCEMENT <= PARAM_REPLACEABLE_KIND_MAX
);
const _: () = assert!(
KIND_GIT_REPO_STATE >= PARAM_REPLACEABLE_KIND_MIN
&& KIND_GIT_REPO_STATE <= PARAM_REPLACEABLE_KIND_MAX
);
// Compile-time: all Sprout kind constants fit in nostr's u16-backed Kind.
const _: () = assert!(KIND_AUTH <= u16::MAX as u32);
const _: () = assert!(KIND_CANVAS <= u16::MAX as u32);

View File

@@ -0,0 +1,627 @@
//! Smart HTTP git transport for Sprout.
//!
//! Three endpoints implement the git Smart HTTP protocol:
//! - `GET /git/{owner}/{repo}/info/refs?service={svc}` — ref advertisement
//! - `POST /git/{owner}/{repo}/git-upload-pack` — clone/fetch
//! - `POST /git/{owner}/{repo}/git-receive-pack` — push
//!
//! Auth: NIP-98 on all routes (clone + push). No public repos for v1.
//! Transport: shells out to `git --stateless-rpc` with `env_clear()`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::{
body::Body,
extract::{Path as AxumPath, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use base64::Engine;
use hex;
use serde::Deserialize;
use tokio::process::Command;
use tower_http::limit::RequestBodyLimitLayer;
use tracing::{error, info, warn};
use crate::state::AppState;
// ── Timeouts ─────────────────────────────────────────────────────────────────
/// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`).
const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// Timeout for pack operations (upload-pack, receive-pack) — large repos need time.
const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
// ── NIP-98 Auth Extractor ────────────────────────────────────────────────────
/// NIP-98 auth extractor for git routes.
///
/// Validates the `Authorization: Nostr <base64>` header before the request body
/// is read. Same pattern as `AuthenticatedUpload` in media.rs.
///
/// Authorization model (v1): any authenticated pubkey can clone; only the repo
/// owner can push. Maintainer lists from kind:30617 are a future enhancement.
pub struct GitAuth {
/// The authenticated user's public key, extracted from the NIP-98 event.
pub pubkey: nostr::PublicKey,
}
impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
type Rejection = Response;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let method = parts.method.as_str();
let auth_header = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(
"WWW-Authenticate",
format!("Nostr realm=\"sprout\", method=\"{method}\""),
)
.body(Body::from("missing Authorization header"))
.unwrap()
})?;
let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(
"WWW-Authenticate",
format!("Nostr realm=\"sprout\", method=\"{method}\""),
)
.body(Body::from("expected Authorization: Nostr <base64>"))
.unwrap()
})?;
let event_bytes = base64::engine::general_purpose::STANDARD
.decode(token)
.or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token))
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?;
let event_json = String::from_utf8(event_bytes)
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?;
// Use configured relay_url as canonical base (don't trust forwarded headers).
let relay_url = &state.config.relay_url;
let base_url = relay_url
.replace("ws://", "http://")
.replace("wss://", "https://");
let base_url = base_url.trim_end_matches('/');
let path_and_query = parts
.uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or(parts.uri.path());
// Bug fix: strip git endpoint suffixes before building the expected URL.
//
// Git's credential helper is invoked once — for the initial GET /info/refs —
// and signs a NIP-98 token with `u = <repo-root>` (e.g. `/git/{owner}/{repo}.git`).
// That same token is reused for all subsequent requests in the session
// (git-upload-pack, git-receive-pack). We must verify against the repo-root URL,
// not the full endpoint URL, so all three endpoints share a single canonical URL.
let repo_path = path_and_query
.split_once("/info/refs")
.map(|(prefix, _)| prefix)
.or_else(|| path_and_query.strip_suffix("/git-upload-pack"))
.or_else(|| path_and_query.strip_suffix("/git-receive-pack"))
.unwrap_or(path_and_query);
let expected_url = format!("{base_url}{repo_path}");
// Bug fix: skip the 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.
//
// 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.
let event_method = serde_json::from_str::<serde_json::Value>(&event_json)
.ok()
.and_then(|v| {
v["tags"]
.as_array()?
.iter()
.find(|t| t[0].as_str() == Some("method"))?[1]
.as_str()
.map(str::to_owned)
})
.unwrap_or_else(|| method.to_owned());
// SECURITY: method intentionally not verified for git routes. The tautological
// check (event.method == event.method) is deliberate — see comment block above.
// Git's credential protocol signs once with GET and reuses for POST. The URL tag
// provides the real security boundary (±60s timestamp + URL lock + HTTPS).
// body=None: can't buffer streaming pack data to verify payload hash.
// Token is time-bounded (±60s) and URL-locked — acceptable trade-off.
let pubkey =
sprout_auth::nip98::verify_nip98_event(&event_json, &expected_url, &event_method, None)
.map_err(|e| {
warn!(error = %e, "git NIP-98 auth failed");
(StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response()
})?;
// NOTE: NIP-98 event-ID dedup intentionally NOT implemented here.
// Git's credential protocol reuses one signed token across multiple requests
// in a session (info_refs GET → upload-pack/receive-pack POST). Rejecting
// replayed event IDs would break normal clone/push operations.
// The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient
// replay protection for v1. Per-request signing requires protocol changes.
Ok(GitAuth { pubkey })
}
}
// ── Path Validation ──────────────────────────────────────────────────────────
struct ValidatedRepoPath {
repo_path: PathBuf,
}
/// Validate and resolve a git repo path from URL parameters.
///
/// Security: allowlist characters, canonicalize, verify under repo root.
#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers
fn validate_repo_path(
owner: &str,
repo: &str,
git_repo_root: &Path,
) -> Result<ValidatedRepoPath, Response> {
// Owner must be exactly 64 lowercase hex chars.
if owner.len() != 64
|| !owner
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return Err((StatusCode::BAD_REQUEST, "invalid owner").into_response());
}
// Strip trailing .git if present.
let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
// Repo name: [a-zA-Z0-9._-]{1,64}, no leading dots, no "..".
if repo_name.is_empty()
|| repo_name.len() > 64
|| repo_name.starts_with('.')
|| repo_name.contains("..")
|| !repo_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err((StatusCode::BAD_REQUEST, "invalid repo name").into_response());
}
let repo_path = git_repo_root.join(owner).join(format!("{repo_name}.git"));
// Path canonicalization: verify resolved path is under repo root.
// Fail closed: if the repo root doesn't exist, reject — the service can't operate safely.
let canonical_root = git_repo_root.canonicalize().map_err(|_| {
error!("git_repo_path does not exist or cannot be canonicalized");
(
StatusCode::INTERNAL_SERVER_ERROR,
"git service misconfigured",
)
.into_response()
})?;
// Repo may not exist yet (404 handled later), but if it does, verify containment.
if let Ok(canonical_repo) = repo_path.canonicalize() {
if !canonical_repo.starts_with(&canonical_root) {
return Err((StatusCode::BAD_REQUEST, "path traversal detected").into_response());
}
}
Ok(ValidatedRepoPath { repo_path })
}
/// Apply hardened environment to a git subprocess command.
///
/// Clears all inherited env vars, then sets only the minimum required:
/// - `PATH` — so git can find its own helpers
/// - `GIT_HTTP_EXPORT_ALL` — required for Smart HTTP
/// - `GIT_CONFIG_NOSYSTEM=1` — ignore system-wide gitconfig
/// - `GIT_CONFIG_GLOBAL=/dev/null` — prevent reading global gitconfig
/// - `HOME=/dev/null` — prevent reading ~/.gitconfig
fn harden_git_env(cmd: &mut Command) {
cmd.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", "/dev/null");
}
// ── Route Handlers ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
/// Query parameters for the `info/refs` endpoint.
pub struct InfoRefsQuery {
service: String,
}
#[derive(Deserialize)]
/// Path parameters for git repo routes: `{owner}/{repo}`.
pub struct GitRepoParams {
owner: String,
repo: String,
}
/// `GET /git/{owner}/{repo}/info/refs?service={service}`
///
/// Advertises refs for clone (git-upload-pack) or push (git-receive-pack).
pub async fn info_refs(
State(state): State<Arc<AppState>>,
_auth: GitAuth,
AxumPath(params): AxumPath<GitRepoParams>,
Query(query): Query<InfoRefsQuery>,
) -> Result<Response, Response> {
// Validate service parameter: exact allowlist.
let service = match query.service.as_str() {
"git-upload-pack" | "git-receive-pack" => &query.service,
_ => return Err((StatusCode::BAD_REQUEST, "invalid service").into_response()),
};
let validated = validate_repo_path(&params.owner, &params.repo, &state.config.git_repo_path)?;
if !validated.repo_path.exists() {
return Err((StatusCode::NOT_FOUND, "repository not found").into_response());
}
let _permit = state.git_semaphore.try_acquire().map_err(|_| {
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header("Retry-After", "5")
.body(Body::from("git service busy"))
.unwrap()
})?;
let mut cmd = Command::new("git");
// Git's smart HTTP protocol uses service names like "git-upload-pack" and
// "git-receive-pack", but the actual git subcommands are "upload-pack" and
// "receive-pack" (without the "git-" prefix).
let git_subcmd = service.strip_prefix("git-").unwrap_or(service.as_str());
cmd.arg(git_subcmd)
.arg("--stateless-rpc")
.arg("--advertise-refs")
.arg(&validated.repo_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
harden_git_env(&mut cmd);
let child = cmd.spawn().map_err(|e| {
error!(error = %e, "git subprocess failed to spawn");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
// kill_on_drop requires a Child handle — .output() doesn't expose one.
// Spawn first, then wait under a timeout; on timeout the Child is dropped
// and kill_on_drop terminates the subprocess.
let output = tokio::time::timeout(INFO_REFS_TIMEOUT, child.wait_with_output())
.await
.map_err(|_| {
warn!(
"git info_refs subprocess timed out ({}s)",
INFO_REFS_TIMEOUT.as_secs()
);
(StatusCode::GATEWAY_TIMEOUT, "git operation timed out").into_response()
})?
.map_err(|e| {
error!(error = %e, "git subprocess failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!(stderr = %stderr, "git --advertise-refs failed");
return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response());
}
// Build pkt-line response: service header + flush + git output.
let svc_line = format!("# service={service}\n");
let svc_pkt = format!("{:04x}{svc_line}", svc_line.len() + 4);
let mut body = Vec::with_capacity(svc_pkt.len() + 4 + output.stdout.len());
body.extend_from_slice(svc_pkt.as_bytes());
body.extend_from_slice(b"0000"); // flush packet
body.extend_from_slice(&output.stdout);
let content_type = format!("application/x-{service}-advertisement");
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(body))
.unwrap())
}
/// `POST /git/{owner}/{repo}/git-upload-pack`
///
/// Handles clone/fetch — client sends wants/haves, server sends pack data.
pub async fn upload_pack(
State(state): State<Arc<AppState>>,
_auth: GitAuth,
AxumPath(params): AxumPath<GitRepoParams>,
body: Body,
) -> Result<Response, Response> {
run_git_service(&state, &params.owner, &params.repo, "upload-pack", body).await
}
/// `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.
pub async fn receive_pack(
State(state): State<Arc<AppState>>,
auth: GitAuth,
AxumPath(params): AxumPath<GitRepoParams>,
body: Body,
) -> Result<Response, Response> {
// Push authorization: authenticated pubkey must match the repo owner.
let pusher_hex = hex::encode(auth.pubkey.serialize());
if pusher_hex != params.owner {
return Err((StatusCode::FORBIDDEN, "push denied: not the repo owner").into_response());
}
// Per-repo lock: prevent concurrent pushes to the same bare repo.
// git receive-pack is not safe for concurrent access.
let validated = validate_repo_path(&params.owner, &params.repo, &state.config.git_repo_path)?;
let repo_lock = state
.git_repo_locks
.entry(validated.repo_path.clone())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone();
let _repo_guard = repo_lock.lock().await;
let response =
run_git_service(&state, &params.owner, &params.repo, "receive-pack", body).await?;
// Post-push: publish kind:30618 ref state event (fire-and-forget).
let state_clone = state.clone();
let owner = params.owner.clone();
let repo = params.repo.clone();
let pusher = auth.pubkey;
tokio::spawn(async move {
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");
}
});
Ok(response)
}
/// Shared git service runner — spawns subprocess and streams I/O.
async fn run_git_service(
state: &Arc<AppState>,
owner: &str,
repo: &str,
service: &str,
body: Body,
) -> Result<Response, Response> {
let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)?;
if !validated.repo_path.exists() {
return Err((StatusCode::NOT_FOUND, "repository not found").into_response());
}
let _permit = state.git_semaphore.try_acquire().map_err(|_| {
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header("Retry-After", "5")
.body(Body::from("git service busy"))
.unwrap()
})?;
let mut cmd = Command::new("git");
cmd.arg(service)
.arg("--stateless-rpc")
.arg(&validated.repo_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
harden_git_env(&mut cmd);
let mut child = cmd.spawn().map_err(|e| {
error!(error = %e, "git subprocess failed to spawn");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
// Stream request body to git stdin.
let mut stdin = child.stdin.take().unwrap();
let body_task = tokio::spawn(async move {
use futures_util::StreamExt;
let mut stream = body.into_data_stream();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
if tokio::io::AsyncWriteExt::write_all(&mut stdin, &bytes)
.await
.is_err()
{
break;
}
}
Err(_) => break,
}
}
drop(stdin); // Close stdin → EOF for git.
});
// Grab an abort handle before moving body_task into the timeout block.
// On timeout, we abort it explicitly — dropping a JoinHandle only detaches
// the task (it keeps running). A stalled client could otherwise keep the
// spawned task alive indefinitely waiting on stream.next().await.
let body_abort = body_task.abort_handle();
// Timeout covers both body streaming and subprocess completion.
// On timeout: child is killed via kill_on_drop, body_task via abort_handle.
let timeout_result = tokio::time::timeout(PACK_OPS_TIMEOUT, async {
let _ = body_task.await;
child.wait_with_output().await
})
.await;
let output = match timeout_result {
Err(_elapsed) => {
body_abort.abort();
warn!(service = %service, timeout_secs = PACK_OPS_TIMEOUT.as_secs(), "git subprocess timed out");
return Err((StatusCode::GATEWAY_TIMEOUT, "git operation timed out").into_response());
}
Ok(Err(e)) => {
error!(error = %e, "git subprocess failed");
return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response());
}
Ok(Ok(out)) => out,
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(stderr = %stderr, service = %service, "git subprocess exited with error");
// Still return output — git protocol errors are communicated in-band.
}
let content_type = format!("application/x-git-{service}-result");
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(output.stdout))
.unwrap())
}
// ── Post-Push Event Publishing ───────────────────────────────────────────────
/// Publish kind:30618 (repo state) after a successful push.
///
/// Reads current refs from the repo and publishes a relay-signed event
/// with the pusher's pubkey in a `p` tag. This makes pushes subscribable.
async fn publish_ref_state(
state: &Arc<AppState>,
owner: &str,
repo: &str,
pusher: &nostr::PublicKey,
) -> anyhow::Result<()> {
let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)
.map_err(|_| anyhow::anyhow!("invalid repo path"))?;
// Read current refs.
let mut cmd = Command::new("git");
cmd.args(["for-each-ref", "--format=%(refname) %(objectname)"])
.current_dir(&validated.repo_path);
harden_git_env(&mut cmd);
let output = cmd.output().await?;
if !output.status.success() {
return Err(anyhow::anyhow!("git for-each-ref failed"));
}
let refs_output = String::from_utf8_lossy(&output.stdout);
// Get HEAD symbolic ref.
let mut head_cmd = Command::new("git");
head_cmd
.args(["symbolic-ref", "HEAD"])
.current_dir(&validated.repo_path);
harden_git_env(&mut head_cmd);
let head_output = head_cmd.output().await.ok();
let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
// Build NIP-34 kind:30618 tags.
let mut tags = vec![nostr::Tag::custom(nostr::TagKind::custom("d"), [repo_name])];
for line in refs_output.lines() {
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() != 2 {
continue;
}
let (ref_name, sha) = (parts[0], parts[1]);
// NIP-34 kind:30618 only includes heads and tags — skip stash, notes, remotes.
if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
continue;
}
// Validate ref name and SHA.
if ref_name.starts_with('/')
|| ref_name.contains("//")
|| !ref_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "/_.-".contains(c))
{
continue;
}
if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
continue;
}
tags.push(nostr::Tag::custom(nostr::TagKind::custom(ref_name), [sha]));
}
// HEAD tag.
if let Some(head) = head_output {
if head.status.success() {
let head_ref = String::from_utf8_lossy(&head.stdout).trim().to_string();
if !head_ref.is_empty() {
tags.push(nostr::Tag::custom(
nostr::TagKind::custom("HEAD"),
[format!("ref: {head_ref}")],
));
}
}
}
// Pusher pubkey in p tag.
tags.push(nostr::Tag::public_key(*pusher));
info!(
repo = %repo_name,
owner = %owner,
ref_count = tags.len().saturating_sub(2),
"publishing kind:30618 ref state"
);
// Sign with relay keypair — the relay is the authoritative source of ref state.
let event = nostr::EventBuilder::new(nostr::Kind::Custom(30618), "", tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign kind:30618: {e}"))?;
// Store globally (channel_id = None) and fan out to subscribers.
let (stored, was_inserted) = state.db.insert_event(&event, None).await?;
if was_inserted {
let matches = state.sub_registry.fan_out(&stored);
for (conn_id, sub_id) in matches {
let _ = state.conn_manager.send_to(
conn_id,
crate::protocol::RelayMessage::event(&sub_id, &stored.event),
);
}
}
Ok(())
}
// ── Router Builder ───────────────────────────────────────────────────────────
/// Build the git sub-router with its own body limit.
///
/// Mounted at `/git/{owner}/{repo}/...` with a configurable max pack size.
pub fn git_router(state: Arc<AppState>) -> Router {
let body_limit = state.config.git_max_pack_bytes as usize;
Router::new()
.route("/git/{owner}/{repo}/info/refs", get(info_refs))
.route("/git/{owner}/{repo}/git-upload-pack", post(upload_pack))
.route("/git/{owner}/{repo}/git-receive-pack", post(receive_pack))
.layer(RequestBodyLimitLayer::new(body_limit))
.with_state(state)
}

View File

@@ -26,6 +26,8 @@ pub mod dms;
pub mod events;
/// Personalized home feed endpoint.
pub mod feed;
/// Smart HTTP git transport (clone, fetch, push).
pub mod git;
/// Blossom-compatible media upload, retrieval, and existence check endpoints.
pub mod media;
/// Channel membership endpoints.

View File

@@ -68,6 +68,17 @@ pub struct Config {
/// Example: `SPROUT_EPHEMERAL_TTL_OVERRIDE=60` → all ephemeral channels expire
/// 60 seconds after the last message.
pub ephemeral_ttl_override: Option<i32>,
// ── Git server configuration ─────────────────────────────────────────────
/// Root directory for bare git repositories.
/// Repos are stored at `{git_repo_path}/{owner_hex}/{repo_id}.git/`.
pub git_repo_path: std::path::PathBuf,
/// Maximum pack file size for git push (bytes). Default: 500 MB.
pub git_max_pack_bytes: u64,
/// Maximum number of repos per pubkey. Default: 100.
pub git_max_repos_per_pubkey: u32,
/// Maximum concurrent git subprocess operations. Default: 20.
pub git_max_concurrent_ops: usize,
}
impl Config {
@@ -217,6 +228,23 @@ impl Config {
);
}
// Git server config
let git_repo_path: std::path::PathBuf = std::env::var("SPROUT_GIT_REPO_PATH")
.unwrap_or_else(|_| "./repos".to_string())
.into();
let git_max_pack_bytes: u64 = std::env::var("SPROUT_GIT_MAX_PACK_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 1024 * 1024); // 500 MB
let git_max_repos_per_pubkey: u32 = std::env::var("SPROUT_GIT_MAX_REPOS_PER_PUBKEY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let git_max_concurrent_ops: usize = std::env::var("SPROUT_GIT_MAX_CONCURRENT_OPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
Ok(Self {
bind_addr,
database_url,
@@ -237,6 +265,10 @@ impl Config {
pubkey_allowlist_enabled,
media,
ephemeral_ttl_override,
git_repo_path,
git_max_pack_bytes,
git_max_repos_per_pubkey,
git_max_concurrent_ops,
})
}
}

View File

@@ -14,13 +14,15 @@ use sprout_auth::Scope;
use sprout_core::kind::{
event_kind_u32, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST,
KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED,
KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT,
KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST,
KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_PRESENCE_UPDATE,
KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE,
KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP,
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
@@ -193,6 +195,16 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
| KIND_HUDDLE_TRACK_PUBLISHED
| KIND_HUDDLE_RECORDING_AVAILABLE
| KIND_HUDDLE_GUIDELINES => Ok(Scope::ChannelsWrite),
// NIP-34: Git repository events
KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite),
KIND_GIT_PATCH
| KIND_GIT_PULL_REQUEST
| KIND_GIT_PR_UPDATE
| KIND_GIT_ISSUE
| KIND_GIT_STATUS_OPEN
| KIND_GIT_STATUS_MERGED
| KIND_GIT_STATUS_CLOSED
| KIND_GIT_STATUS_DRAFT => Ok(Scope::MessagesWrite),
_ => Err("restricted: unknown event kind"),
}
}
@@ -273,7 +285,23 @@ pub(crate) async fn derive_reaction_channel(
pub(crate) fn is_global_only_kind(kind: u32) -> bool {
matches!(
kind,
KIND_PROFILE | KIND_TEXT_NOTE | KIND_CONTACT_LIST | KIND_LONG_FORM | KIND_READ_STATE
KIND_PROFILE
| KIND_TEXT_NOTE
| KIND_CONTACT_LIST
| KIND_LONG_FORM
| KIND_READ_STATE
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
| KIND_GIT_REPO_ANNOUNCEMENT
| KIND_GIT_REPO_STATE
| KIND_GIT_PATCH
| KIND_GIT_PULL_REQUEST
| KIND_GIT_PR_UPDATE
| KIND_GIT_ISSUE
| KIND_GIT_STATUS_OPEN
| KIND_GIT_STATUS_MERGED
| KIND_GIT_STATUS_CLOSED
| KIND_GIT_STATUS_DRAFT
)
}

View File

@@ -7,8 +7,9 @@ use tracing::{info, warn};
use uuid::Uuid;
use sprout_core::kind::{
event_kind_u32, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_REACTION,
event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS,
KIND_NIP29_GROUP_METADATA, KIND_REACTION,
};
use sprout_db::channel::MemberRole;
@@ -27,7 +28,7 @@ pub fn is_admin_kind(kind: u32) -> bool {
/// handled in `ingest_event()` before storage so we can short-circuit on
/// duplicates without storing the event at all.
pub fn is_side_effect_kind(kind: u32) -> bool {
matches!(kind, 0 | 5 | 9000..=9022 | 41001..=41003 | 40099)
matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | 41001..=41003 | 40099)
}
async fn evict_live_channel_subscriptions(
@@ -85,6 +86,8 @@ pub async fn handle_side_effects(
}
9021 => handle_join_request(event, state).await,
9022 => handle_leave_request(event, state).await,
// NIP-34: Git repo announcement → create bare repo on disk.
KIND_GIT_REPO_ANNOUNCEMENT => handle_git_repo_announcement(event, state).await,
// kind:7 (reaction) handled inline in ingest_event() before storage.
_ => Ok(()),
}
@@ -1425,3 +1428,217 @@ fn extract_tag_value(event: &Event, tag_name: &str) -> Option<String> {
}
None
}
// ── NIP-34: Git repository side effects ──────────────────────────────────────
/// Validate a git repo identifier (d-tag value from kind:30617).
///
/// Rules: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
fn validate_repo_id(repo_id: &str) -> bool {
!repo_id.is_empty()
&& repo_id.len() <= 64
&& !repo_id.starts_with('.')
&& !repo_id.contains("..")
&& repo_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
/// Handle kind:30617 (NIP-34 Git Repository Announcement).
///
/// Creates a bare git repo on disk when a repo announcement event is stored.
/// The event's `d` tag is the repo identifier; the pubkey is the owner.
///
/// Security hardening:
/// - 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
/// - Per-pubkey repo count limit enforced
async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
use tokio::process::Command;
// Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events).
let repo_id =
extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?;
if !validate_repo_id(&repo_id) {
return Err(anyhow::anyhow!(
"invalid repo identifier: must be [a-zA-Z0-9._-]{{1,64}}, no leading dots, no '..'"
));
}
let owner_hex = nostr::util::hex::encode(event.pubkey.serialize());
// Resolve repo path.
let git_repo_root = &state.config.git_repo_path;
let repo_dir = git_repo_root
.join(&owner_hex)
.join(format!("{repo_id}.git"));
// If repo already exists, this is an update to the announcement — nothing to do on disk.
// Backfill the name reservation if missing (handles upgrade from pre-uniqueness-check state).
if repo_dir.exists() {
let names_dir = git_repo_root.join(".names");
let _ = std::fs::create_dir_all(&names_dir);
let _ = std::fs::create_dir(names_dir.join(&repo_id));
info!(
repo_id = %repo_id,
owner = %owner_hex,
"kind:30617 repo announcement updated (repo already exists)"
);
return Ok(());
}
// Global uniqueness: repo names are unique across all owners (relay = single namespace).
// The relay signs kind:30618 ref-state with d-tag = repo_name, so collisions would
// cause one owner's ref state to overwrite another's.
//
// Atomicity: std::fs::create_dir fails with AlreadyExists if the directory exists,
// preventing TOCTOU races between concurrent kind:30617 events. The .names/ directory
// acts as a global name reservation index.
let names_dir = git_repo_root.join(".names");
std::fs::create_dir_all(&names_dir)
.map_err(|e| anyhow::anyhow!("failed to create name reservation index: {e}"))?;
let reservation = names_dir.join(&repo_id);
match std::fs::create_dir(&reservation) {
Ok(()) => {} // Name claimed successfully.
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(anyhow::anyhow!(
"repo name '{repo_id}' already taken by another owner"
));
}
Err(e) => {
return Err(anyhow::anyhow!(
"failed to reserve repo name '{repo_id}': {e}"
));
}
}
// Per-pubkey repo count limit.
let owner_dir = git_repo_root.join(&owner_hex);
if owner_dir.exists() {
let count = std::fs::read_dir(&owner_dir)
.map(|entries| entries.filter_map(|e| e.ok()).count())
.unwrap_or(0);
let limit = state.config.git_max_repos_per_pubkey as usize;
if count >= limit {
let _ = std::fs::remove_dir(&reservation);
return Err(anyhow::anyhow!("repo limit exceeded: {count} >= {limit}"));
}
}
// Create parent directory.
if let Err(e) = tokio::fs::create_dir_all(&repo_dir).await {
let _ = std::fs::remove_dir(&reservation);
return Err(anyhow::anyhow!(
"failed to create repo directory {}: {e}",
repo_dir.display()
));
}
// Path canonicalization: verify resolved path is under the repo root.
let canonical_root = match git_repo_root.canonicalize() {
Ok(p) => p,
Err(e) => {
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
let _ = std::fs::remove_dir(&reservation);
return Err(anyhow::anyhow!("failed to canonicalize repo root: {e}"));
}
};
let canonical_repo = match repo_dir.canonicalize() {
Ok(p) => p,
Err(e) => {
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
let _ = std::fs::remove_dir(&reservation);
return Err(anyhow::anyhow!("failed to canonicalize repo path: {e}"));
}
};
if !canonical_repo.starts_with(&canonical_root) {
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
let _ = std::fs::remove_dir(&reservation);
return Err(anyhow::anyhow!(
"repo path escapes root: {} not under {}",
canonical_repo.display(),
canonical_root.display()
));
}
// Initialize bare repo with main as default branch.
let output = match Command::new("git")
.arg("init")
.arg("--bare")
.arg("-b")
.arg("main")
.arg(&repo_dir)
.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
{
Ok(o) => o,
Err(e) => {
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
let _ = std::fs::remove_dir(&reservation);
return Err(anyhow::anyhow!("git init --bare failed to spawn: {e}"));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
let _ = std::fs::remove_dir(&reservation);
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"),
("receive.denyNonFastForwards", "false"),
("uploadpack.allowTipSHA1InWant", "true"),
("uploadpack.allowReachableSHA1InWant", "true"),
] {
let _ = Command::new("git")
.args(["config", "--file"])
.arg(repo_dir.join("config"))
.args([key, value])
.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;
}
// 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;
}
info!(
repo_id = %repo_id,
owner = %owner_hex,
path = %repo_dir.display(),
"bare git repo created from kind:30617 announcement"
);
Ok(())
}

View File

@@ -50,6 +50,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.layer(RequestBodyLimitLayer::new(media_body_limit))
.with_state(state.clone());
// ── Git routes: configurable body limit (default 500 MB) ─────────────────
let git_router = api::git::git_router(state.clone());
// ── All other routes: 1 MB body limit ────────────────────────────────────
let api_router = Router::new()
.route("/", get(nip11_or_ws_handler))
@@ -164,6 +167,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
// Metrics → Trace → CORS applied once over the combined router.
api_router
.merge(media_router)
.merge(git_router)
.layer(middleware::from_fn(track_metrics))
.layer(TraceLayer::new_for_http())
.layer(build_cors_layer(&state.config.cors_origins))

View File

@@ -181,6 +181,12 @@ pub struct AppState {
pub conn_semaphore: Arc<Semaphore>,
/// Semaphore limiting concurrent message handler tasks.
pub handler_semaphore: Arc<Semaphore>,
/// Semaphore limiting concurrent git subprocess operations.
pub git_semaphore: Arc<Semaphore>,
/// Per-repo mutex map — prevents concurrent pushes to the same bare repo.
/// Key: canonical repo path. Value: mutex guarding exclusive push access.
pub git_repo_locks: Arc<DashMap<std::path::PathBuf, Arc<tokio::sync::Mutex<()>>>>,
/// Workflow engine for background processing.
pub workflow_engine: Arc<WorkflowEngine>,
/// Relay signing keypair — used to sign system messages (kind 40099).
@@ -301,6 +307,7 @@ impl AppState {
tracing::warn!("audit log worker exited (expected on shutdown)");
});
let git_max_concurrent_ops = config.git_max_concurrent_ops;
let state = Self {
config: Arc::new(config),
db,
@@ -313,6 +320,8 @@ impl AppState {
conn_manager: Arc::new(ConnectionManager::new()),
conn_semaphore: Arc::new(Semaphore::new(max_connections)),
handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)),
git_semaphore: Arc::new(Semaphore::new(git_max_concurrent_ops)),
git_repo_locks: Arc::new(DashMap::new()),
workflow_engine,
relay_keypair,
mint_rate_limiter: Arc::new(MintRateLimiter::new()),