From e31c1c265e4223ffaeffda096ccec50b66751e77 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 4 Apr 2026 13:53:20 +0000 Subject: [PATCH 1/4] feat(network): add transparent request signing (bot-auth) Implement Ed25519 request signing per RFC 9421 / web-bot-auth profile, matching the toolkit library contract section 9 and fetchkit's reference implementation. - Feature-gated behind `bot-auth` cargo feature (implies `http_client`) - BotAuthConfig: from_seed, from_base64_seed, agent_fqdn, validity_secs - Transparent: all outbound HTTP requests signed automatically - Non-blocking: signing failures never prevent requests - derive_bot_auth_public_key() for consumer key directory serving - JWK Thumbprint (RFC 7638) as key identity - 10 unit tests covering signing, verification, key derivation Closes #1032 --- AGENTS.md | 1 + Cargo.toml | 4 + crates/bashkit/Cargo.toml | 6 + crates/bashkit/src/lib.rs | 39 +++ crates/bashkit/src/network/bot_auth.rs | 327 +++++++++++++++++++++++++ crates/bashkit/src/network/client.rs | 88 ++++++- crates/bashkit/src/network/mod.rs | 6 + specs/017-request-signing.md | 141 +++++++++++ 8 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 crates/bashkit/src/network/bot_auth.rs create mode 100644 specs/017-request-signing.md diff --git a/AGENTS.md b/AGENTS.md index 03415c11..6c0de63a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn | 013-python-package | Python package, PyPI wheels, platform matrix | | 014-scripted-tool-orchestration | Compose ToolDef+callback pairs into OrchestratorTool via bash scripts | | 016-zapcode-runtime | Embedded TypeScript via ZapCode, VFS bridging, resource limits | +| 017-request-signing | Transparent Ed25519 request signing (bot-auth) per RFC 9421 | ### Documentation diff --git a/Cargo.toml b/Cargo.toml index 55aeeb19..2d9166ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,10 @@ md-5 = "0.11" sha1 = "0.11" sha2 = "0.11" +# Ed25519 signing (bot-auth request signing) +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" + # CLI clap = { version = "4", features = ["derive"] } diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index 4b9ba8d9..e05a79c9 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -58,6 +58,10 @@ flate2 = { workspace = true } # Base64 encoding (for base64 builtin and HTTP basic auth) base64 = { workspace = true } +# Ed25519 signing for bot-auth request signing (optional) +ed25519-dalek = { workspace = true, optional = true } +rand = { workspace = true, optional = true } + # Checksums (for md5sum, sha1sum, sha256sum builtins) md-5 = { workspace = true } sha1 = { workspace = true } @@ -75,6 +79,8 @@ zapcode-core = { version = "1.5", optional = true } [features] default = [] http_client = ["reqwest"] +# Enable Ed25519 request signing per RFC 9421 / web-bot-auth profile +bot-auth = ["http_client", "dep:ed25519-dalek", "dep:rand"] # Enable fail points for security/fault injection testing # Usage: FAILPOINTS="fail_point_name=action" cargo test --features failpoints failpoints = ["fail/failpoints"] diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 38fd8e36..b18017cd 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -460,6 +460,9 @@ pub use network::{HttpClient, HttpHandler}; #[cfg(feature = "http_client")] pub use network::Response as HttpResponse; +#[cfg(feature = "bot-auth")] +pub use network::{BotAuthConfig, BotAuthError, BotAuthPublicKey, derive_bot_auth_public_key}; + #[cfg(feature = "git")] pub use git::GitClient; @@ -1027,6 +1030,9 @@ pub struct BashBuilder { /// Custom HTTP handler for request interception #[cfg(feature = "http_client")] http_handler: Option>, + /// Bot-auth config for transparent request signing + #[cfg(feature = "bot-auth")] + bot_auth_config: Option, /// Logging configuration #[cfg(feature = "logging")] log_config: Option, @@ -1239,6 +1245,32 @@ impl BashBuilder { self } + /// Enable transparent request signing for all outbound HTTP requests. + /// + /// When configured, every HTTP request made by curl/wget/http builtins + /// is signed with Ed25519 per RFC 9421 / web-bot-auth profile. No CLI + /// arguments or script changes needed — signing is fully transparent. + /// + /// Signing failures are non-blocking: the request is sent unsigned. + /// + /// # Example + /// + /// ```rust,ignore + /// use bashkit::{Bash, NetworkAllowlist}; + /// use bashkit::network::BotAuthConfig; + /// + /// let bash = Bash::builder() + /// .network(NetworkAllowlist::new().allow("https://api.example.com")) + /// .bot_auth(BotAuthConfig::from_seed([42u8; 32]) + /// .with_agent_fqdn("bot.example.com")) + /// .build(); + /// ``` + #[cfg(feature = "bot-auth")] + pub fn bot_auth(mut self, config: network::BotAuthConfig) -> Self { + self.bot_auth_config = Some(config); + self + } + /// Configure logging behavior. /// /// When the `logging` feature is enabled, Bashkit can emit structured logs @@ -1850,6 +1882,8 @@ impl BashBuilder { self.network_allowlist, #[cfg(feature = "http_client")] self.http_handler, + #[cfg(feature = "bot-auth")] + self.bot_auth_config, #[cfg(feature = "logging")] self.log_config, #[cfg(feature = "git")] @@ -1936,6 +1970,7 @@ impl BashBuilder { history_file: Option, #[cfg(feature = "http_client")] network_allowlist: Option, #[cfg(feature = "http_client")] http_handler: Option>, + #[cfg(feature = "bot-auth")] bot_auth_config: Option, #[cfg(feature = "logging")] log_config: Option, #[cfg(feature = "git")] git_config: Option, ) -> Bash { @@ -1984,6 +2019,10 @@ impl BashBuilder { if let Some(handler) = http_handler { client.set_handler(handler); } + #[cfg(feature = "bot-auth")] + if let Some(bot_auth) = bot_auth_config { + client.set_bot_auth(bot_auth); + } interpreter.set_http_client(client); } diff --git a/crates/bashkit/src/network/bot_auth.rs b/crates/bashkit/src/network/bot_auth.rs new file mode 100644 index 00000000..e4ec9dee --- /dev/null +++ b/crates/bashkit/src/network/bot_auth.rs @@ -0,0 +1,327 @@ +// Decision: Implement draft-meunier-web-bot-auth-architecture using Ed25519 signatures +// over RFC 9421 HTTP Message Signatures. Sign @authority as the primary covered component. +// Feature-gated behind `bot-auth` to avoid pulling crypto deps by default. +// Non-blocking: signing failures log a warning and send the request unsigned. + +//! Web Bot Authentication support (draft-meunier-web-bot-auth-architecture). +//! +//! Signs outgoing HTTP requests with Ed25519 signatures per RFC 9421, +//! enabling origins to verify bot identity cryptographically. +//! +//! # Quick Start +//! +//! ```rust,ignore +//! use bashkit::network::BotAuthConfig; +//! +//! let config = BotAuthConfig::from_seed([42u8; 32]) +//! .with_agent_fqdn("bot.example.com") +//! .with_validity_secs(300); +//! ``` + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Configuration for Web Bot Authentication. +/// +/// Holds an Ed25519 signing key and optional metadata for the +/// `Signature-Agent` discovery header. +#[derive(Debug, Clone)] +pub struct BotAuthConfig { + signing_key: SigningKey, + agent_fqdn: Option, + validity_secs: u64, +} + +impl BotAuthConfig { + /// Create from a 32-byte Ed25519 secret key seed. + pub fn from_seed(seed: [u8; 32]) -> Self { + Self { + signing_key: SigningKey::from_bytes(&seed), + agent_fqdn: None, + validity_secs: 300, + } + } + + /// Create from a base64url-encoded Ed25519 secret key seed. + pub fn from_base64_seed(encoded: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| BotAuthError::InvalidKey("invalid base64url encoding"))?; + let seed: [u8; 32] = bytes + .try_into() + .map_err(|_| BotAuthError::InvalidKey("seed must be exactly 32 bytes"))?; + Ok(Self::from_seed(seed)) + } + + /// Set the agent FQDN for key discovery (`Signature-Agent` header). + pub fn with_agent_fqdn(mut self, fqdn: impl Into) -> Self { + self.agent_fqdn = Some(fqdn.into()); + self + } + + /// Set signature validity duration in seconds (default: 300). + pub fn with_validity_secs(mut self, secs: u64) -> Self { + self.validity_secs = secs; + self + } + + /// Compute the JWK Thumbprint (RFC 7638) keyid for the public key. + pub fn keyid(&self) -> String { + jwk_thumbprint_ed25519(&self.signing_key.verifying_key()) + } + + /// Sign a request targeting the given authority and return headers to attach. + /// + /// Returns `Err` on clock errors; callers should log and send unsigned. + pub(crate) fn sign_request(&self, authority: &str) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| BotAuthError::Clock)? + .as_secs(); + let expires = now + self.validity_secs; + let keyid = self.keyid(); + let nonce = generate_nonce(); + + // Build covered components list + let mut covered = String::from("\"@authority\""); + if self.agent_fqdn.is_some() { + covered.push_str(" \"signature-agent\""); + } + + // Signature parameters (without label, for @signature-params line) + let sig_params = format!( + "({covered});created={now};expires={expires};\ + keyid=\"{keyid}\";alg=\"ed25519\";nonce=\"{nonce}\";\ + tag=\"web-bot-auth\"" + ); + + // Build signature base per RFC 9421 Section 2.5 + let mut sig_base = format!("\"@authority\": {authority}\n"); + if let Some(ref fqdn) = self.agent_fqdn { + sig_base.push_str(&format!("\"signature-agent\": {fqdn}\n")); + } + sig_base.push_str(&format!("\"@signature-params\": {sig_params}")); + + // Sign + let signature = self.signing_key.sign(sig_base.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + Ok(BotAuthHeaders { + signature: format!("sig=:{sig_b64}:"), + signature_input: format!("sig={sig_params}"), + signature_agent: self.agent_fqdn.clone(), + }) + } +} + +/// Headers produced by bot-auth signing. Applied to outbound HTTP requests. +#[derive(Debug)] +pub(crate) struct BotAuthHeaders { + pub signature: String, + pub signature_input: String, + pub signature_agent: Option, +} + +/// Errors from bot-auth operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BotAuthError { + /// The provided key material is invalid. + InvalidKey(&'static str), + /// System clock returned a time before the Unix epoch. + Clock, +} + +impl std::fmt::Display for BotAuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BotAuthError::InvalidKey(msg) => write!(f, "invalid bot-auth key: {msg}"), + BotAuthError::Clock => write!(f, "system clock error"), + } + } +} + +impl std::error::Error for BotAuthError {} + +/// Derived Ed25519 public key and JWK Thumbprint for key directory serving. +pub struct BotAuthPublicKey { + /// JWK Thumbprint (RFC 7638) — used as `keyid` in signatures. + pub key_id: String, + /// Full JWK object (OKP/Ed25519) for inclusion in JWKS responses. + pub jwk: serde_json::Value, +} + +/// Derive the Ed25519 public key and JWK Thumbprint from a base64url seed. +/// +/// The consumer uses the returned key to serve the well-known key directory +/// endpoint so target servers can verify signatures. +pub fn derive_bot_auth_public_key(seed: &str) -> Result { + let config = BotAuthConfig::from_base64_seed(seed)?; + let verifying_key = config.signing_key.verifying_key(); + let x = URL_SAFE_NO_PAD.encode(verifying_key.as_bytes()); + let key_id = jwk_thumbprint_ed25519(&verifying_key); + let jwk = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": x, + }); + Ok(BotAuthPublicKey { key_id, jwk }) +} + +/// Compute JWK Thumbprint (RFC 7638) for an Ed25519 key (RFC 8037). +/// +/// Members in lexicographic order: `crv`, `kty`, `x`. +fn jwk_thumbprint_ed25519(key: &VerifyingKey) -> String { + let x = URL_SAFE_NO_PAD.encode(key.as_bytes()); + let jwk_json = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{x}"}}"#); + let hash = Sha256::digest(jwk_json.as_bytes()); + URL_SAFE_NO_PAD.encode(hash) +} + +/// Generate a cryptographically random nonce (32 bytes, base64url-encoded). +fn generate_nonce() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::Verifier; + + #[test] + fn from_seed_roundtrip() { + let seed = [1u8; 32]; + let config = BotAuthConfig::from_seed(seed); + let keyid = config.keyid(); + assert!(!keyid.is_empty()); + } + + #[test] + fn from_base64_seed() { + let seed = [2u8; 32]; + let encoded = URL_SAFE_NO_PAD.encode(seed); + let config = BotAuthConfig::from_base64_seed(&encoded).unwrap(); + assert_eq!(config.keyid(), BotAuthConfig::from_seed(seed).keyid()); + } + + #[test] + fn from_base64_seed_invalid() { + assert!(BotAuthConfig::from_base64_seed("!!!invalid!!!").is_err()); + let short = URL_SAFE_NO_PAD.encode([0u8; 16]); + assert!(BotAuthConfig::from_base64_seed(&short).is_err()); + } + + #[test] + fn sign_request_produces_valid_headers() { + let config = BotAuthConfig::from_seed([3u8; 32]); + let headers = config.sign_request("example.com").unwrap(); + + assert!(headers.signature.starts_with("sig=:")); + assert!(headers.signature.ends_with(':')); + assert!(headers.signature_input.starts_with("sig=(")); + assert!(headers.signature_input.contains("tag=\"web-bot-auth\"")); + assert!(headers.signature_input.contains("alg=\"ed25519\"")); + assert!(headers.signature_input.contains("keyid=")); + assert!(headers.signature_input.contains("nonce=")); + assert!(headers.signature_agent.is_none()); + } + + #[test] + fn sign_request_with_agent_fqdn() { + let config = BotAuthConfig::from_seed([4u8; 32]).with_agent_fqdn("bot.example.com"); + let headers = config.sign_request("example.com").unwrap(); + + assert_eq!(headers.signature_agent.as_deref(), Some("bot.example.com")); + assert!(headers.signature_input.contains("\"signature-agent\"")); + } + + #[test] + fn signature_is_verifiable() { + let seed = [5u8; 32]; + let config = BotAuthConfig::from_seed(seed); + let signing_key = SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + + let headers = config.sign_request("verify.example.com").unwrap(); + + // Reconstruct signature base + let sig_params = headers.signature_input.strip_prefix("sig=").unwrap(); + let sig_base = + format!("\"@authority\": verify.example.com\n\"@signature-params\": {sig_params}"); + + // Extract raw signature bytes + let sig_b64 = headers + .signature + .strip_prefix("sig=:") + .unwrap() + .strip_suffix(':') + .unwrap(); + let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).unwrap(); + let signature = ed25519_dalek::Signature::from_slice(&sig_bytes).unwrap(); + + assert!( + verifying_key + .verify(sig_base.as_bytes(), &signature) + .is_ok() + ); + } + + #[test] + fn jwk_thumbprint_deterministic() { + let key = SigningKey::from_bytes(&[6u8; 32]).verifying_key(); + let t1 = jwk_thumbprint_ed25519(&key); + let t2 = jwk_thumbprint_ed25519(&key); + assert_eq!(t1, t2); + assert!(!t1.is_empty()); + } + + #[test] + fn validity_secs_respected() { + let config = BotAuthConfig::from_seed([7u8; 32]).with_validity_secs(600); + let headers = config.sign_request("example.com").unwrap(); + let input = &headers.signature_input; + let created: u64 = input + .split("created=") + .nth(1) + .unwrap() + .split(';') + .next() + .unwrap() + .parse() + .unwrap(); + let expires: u64 = input + .split("expires=") + .nth(1) + .unwrap() + .split(';') + .next() + .unwrap() + .parse() + .unwrap(); + assert_eq!(expires - created, 600); + } + + #[test] + fn derive_public_key() { + let seed = [8u8; 32]; + let encoded = URL_SAFE_NO_PAD.encode(seed); + let pubkey = derive_bot_auth_public_key(&encoded).unwrap(); + assert!(!pubkey.key_id.is_empty()); + assert_eq!(pubkey.jwk["kty"], "OKP"); + assert_eq!(pubkey.jwk["crv"], "Ed25519"); + assert!(pubkey.jwk["x"].is_string()); + } + + #[test] + fn derive_public_key_matches_config_keyid() { + let seed = [9u8; 32]; + let encoded = URL_SAFE_NO_PAD.encode(seed); + let pubkey = derive_bot_auth_public_key(&encoded).unwrap(); + let config = BotAuthConfig::from_seed(seed); + assert_eq!(pubkey.key_id, config.keyid()); + } +} diff --git a/crates/bashkit/src/network/client.rs b/crates/bashkit/src/network/client.rs index fe235af7..d7b442fd 100644 --- a/crates/bashkit/src/network/client.rs +++ b/crates/bashkit/src/network/client.rs @@ -75,6 +75,9 @@ pub struct HttpClient { max_response_bytes: usize, /// Optional custom HTTP handler for request interception handler: Option>, + /// Optional bot-auth config for transparent request signing + #[cfg(feature = "bot-auth")] + bot_auth: Option, } /// HTTP request method @@ -162,6 +165,8 @@ impl HttpClient { default_timeout: timeout, max_response_bytes, handler: None, + #[cfg(feature = "bot-auth")] + bot_auth: None, } } @@ -174,6 +179,47 @@ impl HttpClient { self.handler = Some(handler); } + /// Enable bot-auth request signing. + /// + /// When set, all outbound HTTP requests are transparently signed with + /// Ed25519 per RFC 9421 / web-bot-auth profile. No CLI arguments needed. + /// Signing failures are non-blocking — the request is sent unsigned. + #[cfg(feature = "bot-auth")] + pub fn set_bot_auth(&mut self, config: super::bot_auth::BotAuthConfig) { + self.bot_auth = Some(config); + } + + /// Produce bot-auth signing headers for the given URL. + /// Non-blocking: signing failures return an empty vec (request sent unsigned). + #[cfg(feature = "bot-auth")] + fn bot_auth_headers(&self, url: &str) -> Vec<(String, String)> { + let Some(ref bot_auth) = self.bot_auth else { + return Vec::new(); + }; + let Ok(parsed) = url::Url::parse(url) else { + return Vec::new(); + }; + let Some(authority) = parsed.host_str() else { + return Vec::new(); + }; + match bot_auth.sign_request(authority) { + Ok(headers) => { + let mut result = vec![ + ("signature".to_string(), headers.signature), + ("signature-input".to_string(), headers.signature_input), + ]; + if let Some(fqdn) = headers.signature_agent { + result.push(("signature-agent".to_string(), fqdn)); + } + result + } + Err(_e) => { + // Non-blocking: signing failure must not prevent the request + Vec::new() + } + } + } + fn client(&self) -> Result<&Client> { let client = self .client @@ -238,6 +284,12 @@ impl HttpClient { } } + // Compute bot-auth signing headers (transparent, non-blocking) + #[cfg(feature = "bot-auth")] + let signing_headers = self.bot_auth_headers(url); + #[cfg(not(feature = "bot-auth"))] + let signing_headers: Vec<(String, String)> = Vec::new(); + // Delegate to custom handler if set if let Some(handler) = &self.handler { let method_str = match method { @@ -248,8 +300,16 @@ impl HttpClient { Method::Head => "HEAD", Method::Patch => "PATCH", }; + if signing_headers.is_empty() { + return handler + .request(method_str, url, body, headers) + .await + .map_err(Error::Network); + } + let mut all_headers: Vec<(String, String)> = headers.to_vec(); + all_headers.extend(signing_headers); return handler - .request(method_str, url, body, headers) + .request(method_str, url, body, &all_headers) .await .map_err(Error::Network); } @@ -262,6 +322,11 @@ impl HttpClient { request = request.header(name.as_str(), value.as_str()); } + // Add bot-auth signing headers + for (name, value) in &signing_headers { + request = request.header(name.as_str(), value.as_str()); + } + if let Some(body_data) = body { request = request.body(body_data.to_vec()); } @@ -406,6 +471,12 @@ impl HttpClient { } } + // Compute bot-auth signing headers (transparent, non-blocking) + #[cfg(feature = "bot-auth")] + let signing_headers = self.bot_auth_headers(url); + #[cfg(not(feature = "bot-auth"))] + let signing_headers: Vec<(String, String)> = Vec::new(); + // Delegate to custom handler if set (timeouts are the handler's responsibility) if let Some(handler) = &self.handler { let method_str = match method { @@ -416,8 +487,16 @@ impl HttpClient { Method::Head => "HEAD", Method::Patch => "PATCH", }; + if signing_headers.is_empty() { + return handler + .request(method_str, url, body, headers) + .await + .map_err(Error::Network); + } + let mut all_headers: Vec<(String, String)> = headers.to_vec(); + all_headers.extend(signing_headers); return handler - .request(method_str, url, body, headers) + .request(method_str, url, body, &all_headers) .await .map_err(Error::Network); } @@ -449,6 +528,11 @@ impl HttpClient { request = request.header(name.as_str(), value.as_str()); } + // Add bot-auth signing headers + for (name, value) in &signing_headers { + request = request.header(name.as_str(), value.as_str()); + } + if let Some(body_data) = body { request = request.body(body_data.to_vec()); } diff --git a/crates/bashkit/src/network/mod.rs b/crates/bashkit/src/network/mod.rs index 933afccc..b05cb431 100644 --- a/crates/bashkit/src/network/mod.rs +++ b/crates/bashkit/src/network/mod.rs @@ -79,11 +79,17 @@ mod allowlist; +#[cfg(feature = "bot-auth")] +pub mod bot_auth; + #[cfg(feature = "http_client")] mod client; #[allow(unused_imports)] // UrlMatch is used internally but may not be exported pub use allowlist::{NetworkAllowlist, UrlMatch}; +#[cfg(feature = "bot-auth")] +pub use bot_auth::{BotAuthConfig, BotAuthError, BotAuthPublicKey, derive_bot_auth_public_key}; + #[cfg(feature = "http_client")] pub use client::{HttpClient, HttpHandler, Method, Response}; diff --git a/specs/017-request-signing.md b/specs/017-request-signing.md new file mode 100644 index 00000000..3787ec1b --- /dev/null +++ b/specs/017-request-signing.md @@ -0,0 +1,141 @@ +# 017 — Transparent Request Signing (bot-auth) + +> Ed25519 request signing for all outbound HTTP requests per RFC 9421 / web-bot-auth profile. + +## Problem + +The [toolkit library contract](https://github.com/everruns/everruns/blob/main/specs/toolkit-library-contract.md) section 9 requires HTTP-capable kits to support Ed25519 request signing. bashkit has curl, wget, and http builtins that make outbound HTTP requests. Target servers need a way to verify bot identity cryptographically. + +## Design Decisions + +1. **Transparent** — signing happens inside `HttpClient`, before every outbound request. No CLI flags, no script changes. Scripts using `curl -s https://api.example.com` get signed requests automatically. + +2. **Feature-gated** — `bot-auth` cargo feature. When disabled, zero crypto dependencies compiled in. Implies `http_client`. + +3. **Non-blocking** — signing failures (clock errors, key issues) never block the request. The request is sent unsigned. This preserves tool availability. + +4. **Follows fetchkit** — same `BotAuthConfig` shape, same signing algorithm, same header format. Reference: `everruns/fetchkit/crates/fetchkit/src/bot_auth.rs`. + +## Architecture + +``` +BashBuilder::bot_auth(config) + │ + ▼ +HttpClient::set_bot_auth(config) + │ + ▼ (on every request, after allowlist check) +BotAuthConfig::sign_request(authority) + │ + ▼ +Signature + Signature-Input + Signature-Agent headers +``` + +Signing happens in `HttpClient` at the same layer as the allowlist check — both the default reqwest path and custom `HttpHandler` path receive signing headers. + +## API + +### Builder + +```rust +use bashkit::{Bash, NetworkAllowlist, BotAuthConfig}; + +let bash = Bash::builder() + .network(NetworkAllowlist::new().allow("https://api.example.com")) + .bot_auth(BotAuthConfig::from_seed([42u8; 32]) + .with_agent_fqdn("bot.example.com") + .with_validity_secs(300)) + .build(); +``` + +### BotAuthConfig + +```rust +pub struct BotAuthConfig { + signing_key: SigningKey, // Ed25519 + agent_fqdn: Option, // Signature-Agent header + validity_secs: u64, // default: 300 +} + +impl BotAuthConfig { + fn from_seed(seed: [u8; 32]) -> Self; + fn from_base64_seed(encoded: &str) -> Result; + fn with_agent_fqdn(self, fqdn: impl Into) -> Self; + fn with_validity_secs(self, secs: u64) -> Self; + fn keyid(&self) -> String; // JWK Thumbprint +} +``` + +### Public Key Derivation + +```rust +pub fn derive_bot_auth_public_key(seed: &str) -> Result; + +pub struct BotAuthPublicKey { + pub key_id: String, // JWK Thumbprint (RFC 7638) + pub jwk: serde_json::Value, // Full JWK (OKP/Ed25519) +} +``` + +Consumer uses this to serve the well-known key directory endpoint. + +## Signing Format + +Per RFC 9421 with web-bot-auth tag: + +- **Covered components**: `@authority` (+ `signature-agent` when FQDN set) +- **Algorithm**: Ed25519 (`alg="ed25519"`) +- **Key identity**: JWK Thumbprint (RFC 7638) as `keyid` +- **Tag**: `"web-bot-auth"` +- **Nonce**: 32 random bytes, base64url +- **Timestamps**: `created` (now), `expires` (now + validity_secs) + +### Headers Added + +| Header | Value | +|--------|-------| +| `Signature` | `sig=::` | +| `Signature-Input` | `sig=("@authority");created=...;expires=...;keyid="...";alg="ed25519";nonce="...";tag="web-bot-auth"` | +| `Signature-Agent` | FQDN (only when `agent_fqdn` is set) | + +## Consumer Wiring + +```rust +if let Ok(seed) = std::env::var("BOT_AUTH_SIGNING_KEY_SEED") { + builder = builder.bot_auth(BotAuthConfig::from_base64_seed(&seed)? + .with_agent_fqdn(std::env::var("BOT_AUTH_AGENT_FQDN").ok().unwrap_or_default()) + ); +} +``` + +## Dependencies + +Feature `bot-auth` adds: +- `ed25519-dalek` 2.x (Ed25519 signing) +- `rand` 0.8 (nonce generation) +- `sha2` (already a required dep for checksum builtins) + +## Files + +| File | Purpose | +|------|---------| +| `crates/bashkit/src/network/bot_auth.rs` | BotAuthConfig, signing, key derivation | +| `crates/bashkit/src/network/client.rs` | HttpClient integration (bot_auth_headers) | +| `crates/bashkit/src/network/mod.rs` | Module and re-exports | +| `crates/bashkit/src/lib.rs` | BashBuilder::bot_auth(), public exports | + +## Security + +- Signing key never leaves `BotAuthConfig` — only the public key is derivable +- JWK Thumbprint uses SHA-256 with canonical JSON member ordering (RFC 7638) +- Nonce prevents replay attacks +- Expiry window limits signature validity +- Signing failures are non-blocking (TM-AVAIL-001) + +## References + +- [RFC 9421 — HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) +- [draft-meunier-web-bot-auth-architecture](https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture) +- [RFC 7638 — JSON Web Key Thumbprint](https://www.rfc-editor.org/rfc/rfc7638) +- [Toolkit library contract section 9](https://github.com/everruns/everruns/blob/main/specs/toolkit-library-contract.md) +- [fetchkit bot-auth implementation](https://github.com/everruns/fetchkit/blob/main/crates/fetchkit/src/bot_auth.rs) From 116319425748b82eb33f3d8fd4a23f4f2cf78657 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 4 Apr 2026 14:04:44 +0000 Subject: [PATCH 2/4] chore(audit): add cargo-vet exemptions for bot-auth crypto deps Add safe-to-deploy exemptions for 17 transitive dependencies introduced by ed25519-dalek (curve25519-dalek, fiat-crypto, etc.). --- supply-chain/config.toml | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 0bd1537b..6692dc13 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -106,6 +106,10 @@ criteria = "safe-to-deploy" version = "0.22.1" criteria = "safe-to-deploy" +[[exemptions.base64ct]] +version = "1.8.3" +criteria = "safe-to-deploy" + [[exemptions.bit-set]] version = "0.8.0" criteria = "safe-to-deploy" @@ -118,6 +122,10 @@ criteria = "safe-to-deploy" version = "2.11.0" criteria = "safe-to-deploy" +[[exemptions.block-buffer]] +version = "0.10.4" +criteria = "safe-to-deploy" + [[exemptions.block-buffer]] version = "0.12.0" criteria = "safe-to-deploy" @@ -234,6 +242,10 @@ criteria = "safe-to-deploy" version = "0.16.3" criteria = "safe-to-run" +[[exemptions.const-oid]] +version = "0.9.6" +criteria = "safe-to-deploy" + [[exemptions.const-oid]] version = "0.10.2" criteria = "safe-to-deploy" @@ -254,6 +266,10 @@ criteria = "safe-to-deploy" version = "0.1.3" criteria = "safe-to-deploy" +[[exemptions.cpufeatures]] +version = "0.2.17" +criteria = "safe-to-deploy" + [[exemptions.cpufeatures]] version = "0.3.0" criteria = "safe-to-deploy" @@ -290,10 +306,22 @@ criteria = "safe-to-run" version = "0.2.4" criteria = "safe-to-run" +[[exemptions.crypto-common]] +version = "0.1.7" +criteria = "safe-to-deploy" + [[exemptions.crypto-common]] version = "0.2.1" criteria = "safe-to-deploy" +[[exemptions.curve25519-dalek]] +version = "4.1.3" +criteria = "safe-to-deploy" + +[[exemptions.curve25519-dalek-derive]] +version = "0.1.1" +criteria = "safe-to-deploy" + [[exemptions.ctor]] version = "0.8.0" criteria = "safe-to-deploy" @@ -302,6 +330,10 @@ criteria = "safe-to-deploy" version = "0.0.7" criteria = "safe-to-deploy" +[[exemptions.der]] +version = "0.7.10" +criteria = "safe-to-deploy" + [[exemptions.derive-where]] version = "1.6.1" criteria = "safe-to-deploy" @@ -310,6 +342,10 @@ criteria = "safe-to-deploy" version = "0.1.13" criteria = "safe-to-run" +[[exemptions.digest]] +version = "0.10.7" +criteria = "safe-to-deploy" + [[exemptions.digest]] version = "0.11.2" criteria = "safe-to-deploy" @@ -338,6 +374,14 @@ criteria = "safe-to-deploy" version = "1.0.20" criteria = "safe-to-deploy" +[[exemptions.ed25519]] +version = "2.2.3" +criteria = "safe-to-deploy" + +[[exemptions.ed25519-dalek]] +version = "2.2.0" +criteria = "safe-to-deploy" + [[exemptions.either]] version = "1.15.0" criteria = "safe-to-deploy" @@ -370,6 +414,10 @@ criteria = "safe-to-deploy" version = "0.17.0" criteria = "safe-to-deploy" +[[exemptions.fiat-crypto]] +version = "0.2.9" +criteria = "safe-to-deploy" + [[exemptions.fastrand]] version = "2.3.0" criteria = "safe-to-deploy" @@ -466,6 +514,10 @@ criteria = "safe-to-deploy" version = "0.4.2" criteria = "safe-to-run" +[[exemptions.generic-array]] +version = "0.14.7" +criteria = "safe-to-deploy" + [[exemptions.globset]] version = "0.4.18" criteria = "safe-to-deploy" @@ -926,6 +978,10 @@ criteria = "safe-to-deploy" version = "0.13.1" criteria = "safe-to-deploy" +[[exemptions.pkcs8]] +version = "0.10.2" +criteria = "safe-to-deploy" + [[exemptions.pin-project-lite]] version = "0.2.17" criteria = "safe-to-deploy" @@ -1266,6 +1322,10 @@ criteria = "safe-to-run" version = "0.11.0" criteria = "safe-to-deploy" +[[exemptions.sha2]] +version = "0.10.9" +criteria = "safe-to-deploy" + [[exemptions.sha2]] version = "0.11.0" criteria = "safe-to-deploy" @@ -1290,6 +1350,10 @@ criteria = "safe-to-deploy" version = "2.7.0" criteria = "safe-to-run" +[[exemptions.signature]] +version = "2.2.0" +criteria = "safe-to-deploy" + [[exemptions.siphasher]] version = "1.0.2" criteria = "safe-to-deploy" @@ -1314,6 +1378,10 @@ criteria = "safe-to-deploy" version = "0.9.8" criteria = "safe-to-deploy" +[[exemptions.spki]] +version = "0.7.3" +criteria = "safe-to-deploy" + [[exemptions.stable_deref_trait]] version = "1.2.1" criteria = "safe-to-deploy" From 9f3f166827bd548a2e7b74e4e2394472afc8dae7 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 4 Apr 2026 14:13:46 +0000 Subject: [PATCH 3/4] chore(audit): fix cargo-vet exemption ordering Reorder exemptions to match cargo-vet's expected alphabetical sort. --- supply-chain/config.toml | 48 ++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 6692dc13..3d93b024 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -314,14 +314,6 @@ criteria = "safe-to-deploy" version = "0.2.1" criteria = "safe-to-deploy" -[[exemptions.curve25519-dalek]] -version = "4.1.3" -criteria = "safe-to-deploy" - -[[exemptions.curve25519-dalek-derive]] -version = "0.1.1" -criteria = "safe-to-deploy" - [[exemptions.ctor]] version = "0.8.0" criteria = "safe-to-deploy" @@ -330,6 +322,14 @@ criteria = "safe-to-deploy" version = "0.0.7" criteria = "safe-to-deploy" +[[exemptions.curve25519-dalek]] +version = "4.1.3" +criteria = "safe-to-deploy" + +[[exemptions.curve25519-dalek-derive]] +version = "0.1.1" +criteria = "safe-to-deploy" + [[exemptions.der]] version = "0.7.10" criteria = "safe-to-deploy" @@ -414,10 +414,6 @@ criteria = "safe-to-deploy" version = "0.17.0" criteria = "safe-to-deploy" -[[exemptions.fiat-crypto]] -version = "0.2.9" -criteria = "safe-to-deploy" - [[exemptions.fastrand]] version = "2.3.0" criteria = "safe-to-deploy" @@ -426,6 +422,10 @@ criteria = "safe-to-deploy" version = "2.3.0" criteria = "safe-to-run" +[[exemptions.fiat-crypto]] +version = "0.2.9" +criteria = "safe-to-deploy" + [[exemptions.find-msvc-tools]] version = "0.1.9" criteria = "safe-to-deploy" @@ -490,6 +490,10 @@ criteria = "safe-to-deploy" version = "0.3.32" criteria = "safe-to-deploy" +[[exemptions.generic-array]] +version = "0.14.7" +criteria = "safe-to-deploy" + [[exemptions.get-size-derive2]] version = "0.7.4" criteria = "safe-to-deploy" @@ -514,10 +518,6 @@ criteria = "safe-to-deploy" version = "0.4.2" criteria = "safe-to-run" -[[exemptions.generic-array]] -version = "0.14.7" -criteria = "safe-to-deploy" - [[exemptions.globset]] version = "0.4.18" criteria = "safe-to-deploy" @@ -978,14 +978,14 @@ criteria = "safe-to-deploy" version = "0.13.1" criteria = "safe-to-deploy" -[[exemptions.pkcs8]] -version = "0.10.2" -criteria = "safe-to-deploy" - [[exemptions.pin-project-lite]] version = "0.2.17" criteria = "safe-to-deploy" +[[exemptions.pkcs8]] +version = "0.10.2" +criteria = "safe-to-deploy" + [[exemptions.plotters]] version = "0.3.7" criteria = "safe-to-run" @@ -1338,6 +1338,10 @@ criteria = "safe-to-deploy" version = "1.4.8" criteria = "safe-to-deploy" +[[exemptions.signature]] +version = "2.2.0" +criteria = "safe-to-deploy" + [[exemptions.simba]] version = "0.9.1" criteria = "safe-to-deploy" @@ -1350,10 +1354,6 @@ criteria = "safe-to-deploy" version = "2.7.0" criteria = "safe-to-run" -[[exemptions.signature]] -version = "2.2.0" -criteria = "safe-to-deploy" - [[exemptions.siphasher]] version = "1.0.2" criteria = "safe-to-deploy" From 26eca8c7d657e4869e16e9a8e464eaf0d9e66106 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 4 Apr 2026 15:42:03 +0000 Subject: [PATCH 4/4] docs(specs): document signing coverage for all HTTP paths - 017-request-signing: add table showing all HTTP paths are signed (request_with_headers, request_with_timeouts, custom HttpHandler, redirects) - 005-builtins: add http builtin docs, note bot-auth coverage for all network builtins - 006-threat-model: add TM-NET-021 for bot identity spoofing mitigation - network/mod.rs: mention request signing in security model docs --- crates/bashkit/src/network/mod.rs | 1 + specs/005-builtins.md | 6 ++++++ specs/006-threat-model.md | 3 +++ specs/017-request-signing.md | 11 ++++++++++- 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/bashkit/src/network/mod.rs b/crates/bashkit/src/network/mod.rs index b05cb431..da205bc2 100644 --- a/crates/bashkit/src/network/mod.rs +++ b/crates/bashkit/src/network/mod.rs @@ -13,6 +13,7 @@ //! - **Timeouts**: 30 second default prevents hanging on slow servers //! - **No automatic redirects**: Prevents allowlist bypass via redirect chains //! - **Zip bomb protection**: Compressed responses are size-limited during decompression +//! - **Request signing** (opt-in, `bot-auth` feature): Ed25519 signatures per RFC 9421 on all outbound requests //! //! # Usage //! diff --git a/specs/005-builtins.md b/specs/005-builtins.md index 0c096461..f9b4d173 100644 --- a/specs/005-builtins.md +++ b/specs/005-builtins.md @@ -166,6 +166,12 @@ persist in shell variables as usual. - `wget` - Download files (requires http_client feature + allowlist) - Options: `-q/--quiet`, `-O FILE`, `--spider`, `--header`, `-U/--user-agent`, `--post-data`, `-t/--tries`, `-T/--timeout`, `--connect-timeout` - Security: URL allowlist enforced, 10MB response limit, timeouts clamped to [1s, 10min] +- `http` - HTTPie-style HTTP client (requires http_client feature + allowlist) + - Syntax: `http [OPTIONS] [METHOD] URL [ITEMS...]` where items are `key=value` (JSON string), `key:=value` (JSON raw), `Header:value`, `key==value` (query param) + - Options: `--json/-j`, `--form/-f`, `-v/--verbose`, `-h/--headers`, `-b/--body`, `-o FILE` + - Security: URL allowlist enforced, JSON/form injection prevention, query parameter encoding + +**Request Signing**: When the `bot-auth` feature is enabled and configured, all outbound HTTP requests from curl, wget, and http builtins are transparently signed with Ed25519 per RFC 9421. See `specs/017-request-signing.md`. **Network Configuration**: ```rust diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index c2c623df..88846589 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -617,9 +617,12 @@ allowlist.allow("https://api.example.com"); | TM-NET-018 | JSON body injection | `http POST url name='x","admin":true'` via unescaped string formatting | Use `serde_json` for JSON construction | **MITIGATED** | | TM-NET-019 | Query param injection | `http GET url q=='foo&admin=true'` injects extra params | URL-encode via `url::form_urlencoded` | **MITIGATED** | | TM-NET-020 | Form body injection | `http --form POST url user='x&role=admin'` injects extra fields | URL-encode via `url::form_urlencoded` | **MITIGATED** | +| TM-NET-021 | Bot identity spoofing | Forge requests as a trusted bot | Ed25519 request signing (bot-auth feature, `specs/017-request-signing.md`) | **MITIGATED** (opt-in) | **Current Risk**: LOW - Multiple mitigations in place +**Bot-auth signing** (feature `bot-auth`): When configured, all outbound HTTP requests from curl/wget/http builtins are transparently signed with Ed25519 per RFC 9421. Signing is non-blocking — failures send requests unsigned. See `specs/017-request-signing.md`. + **Implementation**: `network/client.rs` ```rust // Security defaults (TM-NET-008, TM-NET-009, TM-NET-010) diff --git a/specs/017-request-signing.md b/specs/017-request-signing.md index 3787ec1b..308de996 100644 --- a/specs/017-request-signing.md +++ b/specs/017-request-signing.md @@ -31,7 +31,16 @@ BotAuthConfig::sign_request(authority) Signature + Signature-Input + Signature-Agent headers ``` -Signing happens in `HttpClient` at the same layer as the allowlist check — both the default reqwest path and custom `HttpHandler` path receive signing headers. +Signing happens in `HttpClient` at the same layer as the allowlist check. **All** outbound HTTP paths are covered: + +| Path | Signed | How | +|------|--------|-----| +| `HttpClient::request_with_headers` (default reqwest) | Yes | `bot_auth_headers()` injected before `request.send()` | +| `HttpClient::request_with_timeouts` (per-request timeout) | Yes | Same `bot_auth_headers()` injection | +| Custom `HttpHandler` | Yes | Signing headers merged into the handler's `headers` slice | +| Redirects (manual follow in curl/wget) | Yes | Each redirect is a new `HttpClient` request, re-signed with the new authority | + +Every HTTP builtin — `curl`, `wget`, `http` — goes through `HttpClient`, so signing is guaranteed for all outbound requests when configured. No builtin can bypass signing. ## API