mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
2545 lines
95 KiB
Rust
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);
|
|
}
|
|
}
|