Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
3 changes: 2 additions & 1 deletion crates/bashkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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"]
Expand Down
62 changes: 56 additions & 6 deletions crates/bashkit/src/network/bot_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
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,
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -159,7 +188,8 @@ pub struct BotAuthPublicKey {
/// endpoint so target servers can verify signatures.
pub fn derive_bot_auth_public_key(seed: &str) -> Result<BotAuthPublicKey, BotAuthError> {
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!({
Expand Down Expand Up @@ -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"));
}
}
13 changes: 11 additions & 2 deletions specs/006-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ All threats use a stable ID format: `TM-<CATEGORY>-<NUMBER>`
| 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 |
Expand Down Expand Up @@ -660,7 +661,15 @@ async fn read_body_with_limit(&self, response: Response) -> Result<Vec<u8>> {
| 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/<pid>/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**:
```
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions specs/017-request-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ let bash = Bash::builder()

```rust
pub struct BotAuthConfig {
signing_key: SigningKey, // Ed25519
seed: [u8; 32], // Ed25519 seed
agent_fqdn: Option<String>, // Signature-Agent header
validity_secs: u64, // default: 300
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading