From 1feb18e2e06c0120b3e50611ed5d744fabfd7723 Mon Sep 17 00:00:00 2001
From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Date: Sun, 3 May 2026 10:05:30 -0400
Subject: [PATCH] feat(git-sign-nostr): implement NIP-GS git object signing
with Nostr keys (#459)
---
Cargo.lock | 27 +
Cargo.toml | 1 +
crates/git-sign-nostr/Cargo.toml | 41 +
crates/git-sign-nostr/README.md | 46 +
crates/git-sign-nostr/src/main.rs | 2544 +++++++++++++++++++++++++++++
scripts/e2e-git-perms.sh | 1110 +++++++++----
6 files changed, 3413 insertions(+), 356 deletions(-)
create mode 100644 crates/git-sign-nostr/Cargo.toml
create mode 100644 crates/git-sign-nostr/README.md
create mode 100644 crates/git-sign-nostr/src/main.rs
diff --git a/Cargo.lock b/Cargo.lock
index 148fd5db5..af1dcd23c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1361,6 +1361,19 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "git-sign-nostr"
+version = "0.1.0"
+dependencies = [
+ "base64",
+ "chrono",
+ "hex",
+ "libc",
+ "nostr",
+ "serde_json",
+ "zeroize",
+]
+
[[package]]
name = "h2"
version = "0.4.13"
@@ -5679,6 +5692,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
[[package]]
name = "zerotrie"
diff --git a/Cargo.toml b/Cargo.toml
index c04a38e9f..1e112aa7f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,7 @@ members = [
"crates/sprout-sdk",
"crates/sprout-persona",
"crates/git-credential-nostr",
+ "crates/git-sign-nostr",
]
exclude = ["desktop/src-tauri"]
resolver = "2"
diff --git a/crates/git-sign-nostr/Cargo.toml b/crates/git-sign-nostr/Cargo.toml
new file mode 100644
index 000000000..7e2c9b456
--- /dev/null
+++ b/crates/git-sign-nostr/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+name = "git-sign-nostr"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "NIP-GS git commit/tag signing program using Nostr secp256k1 keys"
+readme = "README.md"
+publish = false # internal workspace tool, not published to crates.io
+
+[[bin]]
+name = "git-sign-nostr"
+path = "src/main.rs"
+
+[dependencies]
+# Base64 armor encoding/decoding for NIP-GS signature envelopes.
+# Not in workspace deps — each crate pins independently (same pattern as
+# sprout-relay, sprout-mcp, sprout-cli, git-credential-nostr).
+base64 = "0.22"
+
+# Hex encoding for BIP-340 signatures and public keys.
+hex = { workspace = true }
+
+# Secret key zeroization on drop.
+zeroize = { workspace = true, features = ["derive"] }
+
+# Nostr key parsing (nsec/npub bech32), secp256k1 Schnorr signing, SHA-256.
+# Uses the full default feature set because we need: Keys, PublicKey,
+# FromBech32, and the re-exported bitcoin::secp256k1 + bitcoin::hashes.
+nostr = { workspace = true }
+
+# JSON parsing for NIP-OA auth tag and envelope verification.
+serde_json = { workspace = true }
+
+# Timestamp formatting for GnuPG VALIDSIG status lines.
+chrono = { workspace = true }
+
+# Unix-specific: O_NOFOLLOW for keyfile open, fcntl for fd validation.
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
diff --git a/crates/git-sign-nostr/README.md b/crates/git-sign-nostr/README.md
new file mode 100644
index 000000000..26e4b5ca0
--- /dev/null
+++ b/crates/git-sign-nostr/README.md
@@ -0,0 +1,46 @@
+# git-sign-nostr
+
+NIP-GS signing program — signs git commits and tags with Nostr secp256k1 keys
+using BIP-340 Schnorr signatures.
+
+## Usage
+
+```bash
+# Configure git to use nostr signing
+git config gpg.format x509
+git config gpg.x509.program /path/to/git-sign-nostr
+git config commit.gpgsign true
+git config tag.gpgsign true
+git config user.signingkey
+
+# Set the private key (env var)
+export NOSTR_PRIVATE_KEY=
+
+# Optional: NIP-OA owner attestation
+export SPROUT_AUTH_TAG='["auth","","",""]'
+
+# Commits are now automatically signed
+git commit -m "signed with nostr"
+
+# Verify
+git verify-commit HEAD
+```
+
+## Key Loading Priority
+
+1. `NOSTR_PRIVATE_KEY` environment variable
+2. `SPROUT_PRIVATE_KEY` environment variable
+3. Keyfile at path from `git config nostr.keyfile`
+
+Keys may be hex (64 chars) or NIP-19 bech32 (`nsec1...`).
+
+## How It Works
+
+Git invokes this program as a signing/verification backend:
+
+- **Sign:** `git-sign-nostr --status-fd=2 -bsau ` — reads payload from
+ stdin, writes armored signature to stdout, status lines to fd 2 (stderr)
+- **Verify:** `git-sign-nostr --status-fd=1 --verify -` — reads
+ payload from stdin, verifies signature from file, status lines to fd 1 (stdout)
+
+See [NIP-GS](../../docs/nips/NIP-GS.md) for the full specification.
diff --git a/crates/git-sign-nostr/src/main.rs b/crates/git-sign-nostr/src/main.rs
new file mode 100644
index 000000000..87403413b
--- /dev/null
+++ b/crates/git-sign-nostr/src/main.rs
@@ -0,0 +1,2544 @@
+//! git-sign-nostr — NIP-GS git object signing with Nostr keys.
+//!
+//! A pluggable git signing program (`gpg.x509.program`) that signs commits
+//! and tags with BIP-340 Schnorr signatures using the signer's Nostr keypair.
+//!
+//! **Platform:** Unix-only (requires file descriptor passing via `--status-fd`).
+//!
+//! ## Invocation
+//!
+//! - **Sign:** `git-sign-nostr --status-fd=2 -bsau `
+//! Reads payload from stdin, writes armored signature to stdout.
+//! - **Verify:** `git-sign-nostr --status-fd=1 --verify -`
+//! Reads payload from stdin, verifies signature from file.
+//!
+//! ## GnuPG Status Protocol
+//!
+//! This program emits GnuPG-compatible status lines (prefixed `[GNUPG:] `)
+//! on the file descriptor specified by `--status-fd`. Git reads these to
+//! determine signature validity. See:
+//!
+//!
+//! ## Known Limitations
+//!
+//! - **Trust model:** `TRUST_FULLY` is emitted when the verified key matches
+//! `user.signingkey` in git config. This is **advisory only** — it is NOT a
+//! PKI trust root and does NOT prove the signer is trusted by any external
+//! authority. Git's signing interface does not support external keyrings or
+//! allowlists. Callers MUST NOT rely on `TRUST_FULLY` for security decisions
+//! without an external allowlist or owner policy. A `NOTATION_DATA
+//! advisory-config-match-only` line is emitted alongside the trust status
+//! to make this explicit.
+//! - **OA status reporting:** When a NIP-OA auth tag is present, machine-readable
+//! status is emitted via `NOTATION_NAME nostr-oa-status` / `NOTATION_DATA `
+//! on the status-fd. Values: `valid`, `invalid_signature`, `expired`,
+//! `kind_not_applicable`, `none`. `GOODSIG` indicates the commit signature is
+//! valid regardless of OA status — callers MUST check `nostr-oa-status`
+//! separately to verify owner authorization.
+//! - **Secret zeroization:** The raw key string is zeroized after parsing via
+//! `Zeroizing`. We bypass `nostr::Keys` (which caches non-zeroizable
+//! copies) and parse directly into `SecretKey`. The `secp256k1::Keypair` stack
+//! slot is overwritten with zeros after signing (best-effort — the compiler
+//! may optimize this away). The `SecretKey` type in the nostr crate wraps
+//! `secp256k1::SecretKey` which also lacks `Zeroize`, so some residual copies
+//! may persist until the process exits (short-lived by design).
+//! - **Environment variables:** Private keys in env vars are inherently risky
+//! (visible in `/proc`, shell history, crash dumps). Prefer keyfile storage.
+//! Env vars are removed from the process environment immediately after reading
+//! to minimize the exposure window.
+//! - **Unsafe code:** This crate uses minimal `unsafe` for Unix fd operations
+//! (`from_raw_fd`, `fcntl`) where no safe Rust API exists. Each block is
+//! documented with safety invariants. This is an accepted exception to the
+//! project's no-unsafe rule for this standalone binary.
+//! - **`git` subprocess:** Config reads invoke `git` via `$PATH`. A malicious
+//! `git` binary could return attacker-controlled config values.
+//!
+//! ## Ecosystem Constraints (not fixable in this crate)
+//!
+//! These are inherent to the libraries and interfaces we depend on:
+//!
+//! 1. **`secp256k1::SecretKey` lacks `Zeroize`:** The upstream `rust-secp256k1`
+//! crate does not implement `Zeroize` or `Drop`-based erasure on `SecretKey`.
+//! We call `non_secure_erase()` and `ptr::write_bytes` as best-effort, but
+//! the compiler may retain copies in registers or spilled stack slots.
+//! 2. **`git config` subprocess trust:** Git's signing interface invokes us as
+//! a child process. We inherit git's trust model for config reads — if an
+//! attacker controls `$PATH` or the repo's `.git/config`, they can influence
+//! our behavior. This is inherent to all git signing programs (GPG, SSH, etc).
+//! 3. **Piped stdout lifetime:** Git owns the pipe we write signatures to. In
+//! normal operation, git reads our stdout immediately after we exit. There is
+//! no need for timeout/kill logic on our stdout writes — we are a short-lived
+//! process and git is the reader. Blocking on stdout would indicate git itself
+//! is hung, which is outside our control.
+
+use std::fs;
+use std::io::{self, Read, Write};
+use std::mem::ManuallyDrop;
+use std::os::unix::io::FromRawFd;
+use std::process;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use base64::Engine as _;
+use chrono::DateTime;
+use nostr::bitcoin::hashes::sha256::Hash as Sha256Hash;
+use nostr::bitcoin::hashes::{Hash, HashEngine};
+use nostr::bitcoin::secp256k1::schnorr::Signature;
+use nostr::bitcoin::secp256k1::{Keypair, Message, XOnlyPublicKey};
+use nostr::{FromBech32, PublicKey, SecretKey, SECP256K1};
+use zeroize::Zeroize;
+
+// ── Keypair Guard ────────────────────────────────────────────────────────────
+
+/// RAII guard that calls `non_secure_erase()` on drop, ensuring the keypair's
+/// secret material is overwritten even on early-return error paths.
+struct KeypairGuard(Keypair);
+
+impl KeypairGuard {
+ fn new(kp: Keypair) -> Self {
+ Self(kp)
+ }
+
+ /// Access the inner keypair for signing operations.
+ fn inner(&self) -> &Keypair {
+ &self.0
+ }
+}
+
+impl Drop for KeypairGuard {
+ fn drop(&mut self) {
+ self.0.non_secure_erase();
+ }
+}
+
+// ── Constants ────────────────────────────────────────────────────────────────
+
+const DOMAIN_SEPARATOR: &str = "nostr:git:v1:";
+const ARMOR_BEGIN: &str = "-----BEGIN SIGNED MESSAGE-----";
+const ARMOR_END: &str = "-----END SIGNED MESSAGE-----";
+
+/// Maximum payload size (git commit/tag objects). 100 MB matches the NIP-GS
+/// spec limit. Commits and tags are typically < 10 KB; this bound prevents
+/// unbounded memory allocation from malicious input.
+const MAX_PAYLOAD: usize = 100 * 1024 * 1024;
+
+/// Maximum size for signature files read during verification. Legitimate
+/// NIP-GS signatures are ~300 bytes encoded; 8 KB allows for future extensions.
+const MAX_SIG_FILE: usize = 8 * 1024;
+
+/// Maximum decoded JSON size in the signature envelope.
+const MAX_JSON_DECODED: usize = 2048;
+
+/// Maximum base64 line length in the armor format.
+const MAX_BASE64_LINE: usize = 4096;
+
+/// GnuPG status line prefix. Git parses lines with this prefix on the
+/// status-fd to determine signature validity.
+const GNUPG_PREFIX: &str = "[GNUPG:] ";
+
+/// Minimum valid status file descriptor. FD 0 (stdin) is excluded because
+/// we read payload from it.
+const MIN_STATUS_FD: i32 = 1;
+
+// ── Error Type ───────────────────────────────────────────────────────────────
+
+/// Top-level error type. All failures flow through here so `main()` can
+/// handle cleanup (zeroization, status-fd reporting) before exiting.
+#[derive(Debug)]
+enum Error {
+ /// Fatal error — print message to stderr and exit non-zero.
+ Fatal(String),
+ /// Verification failure — signature is cryptographically invalid.
+ /// The pk (if known) is included for ERRSIG/BADSIG reporting.
+ VerifyFailed { pk: Option, msg: String },
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Error::Fatal(msg) => write!(f, "{msg}"),
+ Error::VerifyFailed { pk: Some(pk), msg } => write!(f, "{msg} [key: {pk}]"),
+ Error::VerifyFailed { pk: None, msg } => write!(f, "{msg}"),
+ }
+ }
+}
+
+// ── OA Verification Result ───────────────────────────────────────────────────
+
+/// Result of NIP-OA verification during signature verification.
+enum OaVerifyResult {
+ /// No OA present in the signature (optional field).
+ Absent,
+ /// OA present, signature valid, conditions satisfied.
+ Valid,
+ /// OA present but cryptographic verification failed.
+ InvalidSignature,
+ /// OA present, signature valid, but temporal conditions violated.
+ ConditionsViolated,
+}
+
+impl OaVerifyResult {
+ /// Return the machine-readable status string for NOTATION_DATA output.
+ fn as_status_str(&self) -> &'static str {
+ match self {
+ OaVerifyResult::Absent => "none",
+ OaVerifyResult::Valid => "valid",
+ OaVerifyResult::InvalidSignature => "invalid_signature",
+ OaVerifyResult::ConditionsViolated => "expired",
+ }
+ }
+}
+
+// ── CLI Parsing ──────────────────────────────────────────────────────────────
+
+#[derive(Debug)]
+enum Mode {
+ Sign { key_id: String },
+ Verify { sig_file: String },
+}
+
+#[derive(Debug)]
+struct Args {
+ mode: Mode,
+ status_fd: Option,
+}
+
+fn parse_args() -> Result {
+ let args: Vec = std::env::args().skip(1).collect();
+ let mut status_fd: Option = None;
+ let mut verify_file: Option = None;
+ let mut sign_key: Option = None;
+ let mut saw_stdin_dash = false;
+ let mut i = 0;
+
+ while i < args.len() {
+ let arg = &args[i];
+
+ if let Some(val) = arg.strip_prefix("--status-fd=") {
+ status_fd = Some(parse_status_fd(val)?);
+ } else if arg == "--status-fd" {
+ i += 1;
+ if i < args.len() {
+ status_fd = Some(parse_status_fd(&args[i])?);
+ } else {
+ return Err(Error::Fatal("--status-fd requires a value".to_string()));
+ }
+ } else if arg == "--verify" {
+ // Reject duplicate --verify
+ if verify_file.is_some() {
+ return Err(Error::Fatal(
+ "--verify specified more than once".to_string(),
+ ));
+ }
+ // Reject if -bsau was already seen (conflicting modes)
+ if sign_key.is_some() {
+ return Err(Error::Fatal(
+ "cannot specify both -bsau and --verify".to_string(),
+ ));
+ }
+ i += 1;
+ if i < args.len() {
+ verify_file = Some(args[i].clone());
+ } else {
+ return Err(Error::Fatal(
+ "--verify requires a file argument".to_string(),
+ ));
+ }
+ } else if arg == "-bsau" {
+ // Reject duplicate -bsau
+ if sign_key.is_some() {
+ return Err(Error::Fatal("-bsau specified more than once".to_string()));
+ }
+ // Reject if --verify was already seen (conflicting modes)
+ if verify_file.is_some() {
+ return Err(Error::Fatal(
+ "cannot specify both -bsau and --verify".to_string(),
+ ));
+ }
+ i += 1;
+ if i < args.len() {
+ sign_key = Some(args[i].clone());
+ } else {
+ return Err(Error::Fatal("-bsau requires a key argument".to_string()));
+ }
+ } else if arg == "-" {
+ // stdin marker for verify mode — required by git after the sig file
+ saw_stdin_dash = true;
+ }
+ // Silently ignore unrecognized args for forward compatibility
+ // (NIP-GS spec: implementations SHOULD ignore unknown arguments)
+
+ i += 1;
+ }
+
+ let mode = if let Some(sig_file) = verify_file {
+ // git always passes trailing `-` in verify mode; reject if absent
+ // so we fail fast rather than hanging on stdin with no payload.
+ if !saw_stdin_dash {
+ return Err(Error::Fatal(
+ "--verify requires a trailing `-` argument (stdin marker)".to_string(),
+ ));
+ }
+ Mode::Verify { sig_file }
+ } else if let Some(key_id) = sign_key {
+ Mode::Sign { key_id }
+ } else {
+ return Err(Error::Fatal(
+ "must specify either -bsau (sign) or --verify (verify)".to_string(),
+ ));
+ };
+
+ Ok(Args { mode, status_fd })
+}
+
+fn parse_status_fd(val: &str) -> Result {
+ let fd: i32 = val
+ .parse()
+ .map_err(|_| Error::Fatal(format!("invalid --status-fd value: {val:?}")))?;
+ if fd < MIN_STATUS_FD {
+ return Err(Error::Fatal(format!(
+ "--status-fd must be >= {MIN_STATUS_FD} (fd 0 is stdin), got {fd}"
+ )));
+ }
+ Ok(fd)
+}
+
+// ── Status FD Writer ─────────────────────────────────────────────────────────
+
+struct StatusWriter {
+ /// Wrapped in ManuallyDrop because git owns this fd — we must not close it.
+ /// Git opens the fd before invoking us and reads from it after we exit.
+ file: Option>,
+}
+
+impl StatusWriter {
+ /// Create a status writer for the given file descriptor.
+ ///
+ /// If `strict` is true (verify mode), returns an error when the fd is
+ /// explicitly provided but invalid — git depends on status output to
+ /// determine verification results. If `strict` is false (sign mode),
+ /// falls back to stderr on invalid fd.
+ fn new(fd: Option, strict: bool) -> Result {
+ let file = match fd {
+ None => None,
+ Some(fd) => {
+ #[cfg(unix)]
+ {
+ // SAFETY EXCEPTION: Required for Unix fd operations; no safe Rust API
+ // exists for fcntl. The fd value is >= 1 (validated by
+ // parse_status_fd). F_GETFD is read-only and cannot cause memory
+ // unsafety — the only risk is EBADF, which we handle by checking
+ // the return value.
+ let ret = unsafe { libc::fcntl(fd, libc::F_GETFD) };
+ if ret == -1 {
+ if strict {
+ return Err(Error::Fatal(format!(
+ "--status-fd={fd} is not a valid open fd (required for verify)"
+ )));
+ }
+ eprintln!("warning: --status-fd={fd} is not a valid open fd, using stderr");
+ return Ok(Self { file: None });
+ }
+ }
+ // SAFETY EXCEPTION: Required for Unix fd operations; no safe Rust API
+ // exists for from_raw_fd. The fd is >= 1 (validated by parse_status_fd),
+ // confirmed open by fcntl above, and git owns its lifetime. We use
+ // ManuallyDrop to prevent Rust from closing the inherited fd on drop.
+ Some(ManuallyDrop::new(unsafe { fs::File::from_raw_fd(fd) }))
+ }
+ };
+ Ok(Self { file })
+ }
+
+ /// Write a GnuPG-format status line. Errors are logged to stderr but do
+ /// not abort — git can still function without status lines in some modes.
+ fn write_line(&mut self, line: &str) {
+ let result = if let Some(ref mut f) = self.file {
+ writeln!(&mut **f, "{GNUPG_PREFIX}{line}")
+ } else {
+ writeln!(io::stderr(), "{GNUPG_PREFIX}{line}")
+ };
+ if let Err(e) = result {
+ eprintln!("warning: failed to write status line: {e}");
+ }
+ }
+
+ /// Write a GnuPG-format status line, returning an error if the write fails.
+ ///
+ /// Use this in `cmd_verify` where status output is critical — git reads
+ /// these lines to determine signature validity. A broken status-fd means
+ /// git cannot receive the result, so we must fail rather than silently
+ /// continue.
+ fn write_line_critical(&mut self, line: &str) -> Result<(), Error> {
+ let result = if let Some(ref mut f) = self.file {
+ writeln!(&mut **f, "{GNUPG_PREFIX}{line}")
+ } else {
+ writeln!(io::stderr(), "{GNUPG_PREFIX}{line}")
+ };
+ result.map_err(|e| Error::Fatal(format!("failed to write status line: {e}")))
+ }
+}
+
+/// Write a critical status line; exit with error if the write fails.
+///
+/// Used in `cmd_verify` where status output is required for git to parse the
+/// result. Unlike `status!` (which ignores errors), this macro propagates
+/// write failures as `Error::Fatal`.
+macro_rules! status_or_fail {
+ ($writer:expr, $line:expr) => {
+ $writer.write_line_critical($line)?
+ };
+ ($writer:expr, $fmt:literal, $($arg:tt)*) => {
+ $writer.write_line_critical(&format!($fmt, $($arg)*))?
+ };
+}
+
+// ── Key Loading ──────────────────────────────────────────────────────────────
+
+/// Load the private key from env vars or git config keyfile.
+///
+/// Priority: NOSTR_PRIVATE_KEY > SPROUT_PRIVATE_KEY > git config nostr.keyfile
+///
+/// Returns a zeroize-on-drop string containing the raw key material.
+fn load_key() -> Result, Error> {
+ // 1. NOSTR_PRIVATE_KEY
+ if let Ok(mut val) = std::env::var("NOSTR_PRIVATE_KEY") {
+ // Cap at 128 bytes: nsec1 bech32 is ~63 chars, hex is 64 chars.
+ // 128 bytes is generous headroom; anything larger is malformed input.
+ if val.len() > 128 {
+ val.zeroize();
+ std::env::remove_var("NOSTR_PRIVATE_KEY");
+ return Err(Error::Fatal(
+ "NOSTR_PRIVATE_KEY exceeds 128-byte size limit".to_string(),
+ ));
+ }
+ let trimmed = val.trim().to_string();
+ val.zeroize();
+ // Remove from process environment to minimize exposure window
+ std::env::remove_var("NOSTR_PRIVATE_KEY");
+ if !trimmed.is_empty() {
+ return Ok(zeroize::Zeroizing::new(trimmed));
+ }
+ }
+
+ // 2. SPROUT_PRIVATE_KEY
+ if let Ok(mut val) = std::env::var("SPROUT_PRIVATE_KEY") {
+ // Cap at 128 bytes: nsec1 bech32 is ~63 chars, hex is 64 chars.
+ // 128 bytes is generous headroom; anything larger is malformed input.
+ if val.len() > 128 {
+ val.zeroize();
+ std::env::remove_var("SPROUT_PRIVATE_KEY");
+ return Err(Error::Fatal(
+ "SPROUT_PRIVATE_KEY exceeds 128-byte size limit".to_string(),
+ ));
+ }
+ let trimmed = val.trim().to_string();
+ val.zeroize();
+ // Remove from process environment to minimize exposure window
+ std::env::remove_var("SPROUT_PRIVATE_KEY");
+ if !trimmed.is_empty() {
+ return Ok(zeroize::Zeroizing::new(trimmed));
+ }
+ }
+
+ // 3. nostr.keyfile git config
+ let path = git_config("nostr.keyfile").ok_or_else(|| {
+ Error::Fatal(
+ "no key available: set NOSTR_PRIVATE_KEY, SPROUT_PRIVATE_KEY, \
+ or git config nostr.keyfile"
+ .to_string(),
+ )
+ })?;
+
+ // Delegate to read_keyfile_secure which handles permission checks,
+ // size limits, and Zeroizing wrapping in one place.
+ read_keyfile_secure(&path)
+}
+
+/// Load the NIP-OA auth tag from env or git config.
+///
+/// Priority per NIP-GS spec: `SPROUT_AUTH_TAG` env var > `nostr.authtag` git config.
+/// The env var takes precedence so that CI/CD pipelines and agent harnesses can
+/// inject auth tags without modifying repo config.
+///
+/// Returns:
+/// - `Ok(Some(...))` — valid auth tag found and parsed.
+/// - `Ok(None)` — no auth tag configured (neither git config nor env var set).
+/// - `Err(...)` — auth tag IS configured but malformed. Callers MUST treat
+/// this as a hard error to prevent signing without the intended attestation.
+fn load_auth_tag() -> Result
-
🌱 Sprout Collaborative Page
-
This page was created by two bots collaborating via Sprout's git server.
-
- Bot 1 — Created the initial page structure
-
-
to keep valid HTML
+sed -i.bak '/<\/body>/i\
+
\
+ Bot 2 — Added this section (pushing as bot role → promoted to member)\
+
\
+ ' "$BOT2_DIR/index.html"
+rm -f "$BOT2_DIR/index.html.bak"
+
+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..."
+if git_push "$BOT2_PRIVKEY" "$BOT2_DIR"; 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"
+
+git_clone "$GUEST_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$GUEST_DIR" \
+ || fail "Guest clone failed (read access should work)"
+
+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"
+
+PUSH_OUTPUT=$(git_push "$GUEST_PRIVKEY" "$GUEST_DIR" 2>&1) && \
+ fail "Guest push succeeded (should have been denied!)"
+
+# Verify the denial is permission-related, not a network error
+if echo "$PUSH_OUTPUT" | grep -qi "denied\|forbidden\|not authorized\|403\|permission"; then
+ success "Guest push denied (not a channel member) — reason confirmed in output"
+else
+ warn "Guest push failed but denial reason not found in output: $PUSH_OUTPUT"
+ success "Guest push denied (non-zero exit)"
+fi
+
+# ── Final verification ────────────────────────────────────────────────────────
+
+log "Verifying final repo state..."
+VERIFY_DIR="$WORK_DIR/verify"
+
+git_clone "$OWNER_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$VERIFY_DIR" \
+ || fail "Owner clone for verification failed"
+
+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
+
+success "=== PHASE 1 COMPLETE: Transport + RBAC ==="
+
+# =============================================================================
+# PHASE 2 — Commit Signing (NIP-GS)
+# =============================================================================
+
+SIGNER="${REPO_ROOT}/target/release/git-sign-nostr"
+
+if [[ ! -x "$SIGNER" ]]; then
+ warn "git-sign-nostr not built — skipping signing tests"
+ warn "Build with: cargo build --release --bin git-sign-nostr"
+else
+
+# ── Test: Unsigned commit pushes fine (advisory model) ────────────────────────
+# WHY: NIP-GS signing is client-side provenance only. The relay does NOT enforce
+# signatures on push — any authenticated member can push unsigned commits.
+# This is intentional: signing proves authorship to verifiers, but the relay's
+# job is authorization (channel membership + branch protection), not signature
+# enforcement.
+
+log "Advisory model: unsigned commit should push successfully..."
+UNSIGNED_DIR="$WORK_DIR/unsigned"
+
+git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$UNSIGNED_DIR" \
+ || fail "Clone for unsigned test failed"
+
+echo "" >> "$UNSIGNED_DIR/index.html"
+git -C "$UNSIGNED_DIR" add -A
+git -C "$UNSIGNED_DIR" -c user.name="Bot1" -c user.email="bot1@sprout.test" \
+ commit -m "Unsigned commit (no gpgsign)"
+
+if git_push "$BOT1_PRIVKEY" "$UNSIGNED_DIR"; then
+ success "Unsigned commit pushed (signing is advisory, not enforced server-side)"
+else
+ fail "Unsigned commit push failed — server should not enforce signing"
+fi
+
+# ── Test: Signed commit with git-sign-nostr ───────────────────────────────────
+
+log "Signing: configuring git-sign-nostr and making a signed commit..."
+SIGNED_DIR="$WORK_DIR/signed"
+
+git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$SIGNED_DIR" \
+ || fail "Clone for signed test failed"
+
+echo "" >> "$SIGNED_DIR/index.html"
+git -C "$SIGNED_DIR" add -A
+
+NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY" \
+git -C "$SIGNED_DIR" \
+ -c user.name="Bot1" \
+ -c user.email="bot1@sprout.test" \
+ -c gpg.format=x509 \
+ -c "gpg.x509.program=$SIGNER" \
+ -c commit.gpgsign=true \
+ -c "user.signingkey=$BOT1_PUBKEY" \
+ commit -m "Signed commit via NIP-GS"
+
+# Verify the signature locally
+log "Verifying signature with git verify-commit..."
+if NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY" \
+ git -C "$SIGNED_DIR" \
+ -c gpg.format=x509 \
+ -c "gpg.x509.program=$SIGNER" \
+ -c "user.signingkey=$BOT1_PUBKEY" \
+ verify-commit HEAD 2>&1; then
+ success "git verify-commit succeeded (GOODSIG)"
+else
+ fail "git verify-commit failed"
+fi
+
+# Push the signed commit
+log "Pushing signed commit..."
+if git_push "$BOT1_PRIVKEY" "$SIGNED_DIR"; then
+ success "Signed commit pushed successfully"
+else
+ fail "Signed commit push failed"
+fi
+
+# ── Test: Signed commit with owner attestation (NIP-OA) ──────────────────────
+
+log "Signing with owner attestation (SPROUT_AUTH_TAG)..."
+OA_DIR="$WORK_DIR/oa-signed"
+
+git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$OA_DIR" \
+ || fail "Clone for OA test failed"
+
+echo "" >> "$OA_DIR/index.html"
+git -C "$OA_DIR" add -A
+
+# Generate a NIP-OA auth tag: owner authorizes bot1
+OA_TAG=$(python3 << PYEOF
+import hashlib, json
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
-N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
+N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
@@ -210,17 +666,13 @@ def scalar_mult(k, point):
k >>= 1
return result
-def sign_schnorr(privkey_bytes, msg_bytes):
- k_int = int.from_bytes(privkey_bytes, 'big')
+def sign_schnorr(privkey_hex, msg_bytes):
+ """BIP-340 Schnorr signing — TEST ONLY."""
+ k_int = int(privkey_hex, 16)
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")
@@ -228,308 +680,254 @@ def sign_schnorr(privkey_bytes, msg_bytes):
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')
+ return (R_bytes + s_int.to_bytes(32, 'big')).hex()
-privkey = bytes.fromhex("${privkey}")
-pubpoint = scalar_mult(int.from_bytes(privkey, 'big'), (Gx, Gy))
-pubkey_hex = format(pubpoint[0], '064x')
+owner_privkey = "${OWNER_PRIVKEY}"
+bot1_pubkey = "${BOT1_PUBKEY}"
+owner_pubpoint = scalar_mult(int(owner_privkey, 16), (Gx, Gy))
+owner_pubkey = format(owner_pubpoint[0], '064x')
-created_at = int(time.time())
-tags = json.loads('${tags_json}') if '${tags_json}'.strip() else []
-content = """${content}"""
+preimage = f"nostr:agent-auth:{bot1_pubkey}:"
+msg = hashlib.sha256(preimage.encode()).digest()
+sig = sign_schnorr(owner_privkey, msg)
-# 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()
+print(json.dumps(["auth", owner_pubkey, "", sig]))
PYEOF
-}
+)
-# ── Helper: configure git for a keypair ───────────────────────────────────────
+NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY" SPROUT_AUTH_TAG="$OA_TAG" \
+git -C "$OA_DIR" \
+ -c user.name="Bot1" \
+ -c user.email="bot1@sprout.test" \
+ -c gpg.format=x509 \
+ -c "gpg.x509.program=$SIGNER" \
+ -c commit.gpgsign=true \
+ -c "user.signingkey=$BOT1_PUBKEY" \
+ commit -m "Signed commit with owner attestation"
-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"
+# Verify the oa field is present in the signature
+COMMIT_SIG=$(git -C "$OA_DIR" cat-file commit HEAD | sed -n '/^gpgsig /,/^[^ ]/{ /^gpgsig /d; /^[^ ]/d; s/^ //; p; }')
+DECODED_SIG=$(echo "$COMMIT_SIG" | base64 -d 2>/dev/null || echo "$COMMIT_SIG" | base64 -D 2>/dev/null)
+if echo "$DECODED_SIG" | grep -q '"oa"'; then
+ success "Owner attestation (oa field) present in signature"
else
- fail "Repo not created at repos/${OWNER_PUBKEY}/${REPO_NAME}.git"
+ fail "Owner attestation missing from signature — SPROUT_AUTH_TAG not picked up"
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"
+# Push it
+if git_push "$BOT1_PRIVKEY" "$OA_DIR"; then
+ success "Signed commit with oa pushed successfully"
else
- fail "Pre-receive hook not found or not executable"
+ fail "Signed commit with oa push failed"
fi
-# ── Test: Bot1 clones and pushes index.html ───────────────────────────────────
+success "=== PHASE 2 COMPLETE: Commit Signing ==="
-log "Bot1: cloning repo..."
-BOT1_DIR="$WORK_DIR/bot1"
-mkdir -p "$BOT1_DIR"
+fi # end git-sign-nostr check
-export NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY"
-export GIT_TERMINAL_PROMPT=0
+# =============================================================================
+# PHASE 3 — Auth Bypass Tests
+# =============================================================================
-# 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
+log "Auth bypass: testing unauthenticated access..."
-# 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
+GIT_INFO_URL="${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}/info/refs?service=git-upload-pack"
-# Create index.html
-cat > "$BOT1_DIR/repo/index.html" << 'HTML'
-
-
-