From aec3d60d805f9f1b52a06cd43661eb8b2ce5699b Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 9 Apr 2026 09:36:59 -0500 Subject: [PATCH] fix(network): zeroize bot-auth seed material on drop Use explicit Drop-based zeroization for BotAuthConfig seed bytes, keep Debug redaction, and add a regression test verifying seed bytes are wiped after drop. Update threat-model and request-signing specs for TM-CRY-001 and keep bot-auth feature wired to zeroize. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/bashkit/Cargo.toml | 3 +- crates/bashkit/src/network/bot_auth.rs | 62 +++++++++++++++++++++++--- specs/006-threat-model.md | 13 +++++- specs/017-request-signing.md | 6 ++- 6 files changed, 75 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55abbb1d..e4df648a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,6 +358,7 @@ dependencies = [ "tracing", "url", "zapcode-core", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6df46781..86ff1524 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ sha2 = "0.11" # Ed25519 signing (bot-auth request signing) ed25519-dalek = { version = "2", features = ["rand_core"] } rand = "0.10" +zeroize = "1" # CLI clap = { version = "4", features = ["derive"] } diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index 7cf69932..8d922d1a 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -65,6 +65,7 @@ base64 = { workspace = true } # Ed25519 signing for bot-auth request signing (optional) ed25519-dalek = { workspace = true, optional = true } rand = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } # Checksums (for md5sum, sha1sum, sha256sum builtins) md-5 = { workspace = true } @@ -84,7 +85,7 @@ zapcode-core = { version = "1.5", optional = true } default = [] http_client = ["reqwest"] # Enable Ed25519 request signing per RFC 9421 / web-bot-auth profile -bot-auth = ["http_client", "dep:ed25519-dalek", "dep:rand"] +bot-auth = ["http_client", "dep:ed25519-dalek", "dep:rand", "dep:zeroize"] # 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/network/bot_auth.rs b/crates/bashkit/src/network/bot_auth.rs index 87d82239..46b9e640 100644 --- a/crates/bashkit/src/network/bot_auth.rs +++ b/crates/bashkit/src/network/bot_auth.rs @@ -23,23 +23,50 @@ use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; use rand::Rng; use sha2::{Digest, Sha256}; use std::time::{SystemTime, UNIX_EPOCH}; +use zeroize::Zeroize; /// 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, + // THREAT[TM-CRY-001]: Store raw seed and explicitly zeroize in Drop. + seed: [u8; 32], agent_fqdn: Option, validity_secs: u64, } +impl std::fmt::Debug for BotAuthConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BotAuthConfig") + .field("seed", &"[REDACTED]") + .field("agent_fqdn", &self.agent_fqdn) + .field("validity_secs", &self.validity_secs) + .finish() + } +} + +impl Clone for BotAuthConfig { + fn clone(&self) -> Self { + Self { + seed: self.seed, + agent_fqdn: self.agent_fqdn.clone(), + validity_secs: self.validity_secs, + } + } +} + +impl Drop for BotAuthConfig { + fn drop(&mut self) { + self.seed.zeroize(); + } +} + 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), + seed, agent_fqdn: None, validity_secs: 300, } @@ -70,7 +97,8 @@ impl BotAuthConfig { /// Compute the JWK Thumbprint (RFC 7638) keyid for the public key. pub fn keyid(&self) -> String { - jwk_thumbprint_ed25519(&self.signing_key.verifying_key()) + let signing_key = SigningKey::from_bytes(&self.seed); + jwk_thumbprint_ed25519(&signing_key.verifying_key()) } /// Sign a request targeting the given authority and return headers to attach. @@ -106,7 +134,8 @@ impl BotAuthConfig { sig_base.push_str(&format!("\"@signature-params\": {sig_params}")); // Sign - let signature = self.signing_key.sign(sig_base.as_bytes()); + let signing_key = SigningKey::from_bytes(&self.seed); + let signature = signing_key.sign(sig_base.as_bytes()); let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); Ok(BotAuthHeaders { @@ -159,7 +188,8 @@ pub struct BotAuthPublicKey { /// 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 signing_key = SigningKey::from_bytes(&config.seed); + let verifying_key = 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!({ @@ -324,4 +354,24 @@ mod tests { let config = BotAuthConfig::from_seed(seed); assert_eq!(pubkey.key_id, config.keyid()); } + + #[test] + fn seed_zeroized_on_drop() { + let mut slot = std::mem::MaybeUninit::new(BotAuthConfig::from_seed([0xAB; 32])); + let cfg_ptr = slot.as_mut_ptr(); + let seed_ptr = unsafe { std::ptr::addr_of_mut!((*cfg_ptr).seed) }; + + unsafe { std::ptr::drop_in_place(cfg_ptr) }; + let seed_after_drop = unsafe { std::ptr::read(seed_ptr) }; + assert_eq!(seed_after_drop, [0u8; 32]); + } + + #[test] + fn debug_redacts_key_material() { + let seed = [0xABu8; 32]; + let config = BotAuthConfig::from_seed(seed); + let debug = format!("{config:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("171")); + } } diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index b0246f70..7971a824 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -32,6 +32,7 @@ All threats use a stable ID format: `TM--` | TM-INT | Internal Errors | Panic recovery, error message safety, unexpected failures | | TM-GIT | Git Security | Repository access, identity leak, remote operations | | TM-LOG | Logging Security | Sensitive data in logs, log injection, log volume attacks | +| TM-CRY | Cryptographic Material Security | Private key handling, zeroization, key lifetime minimization | | TM-PY | Python Security | Embedded Python sandbox escape, VFS isolation, resource limits | | TM-TS | TypeScript Security | Embedded TypeScript sandbox escape, VFS isolation, resource limits | | TM-UNI | Unicode Security | Byte-boundary panics, invisible chars, homoglyphs, normalization | @@ -660,7 +661,15 @@ async fn read_body_with_limit(&self, response: Response) -> Result> { | Content-Length check | Pre-download validation | Fail fast on huge files | | User-Agent fixed | "bashkit/0.1.0" | Identify requests, prevent spoofing | -#### 5.5 curl/wget Security Model +#### 5.5 Cryptographic Material Security + +| ID | Threat | Attack Vector | Mitigation | Status | +|----|--------|--------------|------------|--------| +| TM-CRY-001 | Bot-auth private key recovery from process memory | Core dump, heap inspection, `/proc//mem` after key use | `BotAuthConfig` zeroizes Ed25519 seed in `Drop`; debug output redacts key material | **MITIGATED** | + +**Current Risk**: LOW - Key material remains process-resident while configured, but is now explicitly zeroized on drop. + +#### 5.6 curl/wget Security Model **Request Flow**: ``` @@ -724,7 +733,7 @@ Script: curl https://api.example.com/data - 47: Max redirects exceeded - 63: Response too large -#### 5.6 Domain Egress Allowlist Design Rationale +#### 5.7 Domain Egress Allowlist Design Rationale Bashkit's network allowlist uses **literal host matching** — the virtual equivalent of SNI (Server Name Indication) filtering on TLS client-hello headers. This is the same diff --git a/specs/017-request-signing.md b/specs/017-request-signing.md index 308de996..1413561a 100644 --- a/specs/017-request-signing.md +++ b/specs/017-request-signing.md @@ -61,7 +61,7 @@ let bash = Bash::builder() ```rust pub struct BotAuthConfig { - signing_key: SigningKey, // Ed25519 + seed: [u8; 32], // Ed25519 seed agent_fqdn: Option, // Signature-Agent header validity_secs: u64, // default: 300 } @@ -121,7 +121,8 @@ if let Ok(seed) = std::env::var("BOT_AUTH_SIGNING_KEY_SEED") { Feature `bot-auth` adds: - `ed25519-dalek` 2.x (Ed25519 signing) -- `rand` 0.8 (nonce generation) +- `rand` 0.10 (nonce generation) +- `zeroize` 1.x (key material zeroization on drop) - `sha2` (already a required dep for checksum builtins) ## Files @@ -136,6 +137,7 @@ Feature `bot-auth` adds: ## Security - Signing key never leaves `BotAuthConfig` — only the public key is derivable +- `Drop` explicitly calls `zeroize()` on seed bytes before deallocation (TM-CRY-001) - JWK Thumbprint uses SHA-256 with canonical JSON member ordering (RFC 7638) - Nonce prevents replay attacks - Expiry window limits signature validity