Files
buzz/crates/git-sign-nostr/src/main.rs

2545 lines
95 KiB
Rust

//! 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 <keyid>`
//! Reads payload from stdin, writes armored signature to stdout.
//! - **Verify:** `git-sign-nostr --status-fd=1 --verify <sigfile> -`
//! 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:
//! <https://www.gnupg.org/documentation/manuals/gnupg/Format-of-the-_002d_002dstatus_002dfd-output.html>
//!
//! ## 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 <status>`
//! 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<String>`. 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<String>, 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<i32>,
}
fn parse_args() -> Result<Args, Error> {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut status_fd: Option<i32> = None;
let mut verify_file: Option<String> = None;
let mut sign_key: Option<String> = 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 <key> (sign) or --verify <file> (verify)".to_string(),
));
};
Ok(Args { mode, status_fd })
}
fn parse_status_fd(val: &str) -> Result<i32, Error> {
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<ManuallyDrop<fs::File>>,
}
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<i32>, strict: bool) -> Result<Self, Error> {
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<zeroize::Zeroizing<String>, 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<Option<(String, String, String)>, Error> {
// NIP-GS spec: check env var first, then git config.
// Use git_config_strict for auth tag to fail closed on read errors —
// a configured-but-unreadable auth tag must not be silently omitted.
let json_str = match std::env::var("SPROUT_AUTH_TAG")
.ok()
.filter(|s| !s.is_empty())
{
Some(val) => Some(val),
None => git_config_strict("nostr.authtag")
.map_err(|e| Error::Fatal(format!("failed to read nostr.authtag: {e}")))?,
};
let json_str = match json_str {
Some(s) => s,
None => return Ok(None),
};
// Cap auth tag at 1024 bytes. A valid NIP-OA tag is ~300 bytes; 1024
// allows generous headroom while bounding memory use from malformed input.
const MAX_AUTH_TAG: usize = 1024;
if json_str.len() > MAX_AUTH_TAG {
return Err(Error::Fatal(format!(
"auth tag exceeds {MAX_AUTH_TAG}-byte size limit"
)));
}
// Parse: ["auth", "<owner>", "<conditions>", "<sig>"]
let arr: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| Error::Fatal(format!("SPROUT_AUTH_TAG is not valid JSON: {e}")))?;
let arr = arr
.as_array()
.ok_or_else(|| Error::Fatal("SPROUT_AUTH_TAG must be a JSON array".to_string()))?;
if arr.len() != 4 {
return Err(Error::Fatal(
"SPROUT_AUTH_TAG must have exactly 4 elements".to_string(),
));
}
if arr[0].as_str() != Some("auth") {
return Err(Error::Fatal(
"SPROUT_AUTH_TAG[0] must be \"auth\"".to_string(),
));
}
let owner = arr[1]
.as_str()
.ok_or_else(|| Error::Fatal("SPROUT_AUTH_TAG[1] must be a string".to_string()))?
.to_string();
let conditions = arr[2]
.as_str()
.ok_or_else(|| Error::Fatal("SPROUT_AUTH_TAG[2] must be a string".to_string()))?
.to_string();
let sig = arr[3]
.as_str()
.ok_or_else(|| Error::Fatal("SPROUT_AUTH_TAG[3] must be a string".to_string()))?
.to_string();
// Validate conditions character class per NIP-OA: empty string is valid,
// otherwise only ASCII alphanumeric + '_' + '=' + '<' + '>' + '&' allowed.
if !validate_conditions(&conditions) {
return Err(Error::Fatal(
"SPROUT_AUTH_TAG conditions contain invalid characters".to_string(),
));
}
// Validate hex fields
if owner.len() != 64
|| !owner
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Err(Error::Fatal(
"SPROUT_AUTH_TAG owner must be 64 lowercase hex chars".to_string(),
));
}
if sig.len() != 128
|| !sig
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Err(Error::Fatal(
"SPROUT_AUTH_TAG signature must be 128 lowercase hex chars".to_string(),
));
}
Ok(Some((owner, conditions, sig)))
}
/// Validate NIP-OA conditions string with structural parsing.
///
/// Grammar (empty string is valid):
/// ```text
/// conditions = "" | clause ("&" clause)*
/// clause = "kind=" decimal | "created_at<" decimal | "created_at>" decimal
/// decimal = "0" | [1-9][0-9]*
/// ```
/// - `kind=` values must be in 0..=65535
/// - `created_at` values must be in 0..=4294967295
/// - Leading zeros are rejected (except the value `0` itself)
fn validate_conditions(conditions: &str) -> bool {
if conditions.is_empty() {
return true;
}
for clause in conditions.split('&') {
if clause.is_empty() {
return false; // rejects "&&", trailing "&", leading "&"
}
let ok = if let Some(val) = clause.strip_prefix("kind=") {
parse_decimal_u32(val).is_some_and(|n| n <= 65535)
} else if let Some(val) = clause.strip_prefix("created_at<") {
parse_decimal_u32(val).is_some()
} else if let Some(val) = clause.strip_prefix("created_at>") {
parse_decimal_u32(val).is_some()
} else {
false // unknown clause type
};
if !ok {
return false;
}
}
true
}
/// Enforce NIP-OA time constraints against the NIP-GS envelope timestamp.
///
/// Checks `created_at<N` (t must be < N) and `created_at>N` (t must be > N).
/// `kind=` clauses are not applicable in NIP-GS context and are skipped.
/// Assumes `validate_conditions()` has already confirmed structural validity.
fn enforce_conditions(conditions: &str, t: u64) -> Result<(), String> {
if conditions.is_empty() {
return Ok(());
}
for clause in conditions.split('&') {
if let Some(val) = clause.strip_prefix("created_at<") {
if let Some(limit) = parse_decimal_u32(val) {
if t >= limit as u64 {
return Err(format!(
"timestamp {t} violates auth tag constraint created_at<{limit}"
));
}
}
} else if let Some(val) = clause.strip_prefix("created_at>") {
if let Some(limit) = parse_decimal_u32(val) {
if t <= limit as u64 {
return Err(format!(
"timestamp {t} violates auth tag constraint created_at>{limit}"
));
}
}
}
// kind= clauses: not applicable in NIP-GS context, skip
}
Ok(())
}
/// Check if conditions string contains any `kind=` clauses.
/// These are valid NIP-OA but not applicable in NIP-GS git context.
fn has_kind_clause(conditions: &str) -> bool {
if conditions.is_empty() {
return false;
}
conditions.split('&').any(|c| c.starts_with("kind="))
}
/// Parse a decimal string into u32, rejecting leading zeros and non-decimal chars.
/// Valid range: 0..=4294967295.
///
/// NIP-OA requires bare decimal digits only — leading `+` or `-` signs are
/// rejected even though Rust's `str::parse` would accept them.
fn parse_decimal_u32(s: &str) -> Option<u32> {
if s.is_empty() {
return None;
}
// Reject leading sign characters: NIP-OA only allows bare decimal digits.
// Rust's `parse::<u32>()` accepts '+' prefix; we must reject it explicitly.
if s.starts_with('+') || s.starts_with('-') {
return None;
}
// Reject leading zeros (except the single digit "0")
if s.len() > 1 && s.starts_with('0') {
return None;
}
// All chars must be ASCII digits
if !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse::<u32>().ok()
}
/// Read a git config value, sanitizing the subprocess environment.
///
/// We remove env vars that could redirect config reads, inject values, or
/// change the repository context. This is defense-in-depth — the primary
/// trust boundary is that this program runs in the user's own git repo.
///
/// **Known limitation:** We cannot fully protect against a malicious `git`
/// binary on `$PATH`. Callers who need stronger guarantees should use an
/// absolute path to git or avoid subprocess-based config reads entirely.
fn git_config(key: &str) -> Option<String> {
use std::io::Read;
let mut child = process::Command::new("git")
.args(["config", "--get", key])
.env_remove("NOSTR_PRIVATE_KEY")
.env_remove("SPROUT_PRIVATE_KEY")
.stdout(process::Stdio::piped())
.stderr(process::Stdio::null())
.spawn()
.ok()?;
// Read at most MAX_SIG_FILE + 1 bytes to detect oversized output.
let mut buf = Vec::with_capacity(MAX_SIG_FILE + 1);
if let Some(ref mut stdout) = child.stdout {
stdout
.take(MAX_SIG_FILE as u64 + 1)
.read_to_end(&mut buf)
.ok()?;
}
// Drop stdout handle before wait() to avoid deadlock — the child may be
// blocked writing to a full pipe. Dropping closes our end of the pipe,
// which causes the child's write to fail and it exits.
drop(child.stdout.take());
// If output is oversized, kill the child (it may be a malicious git binary
// producing unbounded output) and return None.
if buf.len() > MAX_SIG_FILE {
let _ = child.kill();
let _ = child.wait();
return None;
}
let status = child.wait().ok()?;
if !status.success() {
return None;
}
let val = String::from_utf8(buf).ok()?;
let trimmed = val.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
/// Like `git_config()` but distinguishes "key not set" from "error reading."
///
/// Returns:
/// - `Ok(Some(value))` — key exists and was read successfully
/// - `Ok(None)` — key does not exist (git config exit code 1)
/// - `Err(msg)` — error reading (spawn failure, oversized output, invalid UTF-8, or unexpected exit code)
fn git_config_strict(key: &str) -> Result<Option<String>, String> {
use std::io::Read;
let mut child = process::Command::new("git")
.args(["config", "--get", key])
.env_remove("NOSTR_PRIVATE_KEY")
.env_remove("SPROUT_PRIVATE_KEY")
.stdout(process::Stdio::piped())
.stderr(process::Stdio::null())
.spawn()
.map_err(|e| format!("failed to spawn git: {e}"))?;
let mut buf = Vec::with_capacity(MAX_SIG_FILE + 1);
if let Some(ref mut stdout) = child.stdout {
stdout
.take(MAX_SIG_FILE as u64 + 1)
.read_to_end(&mut buf)
.map_err(|e| format!("failed to read git config output: {e}"))?;
}
// Drop stdout handle before wait() to avoid deadlock.
drop(child.stdout.take());
if buf.len() > MAX_SIG_FILE {
let _ = child.kill();
let _ = child.wait();
return Err(format!("git config output for {key} exceeds size limit"));
}
let status = child
.wait()
.map_err(|e| format!("failed to wait for git: {e}"))?;
// git config --get exits with:
// 0 = key found, 1 = key not found, >1 = error
match status.code() {
Some(0) => {}
Some(1) => return Ok(None),
_ => {
return Err(format!(
"git config --get {key} failed with status {status}"
))
}
}
let val = String::from_utf8(buf).map_err(|_| format!("git config {key} is not valid UTF-8"))?;
let trimmed = val.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
/// Open a keyfile with symlink rejection and permission checks.
///
/// Uses `O_NOFOLLOW` to reject symlinks atomically at the kernel level (no
/// TOCTOU between stat and open). Uses `O_NONBLOCK` to prevent blocking on
/// FIFOs — cleared after confirming the path is a regular file. Then fstats
/// the opened handle to verify permissions. Returns the opened file handle.
#[cfg(unix)]
fn open_keyfile(path: &str) -> Result<fs::File, Error> {
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::os::unix::io::AsRawFd;
// O_NOFOLLOW: fail with ELOOP if path is a symlink.
// O_NONBLOCK: prevent blocking if path is a FIFO (cleared below once we
// confirm it's a regular file).
let file = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)
.map_err(|e| {
if e.raw_os_error() == Some(libc::ELOOP) {
Error::Fatal(format!("keyfile {path} is a symlink (not allowed)"))
} else {
Error::Fatal(format!("cannot open keyfile {path}: {e}"))
}
})?;
// fstat the opened handle — no TOCTOU since we already have the fd
let meta = file
.metadata()
.map_err(|e| Error::Fatal(format!("cannot stat keyfile {path}: {e}")))?;
if !meta.file_type().is_file() {
return Err(Error::Fatal(format!(
"keyfile {path} is not a regular file"
)));
}
// Clear O_NONBLOCK now that we know it's a regular file — reads on regular
// files are always non-blocking anyway, but clearing it is cleaner.
let fd = file.as_raw_fd();
// SAFETY EXCEPTION: Required for Unix fd operations; no safe Rust API exists
// for fcntl F_GETFL/F_SETFL. The fd comes from a File we just opened and
// fstat'd — it is valid for the duration of this block. We only modify the
// O_NONBLOCK flag; no memory is read or written through the fd here.
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags >= 0 {
libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
}
}
let mode = meta.permissions().mode() & 0o777;
if mode & 0o177 != 0 {
return Err(Error::Fatal(format!(
"keyfile {path} has insecure permissions {mode:04o} (expected 0600 or 0400)"
)));
}
// Verify the keyfile is owned by the current user. A 0600 file owned by
// another UID could still be readable via ACLs or privileged execution.
// SAFETY EXCEPTION: getuid(2) has no preconditions and no side effects.
let current_uid = unsafe { libc::getuid() };
if meta.uid() != current_uid {
return Err(Error::Fatal(format!(
"keyfile {path} is owned by uid {} but current uid is {current_uid}",
meta.uid()
)));
}
Ok(file)
}
#[cfg(not(unix))]
fn check_keyfile_permissions(_path: &str) -> Result<(), Error> {
// No permission checking on non-unix platforms.
Ok(())
}
/// Read a keyfile securely, returning its trimmed contents as a `Zeroizing<String>`.
///
/// Performs platform-appropriate permission/symlink checks, enforces the 1 KB
/// size limit, and wraps the buffer in `Zeroizing` from the moment it is
/// allocated so the secret material is erased on drop regardless of the return
/// path.
fn read_keyfile_secure(path: &str) -> Result<zeroize::Zeroizing<String>, Error> {
// Max keyfile size: nsec1 bech32 is ~63 chars, hex is 64 chars.
// 1 KB allows generous headroom for whitespace/newlines.
const MAX_KEYFILE: u64 = 1024;
#[cfg(unix)]
let file = open_keyfile(path)?;
#[cfg(not(unix))]
let file = {
check_keyfile_permissions(path)?;
fs::File::open(path)
.map_err(|e| Error::Fatal(format!("cannot open keyfile {path}: {e}")))?
};
// Allocate inside Zeroizing immediately so the buffer is erased on any
// early-return error path, not just on the success path.
let mut buf = zeroize::Zeroizing::new(String::new());
file.take(MAX_KEYFILE + 1)
.read_to_string(&mut buf)
.map_err(|e| Error::Fatal(format!("cannot read keyfile {path}: {e}")))?;
if buf.len() as u64 > MAX_KEYFILE {
return Err(Error::Fatal(format!(
"keyfile {path} exceeds {MAX_KEYFILE} byte limit"
)));
}
// Trim in-place: build a new Zeroizing<String> from the trimmed slice,
// then let the original (with leading/trailing whitespace) be zeroized.
let trimmed = zeroize::Zeroizing::new(buf.trim().to_string());
Ok(trimmed)
}
// ── Signing Hash ─────────────────────────────────────────────────────────────
/// Compute the NIP-GS signing hash.
///
/// ```text
/// hash = SHA-256("nostr:git:v1:" || decimal(t) || ":" || oa_binding || payload)
/// ```
///
/// Where `oa_binding` is:
/// - If oa present: `oa[0] || ":" || oa[1] || ":" || oa[2] || ":"`
/// - If oa absent: empty (zero bytes)
fn compute_signing_hash(
timestamp: u64,
oa: Option<&(String, String, String)>,
payload: &[u8],
) -> [u8; 32] {
let mut engine = Sha256Hash::engine();
engine.input(DOMAIN_SEPARATOR.as_bytes());
engine.input(timestamp.to_string().as_bytes());
engine.input(b":");
if let Some((owner_pk, conditions, owner_sig)) = oa {
engine.input(owner_pk.as_bytes());
engine.input(b":");
engine.input(conditions.as_bytes());
engine.input(b":");
engine.input(owner_sig.as_bytes());
engine.input(b":");
}
engine.input(payload);
Sha256Hash::from_engine(engine).to_byte_array()
}
// ── JSON Envelope ────────────────────────────────────────────────────────────
/// Build the canonical JSON envelope (compact, deterministic field order).
///
/// NIP-GS requires byte-exact canonical form for verification: field order
/// is `v, pk, sig, t[, oa]`, no whitespace, no trailing commas. We use
/// `format!` rather than serde to guarantee this exact byte layout — serde's
/// serialization order depends on the `Map` implementation and feature flags.
fn build_envelope(pk: &str, sig: &str, t: u64, oa: Option<&(String, String, String)>) -> String {
match oa {
Some((owner, conditions, owner_sig)) => {
format!(
r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":{t},"oa":["{owner}","{conditions}","{owner_sig}"]}}"#
)
}
None => {
format!(r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":{t}}}"#)
}
}
}
/// Wrap JSON bytes in PEM-style armor.
fn armor(json_bytes: &[u8]) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(json_bytes);
format!("{ARMOR_BEGIN}\n{b64}\n{ARMOR_END}\n")
}
// ── Signing Mode ─────────────────────────────────────────────────────────────
fn do_sign(key_id: &str, status: &mut StatusWriter) -> Result<(), Error> {
// Load key (zeroized on drop)
let mut raw_key = load_key()?;
// Parse directly into SecretKey — avoids nostr::Keys which stores
// non-zeroizable copies of the secret material internally.
let mut secret_key = match SecretKey::parse(&*raw_key) {
Ok(k) => k,
Err(e) => {
raw_key.zeroize();
return Err(Error::Fatal(format!("invalid nostr private key: {e}")));
}
};
raw_key.zeroize();
// Derive public key for envelope and key-id matching.
// Drop secret_key immediately after creating the keypair so it doesn't
// linger on the stack through the rest of the function.
// Wrapped in KeypairGuard so non_secure_erase() runs on ALL exit paths.
let keypair = KeypairGuard::new(Keypair::from_secret_key(&SECP256K1, &secret_key));
// Explicitly zero the SecretKey stack slot before dropping. nostr::SecretKey's
// Drop calls inner.non_secure_erase(), but that operates on the moved value.
// This write_bytes targets our local copy to minimize residual secret material.
// SAFETY: We have exclusive mutable access to `secret_key` on the stack.
// write_bytes zeroes size_of::<SecretKey>() bytes at the local's address.
// The subsequent drop is a no-op on zeroed memory (non_secure_erase on zeros).
unsafe {
let ptr = &mut secret_key as *mut SecretKey as *mut u8;
std::ptr::write_bytes(ptr, 0, std::mem::size_of::<SecretKey>());
}
drop(secret_key);
let (xonly_pk, _parity) = keypair.inner().x_only_public_key();
let pk_hex = hex::encode(xonly_pk.serialize());
// Verify key matches the -u argument. Fail closed: if the key_id is
// non-empty and in a recognized format, it MUST match the loaded key.
// If the format is unrecognized, we also fail — better to reject than
// to silently sign with the wrong key.
if !key_id.is_empty() {
match normalize_key_id(key_id) {
Some(expected_hex) => {
if expected_hex != pk_hex {
return Err(Error::Fatal(format!(
"signing key argument ({key_id}) does not match loaded key ({pk_hex})"
)));
}
}
None => {
return Err(Error::Fatal(format!(
"signing key argument ({key_id}) is not a recognized key format \
(expected 64-char hex or npub1...)"
)));
}
}
}
// Read payload from stdin (bounded)
let payload = read_payload_stdin()?;
// Get timestamp — capped at u32::MAX per NIP-GS spec range [0, 4294967295]
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Fatal("system clock is before Unix epoch".to_string()))?
.as_secs();
if t > u32::MAX as u64 {
return Err(Error::Fatal(format!(
"timestamp {t} exceeds NIP-GS u32 range (max {})",
u32::MAX
)));
}
// Load optional auth tag — fails closed if configured but malformed.
// Validate the credential before embedding: check owner key validity,
// reject self-attestation, and verify the owner's signature.
let oa = load_auth_tag()?;
if let Some(ref oa_val) = oa {
// Owner pubkey must be a valid BIP-340 key
if PublicKey::from_hex(&oa_val.0).is_err() {
return Err(Error::Fatal(
"auth tag owner (oa[0]) is not a valid BIP-340 public key".to_string(),
));
}
// Owner must not be the signer (self-attestation is meaningless)
if oa_val.0 == pk_hex {
return Err(Error::Fatal(
"auth tag owner (oa[0]) must not equal signing key (self-attestation)".to_string(),
));
}
// Verify the owner's signature over the auth credential
if !verify_oa(&pk_hex, oa_val) {
return Err(Error::Fatal(
"auth tag owner signature (oa[2]) verification failed — \
the configured SPROUT_AUTH_TAG is invalid or stale"
.to_string(),
));
}
}
// Enforce time constraints from auth tag conditions against signing timestamp.
// This prevents embedding a stale/expired auth tag into a new signature.
if let Some(ref oa_val) = oa {
enforce_conditions(&oa_val.1, t)
.map_err(|msg| Error::Fatal(format!("auth tag conditions not satisfied: {msg}")))?;
if has_kind_clause(&oa_val.1) {
eprintln!("warning: auth tag contains kind= constraints which are not enforced in git signing context");
}
}
// Compute signing hash
let hash = compute_signing_hash(t, oa.as_ref(), &payload);
let message = Message::from_digest(hash);
// Sign with BIP-340 Schnorr using the keypair (guarded — erased on drop).
let sig = SECP256K1.sign_schnorr(&message, keypair.inner());
let sig_hex = hex::encode(sig.serialize());
// Keypair is erased by KeypairGuard::drop; drop it now that signing is done.
drop(keypair);
// Build envelope and armor
let json = build_envelope(&pk_hex, &sig_hex, t, oa.as_ref());
let armored = armor(json.as_bytes());
// Write signature to stdout — errors are fatal because git reads
// the signature from our stdout. Use write_all (not print!) to avoid
// panicking on broken pipe.
io::stdout()
.write_all(armored.as_bytes())
.and_then(|_| io::stdout().flush())
.map_err(|e| Error::Fatal(format!("failed to write signature to stdout: {e}")))?;
// Write GnuPG status lines:
// - SIG_CREATED: D=detached, 8=algo(EdDSA placeholder), 1=hash(SHA256),
// 00=class, timestamp, fingerprint
status.write_line("BEGIN_SIGNING");
status.write_line(&format!("SIG_CREATED D 8 1 00 {t} {pk_hex}"));
Ok(())
}
// ── Verification Mode ────────────────────────────────────────────────────────
/// Best-effort extraction of the pk field from raw JSON, for ERRSIG reporting
/// when full envelope parsing fails. Returns the pk hex string if it looks valid.
fn extract_pk_best_effort(json_str: &str) -> Option<String> {
let marker = "\"pk\":\"";
let start = json_str.find(marker)? + marker.len();
// Use get() to avoid panicking on non-ASCII char boundaries
let candidate = json_str.get(start..start + 64)?;
if candidate
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
Some(candidate.to_string())
} else {
None
}
}
fn do_verify(sig_file: &str, status: &mut StatusWriter) -> Result<(), Error> {
// Read signature file with size bound
let sig_content = read_bounded_file(sig_file, MAX_SIG_FILE).inspect_err(|_e| {
write_errsig(status, None);
})?;
// Parse armor
let b64 = parse_armor(&sig_content).map_err(|e| {
write_errsig(status, None);
Error::VerifyFailed {
pk: None,
msg: e.to_string(),
}
})?;
// Validate base64 line length
if b64.len() > MAX_BASE64_LINE {
write_errsig(status, None);
return Err(Error::VerifyFailed {
pk: None,
msg: format!("base64 line exceeds {MAX_BASE64_LINE} bytes"),
});
}
// Decode base64
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| {
write_errsig(status, None);
Error::VerifyFailed {
pk: None,
msg: format!("invalid base64: {e}"),
}
})?;
// Check decoded size limit
if decoded.len() > MAX_JSON_DECODED {
write_errsig(status, None);
return Err(Error::VerifyFailed {
pk: None,
msg: format!("decoded JSON exceeds {MAX_JSON_DECODED} bytes"),
});
}
// Validate UTF-8
let json_str = std::str::from_utf8(&decoded).map_err(|_| {
write_errsig(status, None);
Error::VerifyFailed {
pk: None,
msg: "decoded bytes are not valid UTF-8".to_string(),
}
})?;
// Reject whitespace outside string values (compact JSON check)
if has_non_string_whitespace(json_str) {
let early_pk = extract_pk_best_effort(json_str);
write_errsig(status, early_pk.as_deref());
return Err(Error::VerifyFailed {
pk: early_pk,
msg: "JSON contains whitespace outside string values".to_string(),
});
}
// Best-effort pk extraction for ERRSIG before full parse
let early_pk = extract_pk_best_effort(json_str);
// Parse JSON envelope
let envelope = parse_envelope(json_str).map_err(|e| {
write_errsig(status, early_pk.as_deref());
Error::VerifyFailed {
pk: early_pk.clone(),
msg: e.to_string(),
}
})?;
// Canonical JSON reconstruction check — ensures no field reordering or
// extra whitespace was present in the original.
let reconstructed = build_envelope(
&envelope.pk,
&envelope.sig,
envelope.t,
envelope.oa.as_ref(),
);
if reconstructed != json_str {
write_errsig(status, Some(&envelope.pk));
return Err(Error::VerifyFailed {
pk: Some(envelope.pk),
msg: "JSON is not in canonical form".to_string(),
});
}
// Validate pk is a valid BIP-340 x-only public key
let pk = PublicKey::from_hex(&envelope.pk).map_err(|e| {
write_errsig(status, Some(&envelope.pk));
Error::VerifyFailed {
pk: Some(envelope.pk.clone()),
msg: format!("pk is not a valid BIP-340 public key: {e}"),
}
})?;
// Read payload from stdin (bounded). Emit ERRSIG on failure since we
// already have the pk from the envelope.
let payload = read_payload_stdin().inspect_err(|_e| {
write_errsig(status, Some(&envelope.pk));
})?;
// Compute signing hash
let hash = compute_signing_hash(envelope.t, envelope.oa.as_ref(), &payload);
let message = Message::from_digest(hash);
// Parse signature
let sig_bytes = hex::decode(&envelope.sig).map_err(|_| {
write_errsig(status, Some(&envelope.pk));
Error::VerifyFailed {
pk: Some(envelope.pk.clone()),
msg: "invalid signature hex".to_string(),
}
})?;
let sig = Signature::from_slice(&sig_bytes).map_err(|_| {
write_errsig(status, Some(&envelope.pk));
Error::VerifyFailed {
pk: Some(envelope.pk.clone()),
msg: "invalid BIP-340 signature".to_string(),
}
})?;
// Verify BIP-340 signature
let xonly: &XOnlyPublicKey = &pk;
if SECP256K1.verify_schnorr(&sig, &message, xonly).is_err() {
status.write_line("NEWSIG");
status.write_line(&format!("BADSIG {} {}", envelope.pk, envelope.pk));
return Err(Error::VerifyFailed {
pk: Some(envelope.pk),
msg: "BIP-340 signature verification failed".to_string(),
});
}
// Signature is valid — check NIP-OA if present and track result.
let oa_result = if let Some(ref oa) = envelope.oa {
// Validate oa[0] is a valid BIP-340 public key. Per NIP-GS spec,
// an invalid owner pubkey is a structural error → ERRSIG.
if PublicKey::from_hex(&oa.0).is_err() {
write_errsig(status, Some(&envelope.pk));
return Err(Error::VerifyFailed {
pk: Some(envelope.pk),
msg: "oa[0] owner pubkey is not a valid BIP-340 key".to_string(),
});
}
if !verify_oa(&envelope.pk, oa) {
eprintln!(
"warning: NIP-OA owner attestation verification failed (signature still valid)"
);
OaVerifyResult::InvalidSignature
} else if let Err(msg) = enforce_conditions(&oa.1, envelope.t) {
eprintln!("warning: NIP-OA conditions not satisfied: {msg}");
OaVerifyResult::ConditionsViolated
} else {
// kind= clauses are valid NIP-OA but not enforceable in NIP-GS.
// We still report Valid here — callers check nostr-oa-status for
// the full picture. The kind_not_applicable status is preserved
// via the NOTATION_DATA line below.
if has_kind_clause(&oa.1) {
eprintln!(
"warning: auth tag contains kind= constraints which are not enforced in git signing context"
);
}
OaVerifyResult::Valid
}
} else {
OaVerifyResult::Absent
};
// Determine trust level.
//
// Direct key match takes priority: if the verified key matches
// `user.signingkey` in git config, that is TRUST_FULLY regardless of OA
// status. OA delegation is only a secondary attestation path — it does not
// affect the primary trust determination.
//
// NOTE: This is NOT a PKI trust root — it simply tells git "this is the
// key I expect for this repo." A proper trust model would use a keyring or
// web-of-trust, but git's signing interface only supports TRUST_FULLY /
// TRUST_UNDEFINED.
let trust = determine_trust(&envelope.pk);
// Format date from timestamp (for VALIDSIG status line)
let date_str = timestamp_to_date(envelope.t);
// Write GnuPG success status lines:
// - NEWSIG: signals start of a new signature check
// - GOODSIG <keyid> <uid>: signature is cryptographically valid
// - VALIDSIG <fpr> <date> <timestamp> ... <primary-fpr>: full details
// - TRUST_*: trust level of the signing key
//
// These are critical — git reads them to determine signature validity.
// Use status_or_fail! so a broken status-fd is surfaced as an error.
status_or_fail!(status, "NEWSIG");
status_or_fail!(status, "GOODSIG {} {}", envelope.pk, envelope.pk);
status_or_fail!(
status,
"VALIDSIG {} {} {} 0 - - - - - {}",
envelope.pk,
date_str,
envelope.t,
envelope.pk
);
status_or_fail!(status, "{} 0 shell", trust);
// Clarify that TRUST_FULLY is advisory — it only means the verified key
// matches user.signingkey in git config, not that the signer is trusted
// by any external authority. Callers MUST NOT rely on this for security
// decisions without an external allowlist or owner policy.
status_or_fail!(status, "NOTATION_NAME nostr-trust-model");
status_or_fail!(status, "NOTATION_DATA advisory-config-match-only");
// Emit machine-readable OA status via NOTATION lines.
// This allows callers to distinguish "valid sig + valid OA" from
// "valid sig + invalid/missing OA" without parsing stderr warnings.
// NOTATION_NAME/NOTATION_DATA pairs are part of the GnuPG status protocol.
if let Some(ref oa) = envelope.oa {
status_or_fail!(status, "NOTATION_NAME nostr-oa-status");
status_or_fail!(status, "NOTATION_DATA {}", oa_result.as_status_str());
status_or_fail!(status, "NOTATION_NAME nostr-oa-owner");
status_or_fail!(status, "NOTATION_DATA {}", oa.0);
} else {
status_or_fail!(status, "NOTATION_NAME nostr-oa-status");
status_or_fail!(status, "NOTATION_DATA none");
}
Ok(())
}
// ── Envelope Parsing ─────────────────────────────────────────────────────────
#[derive(Debug)]
struct Envelope {
pk: String,
sig: String,
t: u64,
oa: Option<(String, String, String)>,
}
fn parse_envelope(json_str: &str) -> Result<Envelope, String> {
let val: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| format!("invalid JSON: {e}"))?;
let obj = val.as_object().ok_or("JSON must be an object")?;
// Reject unknown keys — v=1 envelope allows only: v, pk, sig, t, oa
let allowed = ["v", "pk", "sig", "t", "oa"];
for key in obj.keys() {
if !allowed.contains(&key.as_str()) {
return Err(format!("unknown key in v=1 envelope: {key:?}"));
}
}
// v (required, must be 1)
let v = obj
.get("v")
.ok_or("missing required field: v")?
.as_u64()
.ok_or("v must be an integer")?;
if v != 1 {
return Err(format!("unsupported version: {v}"));
}
// pk (required, 64-char lowercase hex)
let pk = obj
.get("pk")
.ok_or("missing required field: pk")?
.as_str()
.ok_or("pk must be a string")?;
validate_hex_field(pk, 64, "pk")?;
// sig (required, 128-char lowercase hex)
let sig = obj
.get("sig")
.ok_or("missing required field: sig")?
.as_str()
.ok_or("sig must be a string")?;
validate_hex_field(sig, 128, "sig")?;
// t (required, non-negative integer, max u32 range for timestamps)
let t_val = obj.get("t").ok_or("missing required field: t")?;
if t_val.is_f64() && !t_val.is_u64() && !t_val.is_i64() {
return Err("t must be an integer, not a float".to_string());
}
let t = t_val.as_u64().ok_or("t must be a non-negative integer")?;
if t > 4294967295 {
return Err(format!("t out of range: {t}"));
}
// oa (optional array of 3 strings)
let oa = if let Some(oa_val) = obj.get("oa") {
let arr = oa_val.as_array().ok_or("oa must be an array")?;
if arr.len() != 3 {
return Err(format!(
"oa must have exactly 3 elements, got {}",
arr.len()
));
}
let owner = arr[0].as_str().ok_or("oa[0] must be a string")?;
let conditions = arr[1].as_str().ok_or("oa[1] must be a string")?;
let owner_sig = arr[2].as_str().ok_or("oa[2] must be a string")?;
validate_hex_field(owner, 64, "oa[0]")?;
validate_hex_field(owner_sig, 128, "oa[2]")?;
// Validate conditions character class — MUST be checked during parsing
// because build_envelope() interpolates conditions into JSON without
// escaping. Characters outside the allowed set (alphanumeric, _=<>&)
// could break canonical reconstruction or inject JSON syntax.
if !validate_conditions(conditions) {
return Err(
"oa[1] conditions contain invalid characters (allowed: alphanumeric, _=<>&)"
.to_string(),
);
}
// Validate oa[0] is a valid BIP-340 x-only public key (not just hex)
PublicKey::from_hex(owner)
.map_err(|e| format!("oa[0] is not a valid BIP-340 public key: {e}"))?;
// Self-attestation is meaningless — owner must differ from signer
if owner == pk {
return Err("oa[0] (owner) must not equal pk (self-attestation)".to_string());
}
Some((
owner.to_string(),
conditions.to_string(),
owner_sig.to_string(),
))
} else {
None
};
Ok(Envelope {
pk: pk.to_string(),
sig: sig.to_string(),
t,
oa,
})
}
fn validate_hex_field(val: &str, expected_len: usize, name: &str) -> Result<(), String> {
if val.len() != expected_len {
return Err(format!(
"{name} must be exactly {expected_len} characters, got {}",
val.len()
));
}
if !val
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Err(format!("{name} must be lowercase hex"));
}
Ok(())
}
// ── Armor Parsing ────────────────────────────────────────────────────────────
fn parse_armor(content: &str) -> Result<&str, String> {
// NIP-GS spec requires armor to end with a newline after the END marker.
let content = content
.strip_suffix('\n')
.ok_or("armor must end with a newline")?;
let lines: Vec<&str> = content.split('\n').collect();
if lines.len() != 3 {
return Err(format!(
"armor must have exactly 3 lines (BEGIN, base64, END), got {}",
lines.len()
));
}
if lines[0] != ARMOR_BEGIN {
return Err("missing or malformed BEGIN marker".to_string());
}
if lines[2] != ARMOR_END {
return Err("missing or malformed END marker".to_string());
}
// Reject trailing whitespace on any line
for (i, line) in lines.iter().enumerate() {
if line.ends_with(' ') || line.ends_with('\t') || line.ends_with('\r') {
return Err(format!("trailing whitespace on line {}", i + 1));
}
}
Ok(lines[1])
}
// ── NIP-OA Verification ─────────────────────────────────────────────────────
/// Verify the owner attestation signature.
///
/// Returns `true` if the attestation is cryptographically valid, `false`
/// otherwise. Per NIP-GS spec, OA failure does NOT invalidate the commit
/// signature — it only means the delegation claim is unverified.
fn verify_oa(agent_pk_hex: &str, oa: &(String, String, String)) -> bool {
let (owner_pk_hex, conditions, owner_sig_hex) = oa;
// Parse owner pubkey
let owner_pk = match PublicKey::from_hex(owner_pk_hex) {
Ok(p) => p,
Err(_) => {
eprintln!("warning: oa owner pubkey is not a valid BIP-340 key");
return false;
}
};
// Compute NIP-OA preimage: "nostr:agent-auth:" || agent_pk || ":" || conditions
let preimage = format!("nostr:agent-auth:{agent_pk_hex}:{conditions}");
let digest = Sha256Hash::hash(preimage.as_bytes());
let message = Message::from_digest(digest.to_byte_array());
// Parse and verify owner signature
let sig_bytes = match hex::decode(owner_sig_hex) {
Ok(b) => b,
Err(_) => {
eprintln!("warning: oa owner signature is invalid hex");
return false;
}
};
let sig = match Signature::from_slice(&sig_bytes) {
Ok(s) => s,
Err(_) => {
eprintln!("warning: oa owner signature is not a valid BIP-340 signature");
return false;
}
};
let xonly: &XOnlyPublicKey = &owner_pk;
if SECP256K1.verify_schnorr(&sig, &message, xonly).is_err() {
eprintln!("warning: NIP-OA owner attestation signature verification failed");
return false;
}
true
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Read payload from stdin with a bounded allocation.
///
/// Single entry point for all stdin reads — both sign and verify paths use
/// this function so the size limit and error handling are consistent.
fn read_payload_stdin() -> Result<Vec<u8>, Error> {
let limit = (MAX_PAYLOAD as u64) + 1;
let mut payload = Vec::new();
io::stdin()
.take(limit)
.read_to_end(&mut payload)
.map_err(|e| Error::Fatal(format!("failed to read payload from stdin: {e}")))?;
if payload.len() > MAX_PAYLOAD {
return Err(Error::Fatal(format!(
"payload exceeds {} MB limit",
MAX_PAYLOAD / (1024 * 1024)
)));
}
Ok(payload)
}
/// Read a file with a size bound (prevents memory DoS from large files).
/// Opens the file once and checks size from the open handle to avoid TOCTOU.
/// Rejects non-regular files (FIFOs, devices) which could block or produce
/// unbounded data.
///
/// Uses `O_NONBLOCK` on Unix to prevent blocking if the path is a FIFO.
/// After confirming the path is a regular file, clears `O_NONBLOCK` so that
/// subsequent reads behave normally.
fn read_bounded_file(path: &str, max_size: usize) -> Result<String, Error> {
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
let f = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
.map_err(|e| Error::Fatal(format!("cannot open signature file {path}: {e}")))?;
let meta = f
.metadata()
.map_err(|e| Error::Fatal(format!("cannot stat signature file {path}: {e}")))?;
if !meta.file_type().is_file() {
return Err(Error::Fatal(format!(
"signature file {path} is not a regular file"
)));
}
// Clear O_NONBLOCK now that we know it's a regular file.
let fd = f.as_raw_fd();
// SAFETY EXCEPTION: Required for Unix fd operations; no safe Rust API exists
// for fcntl F_GETFL/F_SETFL. The fd comes from a File we just opened and
// fstat'd — it is valid for the duration of this block. We only modify the
// O_NONBLOCK flag; no memory is read or written through the fd here.
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags >= 0 {
libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
}
}
(f, meta)
};
#[cfg(not(unix))]
let file = {
let f = fs::File::open(path)
.map_err(|e| Error::Fatal(format!("cannot open signature file {path}: {e}")))?;
let meta = f
.metadata()
.map_err(|e| Error::Fatal(format!("cannot stat signature file {path}: {e}")))?;
if !meta.file_type().is_file() {
return Err(Error::Fatal(format!(
"signature file {path} is not a regular file"
)));
}
(f, meta)
};
let (file, meta) = file;
if meta.len() > max_size as u64 {
return Err(Error::Fatal(format!(
"signature file {path} exceeds {max_size} byte limit"
)));
}
let mut buf = String::with_capacity(meta.len() as usize);
file.take(max_size as u64 + 1)
.read_to_string(&mut buf)
.map_err(|e| Error::Fatal(format!("cannot read signature file {path}: {e}")))?;
// Post-read check: reject if the file grew between metadata check and read.
// The take() limits us to max_size+1 bytes, so this catches growth.
if buf.len() > max_size {
return Err(Error::Fatal(format!(
"signature file {path} exceeds {max_size} byte limit (grew during read)"
)));
}
Ok(buf)
}
/// Normalize a key ID argument to lowercase hex if recognizable.
/// Returns `None` if the format is not recognized.
fn normalize_key_id(key_id: &str) -> Option<String> {
let trimmed = key_id.trim();
// npub1... bech32
if trimmed.starts_with("npub1") {
return PublicKey::from_bech32(trimmed).ok().map(|pk| pk.to_hex());
}
// 64-char hex
if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(trimmed.to_lowercase());
}
None
}
/// Determine trust level by checking `user.signingkey` git config.
///
/// **ADVISORY ONLY.** `TRUST_FULLY` means "the verified key matches what
/// this repo's `user.signingkey` is configured to expect." It does NOT mean:
/// - The signer is trusted by any external authority
/// - The key has been verified against a keyring or web-of-trust
/// - The commit is safe to deploy or merge
///
/// Git's signing interface (`gpg.x509.program`) provides no mechanism for
/// external trust roots. For security-sensitive verification, use an external
/// allowlist or owner policy and treat `TRUST_FULLY` as advisory only.
///
/// Returns `TRUST_FULLY` if the key matches config, `TRUST_UNDEFINED` otherwise.
fn determine_trust(pk_hex: &str) -> &'static str {
if let Some(configured) = git_config("user.signingkey") {
if let Some(ref hex_val) = normalize_key_id(&configured) {
if hex_val == pk_hex {
return "TRUST_FULLY";
}
}
}
"TRUST_UNDEFINED"
}
/// Convert a unix timestamp to YYYY-MM-DD (UTC) for VALIDSIG status line.
fn timestamp_to_date(t: u64) -> String {
DateTime::from_timestamp(t as i64, 0)
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "1970-01-01".to_string())
}
/// Emit ERRSIG status line (signature could not be processed).
/// Format: ERRSIG <keyid> <pkalgo> <hashalgo> <class> <timestamp> <rc>
fn write_errsig(status: &mut StatusWriter, pk: Option<&str>) {
let key_id = pk.unwrap_or("0000000000000000");
status.write_line(&format!("ERRSIG {key_id} 0 0 00 0 9"));
}
/// Check if JSON string has whitespace outside of string values.
/// Simple state machine tracking whether we're inside a quoted string.
fn has_non_string_whitespace(s: &str) -> bool {
let mut in_string = false;
let mut escape = false;
for c in s.chars() {
if escape {
escape = false;
continue;
}
if in_string {
if c == '\\' {
escape = true;
} else if c == '"' {
in_string = false;
}
} else if c == '"' {
in_string = true;
} else if c.is_ascii_whitespace() {
return true;
}
}
false
}
// ── Main ─────────────────────────────────────────────────────────────────────
fn main() {
let code = run();
process::exit(code);
}
/// Actual entry point — returns an exit code. This ensures all locals are
/// dropped (and zeroized) before `process::exit` is called.
fn run() -> i32 {
let args = match parse_args() {
Ok(a) => a,
Err(Error::Fatal(msg)) => {
eprintln!("error: {msg}");
return 1;
}
Err(Error::VerifyFailed { msg, .. }) => {
eprintln!("error: {msg}");
return 1;
}
};
// Guard: in signing mode, status_fd=1 (stdout) would corrupt the signature
// output. Fall back to stderr (fd 2) in that case.
let effective_fd = match (&args.mode, args.status_fd) {
(Mode::Sign { .. }, Some(1)) => {
eprintln!("warning: --status-fd=1 in sign mode would corrupt output, using stderr");
Some(2)
}
_ => args.status_fd,
};
let mut status = match args.mode {
Mode::Sign { .. } => match StatusWriter::new(effective_fd, false) {
Ok(s) => s,
Err(Error::Fatal(msg)) => {
eprintln!("error: {msg}");
return 1;
}
Err(Error::VerifyFailed { msg, .. }) => {
eprintln!("error: {msg}");
return 1;
}
},
Mode::Verify { .. } => match StatusWriter::new(effective_fd, true) {
Ok(s) => s,
Err(Error::Fatal(msg)) => {
eprintln!("error: {msg}");
return 1;
}
Err(Error::VerifyFailed { msg, .. }) => {
eprintln!("error: {msg}");
return 1;
}
},
};
let result = match args.mode {
Mode::Sign { ref key_id } => do_sign(key_id, &mut status),
Mode::Verify { ref sig_file } => do_verify(sig_file, &mut status),
};
match result {
Ok(()) => 0,
Err(Error::Fatal(msg)) => {
eprintln!("error: {msg}");
1
}
Err(Error::VerifyFailed { msg, .. }) => {
eprintln!("error: {msg}");
1
}
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// NIP-GS spec test key (secret = 0x03)
const TEST_PK: &str = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9";
// Spec test payload: minimal git commit object (170 bytes)
fn test_payload() -> Vec<u8> {
b"tree 4b825dc642cb6eb9a060e54bf899d69f7cb46101\n\
author Test User <test@example.com> 1700000000 +0000\n\
committer Test User <test@example.com> 1700000000 +0000\n\
\n\
Initial commit"
.to_vec()
}
#[test]
fn test_signing_hash_matches_spec() {
// From NIP-GS spec: SHA-256 of preimage with t=1700000000, no oa
let hash = compute_signing_hash(1700000000, None, &test_payload());
let expected = "a11a32173aa35125aaefaad8854f2eda5a144268a4a355905c841f79ff44aa18";
assert_eq!(hex::encode(hash), expected);
}
#[test]
fn test_signing_hash_with_oa_matches_spec() {
// From NIP-GS spec: owner attestation test vector
let oa = (
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
"".to_string(),
"54b97dfd2b7d61c1bc1b5facab9d12a991fe0ac3dcb9044b3176f63bebb6f67340eb0ad866f2d5568b78b58ba234ee9f490f8c41e64a949c200315801520ed25".to_string(),
);
let hash = compute_signing_hash(1700000000, Some(&oa), &test_payload());
let expected = "b61f1658836a4f63a2d2f5d621014a064435dde0765dd9c1dc79c9530fe879f0";
assert_eq!(hex::encode(hash), expected);
}
#[test]
fn test_canonical_json_no_oa() {
let json = build_envelope(TEST_PK, &"a".repeat(128), 1700000000, None);
// Must be compact (no whitespace), field order: v, pk, sig, t
assert!(!json.contains(' '));
assert!(json.starts_with(r#"{"v":1,"pk":""#));
assert!(json.contains(r#","t":1700000000}"#));
assert!(!json.contains("oa"));
}
#[test]
fn test_canonical_json_with_oa() {
let oa = (
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
"".to_string(),
"b".repeat(128),
);
let json = build_envelope(TEST_PK, &"a".repeat(128), 1700000000, Some(&oa));
// Field order: v, pk, sig, t, oa
assert!(json.contains(r#","oa":["#));
let v_pos = json.find(r#""v""#).unwrap();
let oa_pos = json.find(r#""oa""#).unwrap();
assert!(v_pos < oa_pos);
}
#[test]
fn test_armor_format() {
let json = r#"{"v":1,"pk":"test","sig":"test","t":0}"#;
let armored = armor(json.as_bytes());
assert!(armored.starts_with("-----BEGIN SIGNED MESSAGE-----\n"));
assert!(armored.ends_with("-----END SIGNED MESSAGE-----\n"));
let lines: Vec<&str> = armored.trim_end().split('\n').collect();
assert_eq!(lines.len(), 3);
}
#[test]
fn test_parse_armor_valid() {
let input = "-----BEGIN SIGNED MESSAGE-----\nYWJj\n-----END SIGNED MESSAGE-----\n";
let b64 = parse_armor(input).unwrap();
assert_eq!(b64, "YWJj");
}
#[test]
fn test_parse_armor_rejects_bad_header() {
let input = "-----BEGIN PGP SIGNATURE-----\nYWJj\n-----END SIGNED MESSAGE-----\n";
assert!(parse_armor(input).is_err());
}
#[test]
fn test_validate_hex_field() {
assert!(validate_hex_field("abcdef0123456789", 16, "test").is_ok());
assert!(validate_hex_field("ABCDEF", 6, "test").is_err()); // uppercase
assert!(validate_hex_field("abcde", 6, "test").is_err()); // wrong length
assert!(validate_hex_field("ghijkl", 6, "test").is_err()); // non-hex
}
#[test]
fn test_has_non_string_whitespace() {
assert!(!has_non_string_whitespace(r#"{"a":"b c"}"#));
assert!(has_non_string_whitespace(r#"{"a": "b"}"#));
assert!(has_non_string_whitespace(r#"{ "a":"b"}"#));
assert!(!has_non_string_whitespace(r#"{"a":"b\n c"}"#));
}
#[test]
fn test_normalize_key_id_hex() {
let hex = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9";
assert_eq!(normalize_key_id(hex), Some(hex.to_string()));
}
#[test]
fn test_normalize_key_id_unrecognized() {
assert_eq!(normalize_key_id("not-a-key"), None);
assert_eq!(normalize_key_id(""), None);
}
#[test]
fn test_validate_conditions() {
// Valid
assert!(validate_conditions(""));
assert!(validate_conditions("kind=9&created_at<1700000000"));
assert!(validate_conditions("kind=0"));
assert!(validate_conditions("kind=65535"));
assert!(validate_conditions("created_at>0"));
assert!(validate_conditions("created_at<4294967295"));
// Invalid — structural
assert!(!validate_conditions("kind=01")); // leading zero
assert!(!validate_conditions("foo=1")); // unknown clause
assert!(!validate_conditions("kind=1&&kind=2")); // empty clause
assert!(!validate_conditions("kind=1&")); // trailing &
assert!(!validate_conditions("&kind=1")); // leading &
assert!(!validate_conditions("kind=65536")); // out of range
assert!(!validate_conditions("created_at<4294967296")); // out of range
// Invalid — injection / whitespace
assert!(!validate_conditions("kind = 9")); // space
assert!(!validate_conditions("kind=9;rm -rf /")); // semicolon, space, slash
assert!(!validate_conditions("kind=9\n")); // newline
}
#[test]
fn test_parse_status_fd() {
assert!(parse_status_fd("2").is_ok());
assert!(parse_status_fd("255").is_ok());
assert!(parse_status_fd("0").is_err()); // stdin
assert!(parse_status_fd("-1").is_err()); // negative
assert!(parse_status_fd("256").is_ok()); // valid — no upper cap
assert!(parse_status_fd("1024").is_ok()); // high fds are valid
assert!(parse_status_fd("abc").is_err()); // non-numeric
}
#[test]
fn test_read_bounded_file_rejects_missing() {
let result = read_bounded_file("/nonexistent/path", 1024);
assert!(result.is_err());
}
#[test]
fn test_load_auth_tag_rejects_bad_conditions() {
// Valid conditions → Ok(Some(...))
std::env::set_var(
"SPROUT_AUTH_TAG",
format!(
r#"["auth","{}","kind=9&created_at<1700000000","{}"]"#,
"a".repeat(64),
"b".repeat(128)
),
);
let result = load_auth_tag();
assert!(
matches!(result, Ok(Some(_))),
"valid conditions should be accepted"
);
// Empty conditions (valid) → Ok(Some(...))
std::env::set_var(
"SPROUT_AUTH_TAG",
format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)),
);
let result = load_auth_tag();
assert!(
matches!(result, Ok(Some(_))),
"empty conditions should be accepted"
);
// Conditions with spaces → Err (fail closed)
std::env::set_var(
"SPROUT_AUTH_TAG",
format!(
r#"["auth","{}","kind = 9","{}"]"#,
"a".repeat(64),
"b".repeat(128)
),
);
let result = load_auth_tag();
assert!(
result.is_err(),
"conditions with spaces should be rejected (Err)"
);
// Conditions with special chars → Err (fail closed)
std::env::set_var(
"SPROUT_AUTH_TAG",
format!(
r#"["auth","{}","kind=9;rm -rf /","{}"]"#,
"a".repeat(64),
"b".repeat(128)
),
);
let result = load_auth_tag();
assert!(
result.is_err(),
"conditions with special chars should be rejected (Err)"
);
// No auth tag set → Ok(None)
std::env::remove_var("SPROUT_AUTH_TAG");
let result = load_auth_tag();
assert!(
matches!(result, Ok(None)),
"absent auth tag should be Ok(None)"
);
}
// ── Round-trip and verification tests ────────────────────────────────
/// Helper: sign a payload and return the armored signature
fn sign_payload(secret_hex: &str, payload: &[u8], t: u64) -> String {
let keypair = Keypair::from_seckey_str(&SECP256K1, secret_hex).unwrap();
let (xonly, _) = keypair.x_only_public_key();
let pk_hex = hex::encode(xonly.serialize());
let hash = compute_signing_hash(t, None, payload);
let message = Message::from_digest(hash);
let sig = SECP256K1.sign_schnorr(&message, &keypair);
let sig_hex = hex::encode(sig.serialize());
let json = build_envelope(&pk_hex, &sig_hex, t, None);
armor(json.as_bytes())
}
/// Helper: parse and verify a signature against a payload
fn verify_sig(armored: &str, payload: &[u8]) -> Result<Envelope, String> {
let b64 = parse_armor(armored).map_err(|e| e.to_string())?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| format!("base64: {e}"))?;
let json_str = std::str::from_utf8(&decoded).map_err(|e| format!("utf8: {e}"))?;
let envelope = parse_envelope(json_str)?;
let reconstructed = build_envelope(
&envelope.pk,
&envelope.sig,
envelope.t,
envelope.oa.as_ref(),
);
if reconstructed != json_str {
return Err("non-canonical JSON".to_string());
}
let pk = PublicKey::from_hex(&envelope.pk).map_err(|e| format!("invalid pk: {e}"))?;
let hash = compute_signing_hash(envelope.t, envelope.oa.as_ref(), payload);
let message = Message::from_digest(hash);
let sig_bytes = hex::decode(&envelope.sig).map_err(|_| "bad sig hex")?;
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "bad sig")?;
let xonly: &XOnlyPublicKey = &pk;
SECP256K1
.verify_schnorr(&sig, &message, xonly)
.map_err(|_| "signature verification failed")?;
Ok(envelope)
}
#[test]
fn test_sign_verify_round_trip() {
// secret key = 0x03 (spec test key)
let secret = "0000000000000000000000000000000000000000000000000000000000000003";
let payload = test_payload();
let armored = sign_payload(secret, &payload, 1700000000);
let envelope = verify_sig(&armored, &payload).expect("round-trip should verify");
assert_eq!(envelope.pk, TEST_PK);
assert_eq!(envelope.t, 1700000000);
assert!(envelope.oa.is_none());
}
#[test]
fn test_verify_rejects_wrong_payload() {
let secret = "0000000000000000000000000000000000000000000000000000000000000003";
let payload = test_payload();
let armored = sign_payload(secret, &payload, 1700000000);
let wrong_payload = b"wrong payload";
let result = verify_sig(&armored, wrong_payload);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("signature verification failed"));
}
#[test]
fn test_verify_rejects_tampered_sig() {
let secret = "0000000000000000000000000000000000000000000000000000000000000003";
let payload = test_payload();
let armored = sign_payload(secret, &payload, 1700000000);
// Tamper with the base64 content (flip a character in the signature)
let tampered = armored.replace(&armored.lines().nth(1).unwrap()[..10], "AAAAAAAAAA");
// This should either fail to parse or fail verification
let result = verify_sig(&tampered, &payload);
assert!(result.is_err());
}
#[test]
fn test_verify_rejects_non_canonical_json() {
// Build a valid signature but with extra whitespace in JSON
let secret = "0000000000000000000000000000000000000000000000000000000000000003";
let keypair = Keypair::from_seckey_str(&SECP256K1, secret).unwrap();
let (xonly, _) = keypair.x_only_public_key();
let pk_hex = hex::encode(xonly.serialize());
let payload = test_payload();
let hash = compute_signing_hash(1700000000, None, &payload);
let message = Message::from_digest(hash);
let sig = SECP256K1.sign_schnorr(&message, &keypair);
let sig_hex = hex::encode(sig.serialize());
// Non-canonical: add a space after the opening brace
let json = [
r#"{ "v":1,"pk":""#,
&pk_hex,
r#"","sig":""#,
&sig_hex,
r#"","t":1700000000}"#,
]
.concat();
let armored = armor(json.as_bytes());
let result = verify_sig(&armored, &payload);
assert!(result.is_err());
}
#[test]
fn test_parse_envelope_rejects_invalid_oa_pubkey() {
// oa[0] is valid hex but not a valid BIP-340 point (all zeros)
let zero_pk = "0".repeat(64);
let fake_sig = "b".repeat(128);
let sig_field = "a".repeat(128);
let json = [
r#"{"v":1,"pk":""#,
TEST_PK,
r#"","sig":""#,
&sig_field,
r#"","t":1700000000,"oa":[""#,
&zero_pk,
r#"","",""#,
&fake_sig,
r#""]}"#,
]
.concat();
let result = parse_envelope(&json);
assert!(result.is_err());
assert!(result.unwrap_err().contains("BIP-340"));
}
#[test]
fn test_parse_envelope_rejects_self_attestation() {
let sig_field = "a".repeat(128);
let fake_sig = "b".repeat(128);
let json = [
r#"{"v":1,"pk":""#,
TEST_PK,
r#"","sig":""#,
&sig_field,
r#"","t":1700000000,"oa":[""#,
TEST_PK,
r#"","",""#,
&fake_sig,
r#""]}"#,
]
.concat();
let result = parse_envelope(&json);
assert!(result.is_err());
assert!(result.unwrap_err().contains("self-attestation"));
}
#[test]
fn test_extract_pk_best_effort_valid() {
let json = format!(r#"{{"v":1,"pk":"{}","sig":"x","t":0}}"#, TEST_PK);
assert_eq!(extract_pk_best_effort(&json), Some(TEST_PK.to_string()));
}
#[test]
fn test_extract_pk_best_effort_invalid() {
// Uppercase hex — should not match
let json =
r#"{"v":1,"pk":"F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9"}"#;
assert_eq!(extract_pk_best_effort(json), None);
}
#[test]
fn test_extract_pk_best_effort_multibyte() {
// Multi-byte UTF-8 near pk field — should not panic
let json = r#"{"pk":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé"}"#;
assert_eq!(extract_pk_best_effort(json), None);
}
#[test]
fn test_parse_armor_rejects_missing_newline() {
let input = "-----BEGIN SIGNED MESSAGE-----\nYWJj\n-----END SIGNED MESSAGE-----";
assert!(parse_armor(input).is_err());
}
#[test]
fn test_enforce_conditions() {
// Empty conditions — always ok
assert!(enforce_conditions("", 1000).is_ok());
// created_at< — timestamp must be strictly less than limit
assert!(enforce_conditions("created_at<2000", 1999).is_ok());
assert!(enforce_conditions("created_at<2000", 2000).is_err());
assert!(enforce_conditions("created_at<2000", 2001).is_err());
// created_at> — timestamp must be strictly greater than limit
assert!(enforce_conditions("created_at>1000", 1001).is_ok());
assert!(enforce_conditions("created_at>1000", 1000).is_err());
assert!(enforce_conditions("created_at>1000", 999).is_err());
// Combined constraints
assert!(enforce_conditions("created_at>1000&created_at<2000", 1500).is_ok());
assert!(enforce_conditions("created_at>1000&created_at<2000", 500).is_err());
assert!(enforce_conditions("created_at>1000&created_at<2000", 2500).is_err());
// kind= clauses are skipped (not applicable in NIP-GS)
assert!(enforce_conditions("kind=9", 1000).is_ok());
assert!(enforce_conditions("kind=9&created_at<2000", 1999).is_ok());
assert!(enforce_conditions("kind=9&created_at<2000", 2001).is_err());
}
// ── PR-ported helpers ─────────────────────────────────────────────────
/// Wrapper for PR-ported tests: matches PR's is_lower_hex API
fn is_lower_hex(s: &str, len: usize) -> bool {
s.len() == len
&& s.bytes()
.all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
}
fn valid_pk() -> String {
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string()
}
fn valid_sig() -> String {
"a".repeat(128)
}
fn valid_envelope_json() -> String {
format!(
r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":1700000000}}"#,
pk = valid_pk(),
sig = valid_sig(),
)
}
/// Wrapper: parse_oa_tag for PR-ported tests
fn parse_oa_tag(json: &str) -> Result<(String, String, String), String> {
let v: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("invalid auth tag JSON: {e}"))?;
let arr = v.as_array().ok_or("auth tag must be a JSON array")?;
if arr.len() != 4 {
return Err(format!("auth tag must have 4 elements, got {}", arr.len()));
}
let label = arr[0].as_str().ok_or("element 0 must be a string")?;
if label != "auth" {
return Err(format!("first element must be \"auth\", got {label:?}"));
}
let owner = arr[1]
.as_str()
.ok_or("element 1 must be a string")?
.to_string();
let cond = arr[2]
.as_str()
.ok_or("element 2 must be a string")?
.to_string();
let sig = arr[3]
.as_str()
.ok_or("element 3 must be a string")?
.to_string();
if !is_lower_hex(&owner, 64) {
return Err("auth tag owner must be 64 lowercase hex chars".to_string());
}
PublicKey::from_hex(&owner)
.map_err(|e| format!("auth tag owner is not a valid BIP-340 key: {e}"))?;
if !is_lower_hex(&sig, 128) {
return Err("auth tag sig must be 128 lowercase hex chars".to_string());
}
if !cond.is_empty() && !validate_conditions(&cond) {
return Err(format!("invalid conditions: {cond}"));
}
Ok((owner, cond, sig))
}
// ── PR-ported: is_lower_hex ───────────────────────────────────────────
#[test]
fn test_lower_hex_valid() {
assert!(is_lower_hex("deadbeef", 8));
assert!(is_lower_hex(&"a".repeat(64), 64));
assert!(is_lower_hex(&"0123456789abcdef".repeat(4), 64));
}
#[test]
fn test_lower_hex_rejects_uppercase() {
assert!(!is_lower_hex("DEADBEEF", 8));
assert!(!is_lower_hex("DeadBeef", 8));
}
#[test]
fn test_lower_hex_rejects_wrong_length() {
assert!(!is_lower_hex("deadbeef", 7));
assert!(!is_lower_hex("deadbeef", 9));
assert!(!is_lower_hex("", 1));
}
#[test]
fn test_lower_hex_rejects_non_hex() {
assert!(!is_lower_hex("deadbeeg", 8));
assert!(!is_lower_hex("dead beef", 9));
}
// ── PR-ported: parse_armor edge cases ─────────────────────────────────
#[test]
fn test_armor_roundtrip() {
let b64 = "dGVzdA==";
let text = format!("{ARMOR_BEGIN}\n{b64}\n{ARMOR_END}\n");
let got = parse_armor(&text).unwrap();
assert_eq!(got, b64);
}
#[test]
fn test_armor_no_trailing_newline() {
let b64 = "dGVzdA==";
let text = format!("{ARMOR_BEGIN}\n{b64}\n{ARMOR_END}");
// Hardened parse_armor requires trailing newline per NIP-GS spec.
assert!(parse_armor(&text).is_err());
}
#[test]
fn test_armor_rejects_crlf() {
let text = format!("{ARMOR_BEGIN}\r\ndGVzdA==\r\n{ARMOR_END}\r\n");
assert!(parse_armor(&text).is_err());
}
#[test]
fn test_armor_rejects_wrong_begin() {
let text = format!("-----BEGIN SOMETHING-----\ndGVzdA==\n{ARMOR_END}\n");
assert!(parse_armor(&text).is_err());
}
#[test]
fn test_armor_rejects_wrong_end() {
let text = format!("{ARMOR_BEGIN}\ndGVzdA==\n-----END SOMETHING-----\n");
assert!(parse_armor(&text).is_err());
}
#[test]
fn test_armor_rejects_trailing_whitespace_on_b64() {
let text = format!("{ARMOR_BEGIN}\ndGVzdA== \n{ARMOR_END}\n");
assert!(parse_armor(&text).is_err());
}
#[test]
fn test_armor_rejects_oversized_b64() {
// parse_armor is a structural parser only; the MAX_BASE64_LINE size
// check is enforced in the verify path after parse_armor returns.
// This test confirms that an oversized b64 line is accepted by
// parse_armor but would be caught downstream.
let big = "A".repeat(MAX_BASE64_LINE + 1);
let text = format!("{ARMOR_BEGIN}\n{big}\n{ARMOR_END}\n");
let result = parse_armor(&text);
// parse_armor itself accepts it (structural check only)
assert!(result.is_ok());
// The returned slice must equal the oversized string
assert_eq!(result.unwrap().len(), MAX_BASE64_LINE + 1);
}
// ── PR-ported: parse_envelope edge cases ──────────────────────────────
#[test]
fn test_envelope_valid_minimal() {
let env = parse_envelope(&valid_envelope_json()).unwrap();
assert_eq!(env.pk, valid_pk());
assert_eq!(env.t, 1700000000);
assert!(env.oa.is_none());
}
#[test]
fn test_envelope_rejects_missing_v() {
let json = format!(
r#"{{"pk":"{pk}","sig":"{sig}","t":1700000000}}"#,
pk = valid_pk(),
sig = valid_sig(),
);
assert!(parse_envelope(&json).is_err());
}
#[test]
fn test_envelope_rejects_v_not_1() {
let json = format!(
r#"{{"v":2,"pk":"{pk}","sig":"{sig}","t":1700000000}}"#,
pk = valid_pk(),
sig = valid_sig(),
);
assert!(parse_envelope(&json).is_err());
}
#[test]
fn test_envelope_rejects_unknown_field() {
let json = format!(
r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":1700000000,"extra":"bad"}}"#,
pk = valid_pk(),
sig = valid_sig(),
);
assert!(parse_envelope(&json).is_err());
}
#[test]
fn test_envelope_rejects_uppercase_pk() {
let pk_upper = valid_pk().to_uppercase();
let json = format!(
r#"{{"v":1,"pk":"{pk_upper}","sig":"{sig}","t":1700000000}}"#,
sig = valid_sig(),
);
assert!(parse_envelope(&json).is_err());
}
#[test]
fn test_envelope_rejects_t_as_float() {
let json = format!(
r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":1700000000.0}}"#,
pk = valid_pk(),
sig = valid_sig(),
);
assert!(parse_envelope(&json).is_err());
}
#[test]
fn test_envelope_rejects_t_out_of_range() {
let json = format!(
r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":9999999999}}"#,
pk = valid_pk(),
sig = valid_sig(),
);
assert!(parse_envelope(&json).is_err());
}
#[test]
fn test_envelope_rejects_sig_wrong_length() {
let json = format!(
r#"{{"v":1,"pk":"{pk}","sig":"aabbcc","t":1700000000}}"#,
pk = valid_pk(),
);
assert!(parse_envelope(&json).is_err());
}
// ── PR-ported: canonical JSON roundtrip ───────────────────────────────
#[test]
fn test_canonical_json_roundtrip() {
let json = valid_envelope_json();
let env = parse_envelope(&json).unwrap();
let rebuilt = build_envelope(&env.pk, &env.sig, env.t, env.oa.as_ref());
assert_eq!(rebuilt.as_bytes(), json.as_bytes());
}
// ── PR-ported: timestamp_to_date (was format_date) ────────────────────
#[test]
fn test_format_date_epoch() {
assert_eq!(timestamp_to_date(0), "1970-01-01");
}
#[test]
fn test_format_date_known() {
assert_eq!(timestamp_to_date(1704067200), "2024-01-01");
}
#[test]
fn test_format_date_leap_day() {
assert_eq!(timestamp_to_date(1709164800), "2024-02-29");
}
// ── PR-ported: parse_oa_tag ───────────────────────────────────────────
#[test]
fn test_oa_tag_rejects_invalid_owner_hex() {
let json = r#"["auth","ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ","read","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]"#;
assert!(parse_oa_tag(json).is_err());
}
#[test]
fn test_oa_tag_rejects_invalid_sig_hex() {
let json = format!(r#"["auth","{}","read","ZZZZ"]"#, valid_pk());
assert!(parse_oa_tag(&json).is_err());
}
#[test]
fn test_oa_tag_rejects_dangerous_conditions() {
let json = format!(
r#"["auth","{}","read; rm -rf /","{}"]"#,
valid_pk(),
valid_sig()
);
assert!(parse_oa_tag(&json).is_err());
}
#[test]
fn test_oa_tag_rejects_wrong_label() {
let json = format!(r#"["bad","{}","read","{}"]"#, valid_pk(), valid_sig());
assert!(parse_oa_tag(&json).is_err());
}
// ── PR-ported: compute_signing_hash (was signing_message) ─────────────
#[test]
fn test_signing_hash_deterministic() {
let payload = b"hello world";
let m1 = compute_signing_hash(1700000000, None, payload);
let m2 = compute_signing_hash(1700000000, None, payload);
assert_eq!(m1, m2);
}
#[test]
fn test_signing_hash_differs_by_timestamp() {
let payload = b"hello";
let m1 = compute_signing_hash(1, None, payload);
let m2 = compute_signing_hash(2, None, payload);
assert_ne!(m1, m2);
}
#[test]
fn test_signing_hash_differs_with_oa() {
let payload = b"hello";
let oa = (valid_pk(), "read".to_string(), valid_sig());
let m_no_oa = compute_signing_hash(1, None, payload);
let m_with_oa = compute_signing_hash(1, Some(&oa), payload);
assert_ne!(m_no_oa, m_with_oa);
}
}