Skip to content

Commit def3a77

Browse files
trodemasterclaude
andcommitted
feat: add --identity-stdin; document Mac SE and Actions runner modes
--identity-stdin (serve): Reads the age identity from stdin instead of a file. The private key is parsed into memory, the source buffer is immediately scrambled with memguard.ScrambleBytes, and the key never touches disk. Mutually exclusive with --identity; exactly one is required. CI integration test updated to start the proxy via --identity-stdin. README — two deployment mode sections with Mermaid diagrams: Mode 1: Mac persistent proxy with Secure Enclave key - age-plugin-se keygen --access-control none for silent, device-bound decryption (no Touch ID at runtime) - botlockbox seal --recipient age1se1q… - launchd user-session agent keeps the proxy running at login - secrets.age is hardware-bound and useless on any other Mac - Covers one-time setup, launchd install, and secret rotation via reload Mode 2: GitHub Actions self-hosted runner (ephemeral key) - Fresh age keypair generated each run; private key piped via stdin (--identity-stdin) so it never touches disk - Credentials sourced from GitHub Actions secrets at seal time - Agent runs as an unprivileged user; proxy injects credentials the agent cannot read from env or disk - Threat table comparing with/without botlockbox - Process isolation pattern (two-UID Docker entrypoint) CLI reference updated for --identity / --recipient / --identity-stdin flags and their mutual-exclusion rules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cd11b6c commit def3a77

3 files changed

Lines changed: 244 additions & 39 deletions

File tree

.github/workflows/integration.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@ jobs:
7373
--recipient "$PUBKEY"
7474
7575
# -----------------------------------------------------------------------
76-
# Start proxy
76+
# Start proxy via --identity-stdin (key never touches disk after this point)
7777
# -----------------------------------------------------------------------
78-
- name: Start botlockbox serve
78+
- name: Start botlockbox serve via --identity-stdin
7979
run: |
80-
./bin/botlockbox serve \
80+
cat /tmp/identity.txt | ./bin/botlockbox serve \
8181
--config /tmp/botlockbox.yaml \
82-
--identity /tmp/identity.txt \
82+
--identity-stdin \
8383
--ca-cert /tmp/botlockbox-ca.pem \
8484
--pidfile /tmp/botlockbox.pid &
8585

README.md

Lines changed: 205 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,196 @@ https_proxy=http://127.0.0.1:8080 \
110110
# Authorization: Bearer ghp_xxx injected transparently
111111
```
112112

113+
---
114+
115+
## Deployment modes
116+
117+
### Mode 1: Mac persistent proxy with Secure Enclave key
118+
119+
Use [`age-plugin-se`](https://github.com/remko/age-plugin-se) to bind `secrets.age` to a specific Mac's Secure Enclave. The private key is generated inside the chip and **cannot be exported or used on any other machine**. With `--access-control none`, decryption is silent — no Touch ID prompt — making it suitable for a `launchd` user-session agent that starts at login.
120+
121+
```mermaid
122+
flowchart TD
123+
subgraph setup["One-time setup"]
124+
direction LR
125+
KG["age-plugin-se keygen\n--access-control none"]
126+
SE["Secure Enclave\nT2 / M-series"]
127+
SE -->|"generates &amp; stores\nprivate key in hardware"| KG
128+
KG -->|"identity reference\n(AGE-PLUGIN-SE-1…)"| IDFILE["~/.botlockbox/identity.txt"]
129+
KG -->|"public key\n(age1se1q…)"| SEAL
130+
CREDS["credentials\n(stdin, plaintext)"] --> SEAL["botlockbox seal\n--recipient age1se1q…"]
131+
SEAL -->|"device-bound\nciphertext"| SAGE["secrets.age"]
132+
end
133+
134+
subgraph runtime["Every login — launchd user-session agent"]
135+
direction LR
136+
LAUNCHD["launchd"] -->|"starts at login\nKeepAlive: true"| SERVE["botlockbox serve\n--identity identity.txt"]
137+
IDFILE -->|"identity ref"| SERVE
138+
SAGE -->|"ciphertext"| SERVE
139+
SERVE -->|"SE decrypts silently\nno Touch ID"| MEM["secrets in\nmemguard enclaves"]
140+
AGENT["AI agent / MCP / CLI\nno credentials"] -->|"HTTPS_PROXY=\nlocalhost:8080"| SERVE
141+
SERVE -->|"Authorization injected\nfrom enclave"| API["External API"]
142+
end
143+
144+
SAGE -.->|"useless on\nany other Mac"| LOCK["🔒 device-bound"]
145+
```
146+
147+
**One-time setup:**
148+
149+
```bash
150+
# Install age-plugin-se (e.g. via Homebrew)
151+
brew install age-plugin-se
152+
153+
# Generate a key bound to this Mac's Secure Enclave (no Touch ID at runtime)
154+
age-plugin-se keygen --access-control none -o ~/.botlockbox/identity.txt
155+
# note the "public key: age1se1q..." line
156+
157+
# Seal your credentials to that public key
158+
printf 'openai_key: "sk-xxxx"\ngithub_token: "ghp_xxxx"\n' \
159+
| botlockbox seal \
160+
--config ~/.botlockbox/botlockbox.yaml \
161+
--recipient age1se1q...
162+
```
163+
164+
**Install as a launchd user-session agent** (see `contrib/com.trodemaster.botlockbox.plist` for the full template):
165+
166+
```bash
167+
# Edit the plist to set your username and paths, then:
168+
cp contrib/com.trodemaster.botlockbox.plist ~/Library/LaunchAgents/
169+
launchctl load ~/Library/LaunchAgents/com.trodemaster.botlockbox.plist
170+
```
171+
172+
The proxy starts at every login. `secrets.age` and `identity.txt` are useless on any other Mac — hardware binding without prompts.
173+
174+
**Rotating a secret:**
175+
176+
```bash
177+
# Re-seal with the new credential value
178+
printf 'openai_key: "sk-new"\ngithub_token: "ghp_xxxx"\n' \
179+
| botlockbox seal \
180+
--config ~/.botlockbox/botlockbox.yaml \
181+
--recipient age1se1q...
182+
183+
# Hot-reload the running proxy (no restart, zero dropped connections)
184+
botlockbox reload --pidfile ~/.botlockbox/botlockbox.pid
185+
```
186+
187+
---
188+
189+
### Mode 2: GitHub Actions self-hosted runner (ephemeral key)
190+
191+
The runner hosts AI agents that call external APIs. Credentials live in GitHub Actions secrets and are never placed in agent environment variables. An ephemeral age key is generated fresh for each workflow run and piped directly to `botlockbox serve` via `--identity-stdin`**the private key never touches disk**.
192+
193+
```mermaid
194+
flowchart TD
195+
subgraph gha["GitHub Actions workflow — self-hosted runner"]
196+
direction TB
197+
198+
subgraph setup["Job startup (root / botlockbox user)"]
199+
GHS["GitHub Actions secrets\nOPENAI_KEY, GITHUB_TOKEN…"]
200+
KEYGEN["age-keygen\nephemeral keypair"]
201+
PUBKEY["public key\nshell variable only"]
202+
PRIVKEY["private key\nshell variable only"]
203+
KEYGEN --> PUBKEY
204+
KEYGEN --> PRIVKEY
205+
GHS --> SEAL["botlockbox seal\n--recipient \$PUBKEY"]
206+
PUBKEY --> SEAL
207+
SEAL --> SAGE["secrets.age\nrun-scoped"]
208+
PRIVKEY -->|"piped via stdin"| SERVE["botlockbox serve\n--identity-stdin"]
209+
SAGE --> SERVE
210+
SERVE -->|"decrypts once\nscrambles key buffer"| MEM["secrets in\nmemguard enclaves"]
211+
end
212+
213+
subgraph run["Agent execution (unprivileged user)"]
214+
AGENT["AI agent\nuid: agent"]
215+
AGENT -->|"HTTPS_PROXY=localhost:8080\nno credentials in env"| SERVE
216+
SERVE -->|"Authorization injected\nfrom enclave"| API["External API"]
217+
end
218+
end
219+
220+
KEYGEN -.->|"shell vars gone\nafter step exits"| GONE["🗑 ephemeral"]
221+
SERVE -.->|"cannot read identity.txt\n(it does not exist)"| NOFILE["no key on disk"]
222+
```
223+
224+
**Workflow pattern:**
225+
226+
```yaml
227+
jobs:
228+
run-agent:
229+
runs-on: [self-hosted, linux]
230+
steps:
231+
- uses: actions/checkout@v4
232+
233+
- name: Start botlockbox (key never touches disk)
234+
env:
235+
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
236+
run: |
237+
# Generate a fresh ephemeral keypair for this run
238+
IDENTITY=$(age-keygen)
239+
PUBKEY=$(echo "$IDENTITY" | grep '# public key:' | awk '{print $NF}')
240+
241+
# Seal the credentials to the ephemeral public key
242+
printf 'openai_key: "%s"\n' "$OPENAI_KEY" \
243+
| botlockbox seal --config botlockbox.yaml --recipient "$PUBKEY"
244+
245+
# Pipe the private key directly to serve — never written to disk
246+
echo "$IDENTITY" | botlockbox serve \
247+
--config botlockbox.yaml \
248+
--identity-stdin \
249+
--ca-cert /tmp/botlockbox-ca.pem \
250+
--pidfile /tmp/botlockbox.pid &
251+
252+
# Trust the ephemeral CA so agent tools can verify TLS
253+
sudo cp /tmp/botlockbox-ca.pem /usr/local/share/ca-certificates/botlockbox.crt
254+
sudo update-ca-certificates
255+
256+
- name: Run agent (no credentials in environment)
257+
run: |
258+
HTTPS_PROXY=http://127.0.0.1:8080 python agent.py
259+
```
260+
261+
**What this protects against:**
262+
263+
| Threat | Without botlockbox | With botlockbox |
264+
|--------|-------------------|-----------------|
265+
| Agent reads `$OPENAI_KEY` from env | ✗ exposed | ✓ not in env |
266+
| Agent reads credentials from disk | ✗ if written to file | ✓ never on disk |
267+
| Agent logs or exfiltrates request body | ✗ credential visible | ✓ scrubbed from responses |
268+
| Leaked `secrets.age` file after run | ✗ decryptable with key | ✓ key is ephemeral, gone after job |
269+
| Agent calls an unlisted host | ✗ no enforcement | ✓ sealed allowlist blocks injection |
270+
271+
**Process isolation (recommended):**
272+
273+
For stronger isolation, run botlockbox as a privileged user and the agent as a separate unprivileged user. The agent can reach the proxy over TCP but cannot read botlockbox's memory or files.
274+
275+
```
276+
uid 0 / botlockbox: botlockbox serve --identity-stdin ...
277+
uid 1001 / agent: HTTPS_PROXY=http://127.0.0.1:8080 python agent.py
278+
```
279+
280+
In Docker, this is a two-stage entrypoint: start the proxy as root, then `exec su -c "python agent.py" agent`.
281+
282+
---
283+
113284
## CLI reference
114285
115286
### `botlockbox seal`
116287
117288
Reads plaintext secrets from stdin, binds them to the host allowlist derived from the config, and writes an `age`-encrypted envelope to `secrets_file`. Also sets the config to read-only (`0444`) to prevent post-seal tampering.
118289
119290
```
120-
botlockbox seal --config <path> --identity <path>
291+
botlockbox seal --config <path> (--identity <path> | --recipient <pubkey>)
121292
```
122293
123294
| Flag | Default | Description |
124295
|------|---------|-------------|
125296
| `--config` | `botlockbox.yaml` | Path to `botlockbox.yaml` |
126-
| `--identity` | _(required)_ | Path to an `age` identity file (e.g. `~/.age/identity.txt`) |
297+
| `--identity` | — | Path to an age X25519 identity file; derives the recipient from the key. Mutually exclusive with `--recipient`. |
298+
| `--recipient` | — | Age public key string (`age1…` or `age1se1…`). Use this for plugin keys such as `age-plugin-se`. Mutually exclusive with `--identity`. |
299+
300+
Exactly one of `--identity` or `--recipient` is required.
127301
128-
**Stdin format** — YAML key/value pairs, one secret per line:
302+
**Stdin format** — YAML key/value pairs:
129303
130304
```yaml
131305
github_token: "ghp_xxxxxxxxxxxxxxxxxxxx"
@@ -134,13 +308,6 @@ openai_key: "sk-xxxxxxxxxxxxxxxxxxxx"
134308

135309
Every secret name referenced in a `{{secrets.NAME}}` template in the config must be present on stdin. Missing secrets are a hard error.
136310

137-
**Output:**
138-
139-
```
140-
Secrets sealed to /home/user/.botlockbox/secrets.age
141-
Config set to read-only (0444): botlockbox.yaml
142-
```
143-
144311
**Re-sealing** — run `seal` again any time you rotate a secret or add a new host to the config. The previous `secrets.age` is overwritten atomically.
145312

146313
---
@@ -150,52 +317,59 @@ Config set to read-only (0444): botlockbox.yaml
150317
Decrypts the sealed envelope, validates it against the live config, loads secrets into locked memory, and starts the MITM proxy.
151318

152319
```
153-
botlockbox serve --config <path> --identity <path>
320+
botlockbox serve --config <path> (--identity <path> | --identity-stdin) [flags]
154321
```
155322

156323
| Flag | Default | Description |
157324
|------|---------|-------------|
158325
| `--config` | `botlockbox.yaml` | Path to `botlockbox.yaml` |
159-
| `--identity` | _(required)_ | Path to an `age` identity file |
326+
| `--identity` || Path to an age identity file. Mutually exclusive with `--identity-stdin`. |
327+
| `--identity-stdin` | `false` | Read age identity from stdin; the key is never written to disk. Mutually exclusive with `--identity`. |
328+
| `--pidfile` || Write the proxy PID here; used by `botlockbox reload`. |
329+
| `--ca-cert` || Write the ephemeral MITM CA public certificate PEM here so clients can trust it. |
330+
331+
Exactly one of `--identity` or `--identity-stdin` is required.
160332

161333
**Startup sequence:**
162334

163335
1. Load and parse `botlockbox.yaml`
164336
2. Decrypt `secrets_file` using the age identity
165-
3. Validate the sealed envelope against the live config — any secret or host present in the config that was not committed at seal time causes an immediate `os.Exit(1)` with a descriptive error
337+
3. Validate the sealed envelope against the live config — any secret or host present in the config that was not committed at seal time causes an immediate `os.Exit(1)`
166338
4. Load each secret into a `memguard` encrypted enclave; scramble the plaintext bytes immediately
167339
5. Apply OS hardening (`PR_SET_DUMPABLE=0`, `mlockall`, `RLIMIT_CORE=0` on Linux)
168340
6. Generate an ephemeral in-memory ECDSA P-256 MITM CA (24 h lifetime, never written to disk)
169-
7. Begin accepting connections
341+
7. Write CA cert PEM and PID file if requested
342+
8. Begin accepting connections
170343

171-
**Output on successful start:**
344+
---
172345

173-
```
174-
Host binding verified
175-
botlockbox listening on 127.0.0.1:8080
176-
```
346+
### `botlockbox reload`
177347

178-
**Security violation example** (config edited after seal):
348+
Sends SIGHUP to a running `serve` process, triggering a live secret reload. The proxy keeps serving with old secrets if the reload fails for any reason.
179349

180350
```
181-
SECURITY VIOLATION: secret "github_token" is referenced in botlockbox.yaml but was not present at seal time -- re-seal to add new secrets
182-
exit status 1
351+
botlockbox reload --pidfile <path>
183352
```
184353

354+
| Flag | Default | Description |
355+
|------|---------|-------------|
356+
| `--pidfile` | _(required)_ | Path to the PID file written by `botlockbox serve`. |
357+
358+
---
359+
185360
## Config reference
186361

187362
| Field | Type | Default | Description |
188363
|-------|------|---------|-------------|
189364
| `listen` | string | `127.0.0.1:8080` | Proxy listen address |
190365
| `secrets_file` | string | `~/.botlockbox/secrets.age` | Path to age-encrypted secrets |
191366
| `verbose` | bool | `false` | Log every proxied request |
192-
| `identity_file` | string | -- | Default age identity file path |
193-
| `rules` | list | -- | Credential injection rules |
194-
| `rules[].name` | string | -- | Human-readable rule name (appears in audit log) |
195-
| `rules[].match.hosts` | list | -- | Host glob patterns (`*.example.com` supported) |
196-
| `rules[].match.path_prefixes` | list | -- | Optional URL path prefix filters |
197-
| `rules[].inject.headers` | map | -- | Request headers to inject; supports `{{secrets.NAME}}` |
198-
| `rules[].inject.query_params` | map | -- | Query parameters to inject; supports `{{secrets.NAME}}` |
367+
| `rules` | list || Credential injection rules |
368+
| `rules[].name` | string || Human-readable rule name (appears in audit log) |
369+
| `rules[].match.hosts` | list || Host glob patterns (`*.example.com` supported) |
370+
| `rules[].match.path_prefixes` | list || Optional URL path prefix filters |
371+
| `rules[].inject.headers` | map || Request headers to inject; supports `{{secrets.NAME}}` |
372+
| `rules[].inject.query_params` | map || Query parameters to inject; supports `{{secrets.NAME}}` |
199373

200374
## Secrets file format
201375

@@ -207,7 +381,7 @@ openai_key: "sk-xxxxxxxxxxxxxxxxxxxx"
207381
aws_session_token: "IQoJb3..."
208382
```
209383
210-
The only artifact written to disk is `~/.botlockbox/secrets.age` -- an opaque `age`-encrypted blob.
384+
The only artifact written to disk is `secrets.age` -- an opaque `age`-encrypted blob.
211385

212386
## Deployment posture
213387

@@ -237,7 +411,7 @@ The only artifact written to disk is `~/.botlockbox/secrets.age` -- an opaque `a
237411
```bash
238412
make build # compile to bin/botlockbox
239413
make install # go install
240-
make test # go test ./...
414+
make test # go test -race ./...
241415
make lint # go vet ./...
242416
make tidy # go mod tidy
243417
```

0 commit comments

Comments
 (0)