mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-09-14 04:45:07 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user