Merge #cd4d6f74: feat(grasp-audit): support explicit audit identities

nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqsv6nt0ws0mrn9utmr9atvgvcpp0qq4nk66mqujwlsvz83nd7trl4cmtj3nc

PR-Author: DanConwayDev's Agent
nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0

PR description:

Use the same identity inputs for probes and full audits: inline --nsec, --nsec-file, or GRASP_AUDIT_NSEC. Parse nsec and hex keys, retain generated-key fallback, and share the environment name with Git subprocess secret scrubbing.
This commit is contained in:
DanConwayDev
2026-08-05 11:10:17 +01:00
4 changed files with 207 additions and 19 deletions
+20
View File
@@ -82,6 +82,26 @@ grasp-audit audit --relay ws://localhost:7334 --spec nip01-smoke
grasp-audit audit --relay ws://localhost:7334 --mode isolated --spec push-auth
```
### Audit identity
Probe write checks and full audits can use an explicit identity. Key sources
are checked in this order:
1. `--nsec <nsec-or-hex>`
2. `--nsec-file <path>`
3. `GRASP_AUDIT_NSEC`
`--nsec` is convenient for disposable keys, but command arguments may be
visible to other local users. Prefer `--nsec-file` or `GRASP_AUDIT_NSEC` for
scheduled jobs. The two command-line options cannot be used together. If no
source is supplied, the tool generates a fresh key as before.
```bash
grasp-audit probe --relay wss://relay.ngit.dev --create-repo --nsec nsec1...
grasp-audit audit --relay wss://relay.ngit.dev --nsec-file /run/credentials/audit_nsec
GRASP_AUDIT_NSEC=nsec1... grasp-audit audit --relay wss://relay.ngit.dev
```
### As a Library (Audit)
```rust
+182 -17
View File
@@ -1,7 +1,8 @@
//! GRASP Audit CLI Tool
use clap::{CommandFactory, Parser, Subcommand};
use clap::{Args, CommandFactory, Parser, Subcommand};
use grasp_audit::*;
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::Duration;
@@ -13,6 +14,18 @@ struct Cli {
command: Commands,
}
#[derive(Debug, Clone, Args)]
struct KeyArgs {
/// Secret key in nsec or hex format. Convenient for disposable keys, but
/// visible in the process argument list; prefer --nsec-file or GRASP_AUDIT_NSEC.
#[arg(long, conflicts_with = "nsec_file")]
nsec: Option<String>,
/// Read a secret key in nsec or hex format from this file.
#[arg(long, value_name = "PATH", conflicts_with = "nsec")]
nsec_file: Option<PathBuf>,
}
#[derive(Subcommand)]
enum Commands {
/// Run a probe/smoke test against a server
@@ -33,9 +46,8 @@ enum Commands {
#[arg(long)]
watch: Option<u64>,
/// Secret key in nsec bech32 format (for whitelisted relays)
#[arg(long)]
nsec: Option<String>,
#[command(flatten)]
keys: KeyArgs,
/// Create a test repo on the relay to verify the full write path
/// (publish events, git push, verify refs match state).
@@ -64,6 +76,9 @@ enum Commands {
/// Git data directory (required for cors, git-clone, push-auth, repo-creation specs)
#[arg(short, long)]
git_data_dir: Option<PathBuf>,
#[command(flatten)]
keys: KeyArgs,
},
}
@@ -91,7 +106,7 @@ async fn main() -> Result<()> {
json,
timeout,
watch,
nsec,
keys,
create_repo,
} => {
let relay = match relay {
@@ -105,15 +120,7 @@ async fn main() -> Result<()> {
}
};
// Parse nsec if provided
let keys = if let Some(nsec_str) = nsec {
use nostr_sdk::prelude::SecretKey;
let sk = SecretKey::from_bech32(&nsec_str)
.map_err(|e| anyhow!("Invalid nsec: {}", e))?;
Some(Keys::new(sk))
} else {
None
};
let keys = load_keys(&keys)?;
// read_only is the default; --create-repo opts into the write path
let read_only = !create_repo;
@@ -167,7 +174,9 @@ async fn main() -> Result<()> {
mode,
spec,
git_data_dir,
keys,
} => {
let keys = load_keys(&keys)?;
let mut config = match mode.as_str() {
"shared" => AuditConfig::shared(),
"isolated" => AuditConfig::isolated(),
@@ -205,9 +214,11 @@ async fn main() -> Result<()> {
println!();
println!("Connecting to relay...");
let client = AuditClient::new(&relay, config)
.await
.map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
let client = match keys {
Some(keys) => AuditClient::new_with_keys(&relay, config, keys).await,
None => AuditClient::new(&relay, config).await,
}
.map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
if !client.is_connected().await {
return Err(anyhow!("Could not establish connection to relay"));
@@ -363,3 +374,157 @@ async fn main() -> Result<()> {
Ok(())
}
fn load_keys(args: &KeyArgs) -> Result<Option<Keys>> {
load_keys_with_env(args, std::env::var_os(GRASP_AUDIT_NSEC_ENV))
}
fn load_keys_with_env(args: &KeyArgs, env_value: Option<OsString>) -> Result<Option<Keys>> {
let value = if let Some(value) = &args.nsec {
Some((value.clone(), "--nsec".to_string()))
} else if let Some(path) = &args.nsec_file {
Some((
std::fs::read_to_string(path)
.map_err(|error| anyhow!("Failed to read {}: {error}", path.display()))?,
format!("--nsec-file {}", path.display()),
))
} else if let Some(value) = env_value {
Some((
value.into_string().map_err(|_| {
anyhow!("Secret key from {GRASP_AUDIT_NSEC_ENV} is not valid UTF-8")
})?,
GRASP_AUDIT_NSEC_ENV.to_string(),
))
} else {
None
};
let Some((value, source)) = value else {
return Ok(None);
};
let value = value.trim();
if value.is_empty() {
return Err(anyhow!("Secret key source {source} is empty"));
}
Keys::parse(value)
.map(Some)
.map_err(|error| anyhow!("Invalid secret key from {source}: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn key_args(nsec: Option<String>, nsec_file: Option<PathBuf>) -> KeyArgs {
KeyArgs { nsec, nsec_file }
}
fn nsec(keys: &Keys) -> String {
keys.secret_key().to_bech32().unwrap()
}
#[test]
fn inline_key_takes_precedence_over_environment() {
let inline = Keys::generate();
let environment = Keys::generate();
let loaded = load_keys_with_env(
&key_args(Some(format!(" {}\n", nsec(&inline))), None),
Some(nsec(&environment).into()),
)
.unwrap()
.unwrap();
assert_eq!(loaded.public_key(), inline.public_key());
}
#[test]
fn file_key_takes_precedence_over_environment_and_is_trimmed() {
let file_keys = Keys::generate();
let environment = Keys::generate();
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(file.path(), format!("\n {} \n", nsec(&file_keys))).unwrap();
let loaded = load_keys_with_env(
&key_args(None, Some(file.path().to_path_buf())),
Some(nsec(&environment).into()),
)
.unwrap()
.unwrap();
assert_eq!(loaded.public_key(), file_keys.public_key());
}
#[test]
fn environment_key_is_used_and_trimmed() {
let environment = Keys::generate();
let loaded = load_keys_with_env(
&key_args(None, None),
Some(format!("\t{}\n", nsec(&environment)).into()),
)
.unwrap()
.unwrap();
assert_eq!(loaded.public_key(), environment.public_key());
}
#[test]
fn hex_keys_are_supported() {
let expected = Keys::generate();
let hex = expected.secret_key().to_secret_hex();
let loaded = load_keys_with_env(&key_args(Some(hex), None), None)
.unwrap()
.unwrap();
assert_eq!(loaded.public_key(), expected.public_key());
}
#[test]
fn no_key_source_preserves_generated_key_behavior() {
assert!(load_keys_with_env(&key_args(None, None), None)
.unwrap()
.is_none());
}
#[test]
fn empty_and_invalid_key_sources_are_rejected() {
let empty = load_keys_with_env(&key_args(Some(" \n".to_string()), None), None).unwrap_err();
assert!(empty.to_string().contains("--nsec is empty"));
let invalid =
load_keys_with_env(&key_args(None, None), Some("not-a-key".into())).unwrap_err();
assert!(invalid
.to_string()
.contains("Invalid secret key from GRASP_AUDIT_NSEC"));
}
#[test]
fn empty_key_file_is_rejected_with_its_source() {
let file = tempfile::NamedTempFile::new().unwrap();
let error =
load_keys_with_env(&key_args(None, Some(file.path().to_path_buf())), None).unwrap_err();
assert!(error.to_string().contains("--nsec-file"));
assert!(error.to_string().contains("is empty"));
}
#[test]
fn both_subcommands_expose_key_options_and_reject_cli_conflicts() {
for subcommand in ["probe", "audit"] {
let parsed = Cli::try_parse_from([
"grasp-audit",
subcommand,
"--relay",
"wss://example.com",
"--nsec",
"secret",
"--nsec-file",
"/tmp/secret",
]);
assert!(
parsed.is_err(),
"{subcommand} accepted conflicting key sources"
);
}
}
}
+4 -1
View File
@@ -8,10 +8,13 @@
use std::process::Command;
/// Environment variable used to supply an audit signing key.
pub const GRASP_AUDIT_NSEC_ENV: &str = "GRASP_AUDIT_NSEC";
/// Signing secrets that audit processes may legitimately hold but git never
/// needs. Removing them here also removes them from hooks, credential helpers,
/// remote helpers, and any other process git starts.
const SIGNING_SECRET_ENV_VARS: [&str; 2] = ["GRASP_AUDIT_NSEC", "NGIT_RELAY_OWNER_NSEC"];
const SIGNING_SECRET_ENV_VARS: [&str; 2] = [GRASP_AUDIT_NSEC_ENV, "NGIT_RELAY_OWNER_NSEC"];
/// Build a `git` [`Command`] that is hermetic with respect to ambient git
/// configuration.
+1 -1
View File
@@ -63,7 +63,7 @@ pub use fixtures::{
PR_TEST_COMMIT_HASH,
RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH,
};
pub use git::git_command;
pub use git::{git_command, GRASP_AUDIT_NSEC_ENV};
pub use probe::{run_probe, ProbeCheck, ProbeReport};
pub use result::{AuditResult, TestResult};