feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[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 Buzz's git server"
|
||||
|
||||
[lib]
|
||||
name = "git_credential_nostr"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "git-credential-nostr"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nostr = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
base64 = "0.22"
|
||||
@@ -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 Buzz'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 Buzz 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 Buzz 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`) |
|
||||
@@ -0,0 +1,266 @@
|
||||
//! git-credential-nostr — NIP-98 git credential helper for Buzz.
|
||||
//!
|
||||
//! Git calls this 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 base64::Engine as _;
|
||||
use nostr::nips::nip98::{HttpData, HttpMethod};
|
||||
use nostr::types::Url;
|
||||
use nostr::{EventBuilder, Keys, Tag};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
/// Max keyfile size — nsec1 is 63 bytes; hex keys are 64 bytes. 256 is generous.
|
||||
const MAX_KEYFILE_BYTES: u64 = 256;
|
||||
|
||||
fn load_key() -> Result<String, String> {
|
||||
if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") {
|
||||
if !val.is_empty() {
|
||||
return Ok(val);
|
||||
}
|
||||
}
|
||||
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 meta = std::fs::metadata(&path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("keyfile {path} is not a regular file"));
|
||||
}
|
||||
if meta.len() > MAX_KEYFILE_BYTES {
|
||||
return Err(format!(
|
||||
"keyfile {path} exceeds {MAX_KEYFILE_BYTES}-byte size limit"
|
||||
));
|
||||
}
|
||||
let raw =
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("cannot read keyfile {path}: {e}"))?;
|
||||
Ok(raw.trim().to_string())
|
||||
}
|
||||
|
||||
/// Load the NIP-OA owner attestation injected by Buzz Desktop/ACP.
|
||||
///
|
||||
/// The tag must be part of the signed NIP-98 event: Git's credential protocol
|
||||
/// can return an Authorization value, but it cannot add a separate HTTP header.
|
||||
fn load_auth_tag() -> Result<Option<Tag>, String> {
|
||||
let raw = std::env::var("BUZZ_AUTH_TAG")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| git_config("nostr.authtag"));
|
||||
|
||||
raw.map(|value| {
|
||||
let parts: Vec<String> =
|
||||
serde_json::from_str(&value).map_err(|e| format!("invalid NIP-OA auth tag: {e}"))?;
|
||||
if parts.len() != 4 || parts.first().map(String::as_str) != Some("auth") {
|
||||
return Err(
|
||||
"invalid NIP-OA auth tag: expected [auth, owner, conditions, signature]"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Tag::parse(parts).map_err(|e| format!("invalid NIP-OA auth tag: {e}"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[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[]=") {
|
||||
if v.starts_with("Nostr ") && req.wwwauth.is_none() {
|
||||
req.wwwauth = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
fn parse_method(wwwauth: &str) -> Option<HttpMethod> {
|
||||
// Strip the scheme prefix ("Nostr ") if present, then split on commas.
|
||||
// Handles variations: `Nostr method="GET", realm="buzz"` and
|
||||
// `Nostr method="GET",realm="buzz"` (with or without space after comma).
|
||||
let params = wwwauth.strip_prefix("Nostr ").unwrap_or(wwwauth);
|
||||
for param in params.split(',') {
|
||||
let param = param.trim();
|
||||
if let Some(rest) = param.strip_prefix("method=\"") {
|
||||
let end = rest.find('"')?;
|
||||
return rest[..end].parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Run the credential helper. Returns exit code.
|
||||
/// Reads from stdin, writes to stdout. Errors go to stderr only.
|
||||
pub fn run() -> i32 {
|
||||
match std::env::args().nth(1).as_deref() {
|
||||
Some("get") | None => {}
|
||||
Some(_) => return 0, // store, erase, or unknown → silent exit 0
|
||||
}
|
||||
|
||||
let req = parse_stdin();
|
||||
|
||||
if !req.has_authtype_capability {
|
||||
println!();
|
||||
let _ = io::stdout().flush();
|
||||
return 0;
|
||||
}
|
||||
|
||||
macro_rules! require {
|
||||
($opt:expr, $msg:expr) => {
|
||||
match $opt {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
eprintln!("error: {}", $msg);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// No Nostr challenge from the server — this isn't a Buzz remote.
|
||||
// Exit silently so git falls through to the next credential helper.
|
||||
// This check comes FIRST so non-Buzz remotes never hit validation errors.
|
||||
let wwwauth = match req.wwwauth.as_deref() {
|
||||
Some(v) => v,
|
||||
None => return 0,
|
||||
};
|
||||
let method = match parse_method(wwwauth) {
|
||||
Some(m) => m,
|
||||
None => return 0,
|
||||
};
|
||||
|
||||
let protocol = require!(
|
||||
req.protocol.as_deref(),
|
||||
"missing protocol in credential request"
|
||||
);
|
||||
let host = require!(req.host.as_deref(), "missing host in credential request");
|
||||
let path = require!(
|
||||
req.path.as_deref(),
|
||||
"credential.useHttpPath must be true for NIP-98 auth"
|
||||
);
|
||||
|
||||
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}");
|
||||
|
||||
let mut raw_key = match load_key() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
let keys = match Keys::parse(&raw_key) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
raw_key.zeroize();
|
||||
eprintln!("error: invalid nostr private key: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
raw_key.zeroize();
|
||||
|
||||
let parsed_url = Url::parse(&url).unwrap_or_else(|e| panic!("invalid URL {url:?}: {e}"));
|
||||
let http_data = HttpData::new(parsed_url, method);
|
||||
let auth_tag = match load_auth_tag() {
|
||||
Ok(tag) => tag,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let builder = EventBuilder::http_auth(http_data);
|
||||
let builder = match auth_tag {
|
||||
Some(tag) => builder.tag(tag),
|
||||
None => builder,
|
||||
};
|
||||
let event = match builder.sign_with_keys(&keys) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("error: failed to sign NIP-98 event: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
let json = match serde_json::to_string(&event) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("error: failed to serialize event: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
let credential = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
|
||||
|
||||
println!("capability[]=authtype");
|
||||
println!("authtype=Nostr");
|
||||
println!("credential={credential}");
|
||||
println!("ephemeral=true");
|
||||
println!("quit=true");
|
||||
println!();
|
||||
let _ = io::stdout().flush();
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
std::process::exit(git_credential_nostr::run());
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! 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};
|
||||
|
||||
/// 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())
|
||||
.current_dir(std::env::temp_dir())
|
||||
.env_remove("NOSTR_PRIVATE_KEY")
|
||||
.env_remove("BUZZ_AUTH_TAG")
|
||||
.env_remove("GIT_CONFIG_COUNT")
|
||||
// Prevent git config on the test machine from supplying credentials.
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.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=\"buzz\", method=\"GET\"\n\
|
||||
\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 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'");
|
||||
}
|
||||
|
||||
/// A Buzz-managed agent must carry its NIP-OA owner attestation inside the
|
||||
/// signed NIP-98 event so the relay can admit it through the owner's membership.
|
||||
#[test]
|
||||
fn includes_nip_oa_auth_tag_in_signed_event() {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
let nsec = agent_keys.secret_key().to_bech32().unwrap();
|
||||
let auth_tag = serde_json::to_string(&[
|
||||
"auth",
|
||||
owner_keys.public_key().to_hex().as_str(),
|
||||
"",
|
||||
&"00".repeat(64),
|
||||
])
|
||||
.expect("serialize auth tag");
|
||||
|
||||
let out = run_helper(
|
||||
&valid_input(),
|
||||
&[("NOSTR_PRIVATE_KEY", &nsec), ("BUZZ_AUTH_TAG", &auth_tag)],
|
||||
);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"helper failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let credential = stdout
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("credential="))
|
||||
.expect("credential output");
|
||||
let event_json = base64::engine::general_purpose::STANDARD
|
||||
.decode(credential)
|
||||
.expect("base64 credential");
|
||||
let event: nostr::Event = serde_json::from_slice(&event_json).expect("NIP-98 event");
|
||||
|
||||
assert!(
|
||||
event.verify().is_ok(),
|
||||
"auth tag must be covered by the event signature"
|
||||
);
|
||||
assert!(event.tags.iter().any(|tag| tag.as_slice()
|
||||
== [
|
||||
"auth",
|
||||
owner_keys.public_key().to_hex().as_str(),
|
||||
"",
|
||||
serde_json::from_str::<Vec<String>>(&auth_tag).unwrap()[3].as_str(),
|
||||
]));
|
||||
}
|
||||
|
||||
/// A configured but malformed owner attestation must fail closed rather than
|
||||
/// silently authenticating the agent without delegation.
|
||||
#[test]
|
||||
fn malformed_nip_oa_auth_tag_fails_closed() {
|
||||
let nsec = fresh_nsec();
|
||||
let out = run_helper(
|
||||
&valid_input(),
|
||||
&[("NOSTR_PRIVATE_KEY", &nsec), ("BUZZ_AUTH_TAG", "not-json")],
|
||||
);
|
||||
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid NIP-OA auth tag"));
|
||||
assert!(!String::from_utf8_lossy(&out.stdout).contains("credential="));
|
||||
}
|
||||
|
||||
/// 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 0, no credential emitted.
|
||||
/// The helper gracefully declines rather than erroring, so git can fall through
|
||||
/// to the next credential helper (safe for global credential.helper config).
|
||||
#[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=\"buzz\"\n\
|
||||
\n";
|
||||
|
||||
let nsec = fresh_nsec();
|
||||
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"expected exit 0 for missing method hint (graceful decline), got {:?}",
|
||||
out.status.code()
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
!stdout.contains("credential="),
|
||||
"should not emit credential= when method hint is missing"
|
||||
);
|
||||
}
|
||||
|
||||
/// Input without `path=` line (useHttpPath not set) → exit 1, stderr mentions "useHttpPath".
|
||||
/// The relay requires the full repo-root URL for NIP-98 verification, so the
|
||||
/// credential helper cannot function without the path component.
|
||||
#[test]
|
||||
fn missing_path() {
|
||||
let input = "capability[]=authtype\n\
|
||||
capability[]=state\n\
|
||||
protocol=https\n\
|
||||
host=relay.example.com\n\
|
||||
wwwauth[]=Nostr realm=\"buzz\", 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()),
|
||||
("GIT_CONFIG_GLOBAL", git_config_file.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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user