Skip to content

Commit 2119e67

Browse files
trodemasterclaude
andcommitted
feat: expose CA cert PEM and add integration test workflow
Adds --ca-cert flag to 'botlockbox serve' that writes the ephemeral MITM CA public certificate to a file so clients can trust the proxy. The CA PEM is threaded from GenerateEphemeralCA() through proxy.New() into Injector.CACertPEM. Adds .github/workflows/integration.yml which: - Builds the binary - Generates an age identity and config for api.github.com - Seals the GITHUB_TOKEN from the Actions secret store - Starts botlockbox serve (--ca-cert, --pidfile) - Waits for the proxy port to open - Trusts the CA system-wide (update-ca-certificates) - Proves credential injection end-to-end: * Unauthenticated curl without proxy → 401 * gh api /user with GH_TOKEN=dummy through proxy → 200 (proxy replaces dummy) * curl through proxy with no auth → valid user JSON * Dummy token without proxy → 401 - Tests live reload: re-seals and sends SIGHUP, verifies proxy still works Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 358cc67 commit 2119e67

5 files changed

Lines changed: 180 additions & 9 deletions

File tree

.github/workflows/integration.yml

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
name: Integration
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
integration:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: actions/setup-go@v5
19+
with:
20+
go-version-file: go.mod
21+
cache: true
22+
23+
- name: Build
24+
run: make build
25+
26+
- name: Install age
27+
run: |
28+
GOBIN=/usr/local/bin go install filippo.io/age/cmd/age-keygen@v1.3.1
29+
GOBIN=/usr/local/bin go install filippo.io/age/cmd/age@v1.3.1
30+
31+
# -----------------------------------------------------------------------
32+
# Setup: generate keys, config, and sealed secrets
33+
# -----------------------------------------------------------------------
34+
- name: Generate age identity
35+
run: age-keygen -o /tmp/identity.txt
36+
37+
- name: Write botlockbox config
38+
run: |
39+
cat > /tmp/botlockbox.yaml <<'EOF'
40+
listen: "127.0.0.1:8080"
41+
secrets_file: /tmp/secrets.age
42+
rules:
43+
- name: github-api
44+
match:
45+
hosts:
46+
- "api.github.com"
47+
inject:
48+
headers:
49+
Authorization: "Bearer {{secrets.github_token}}"
50+
EOF
51+
52+
- name: Seal GITHUB_TOKEN into secrets.age
53+
env:
54+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55+
run: |
56+
printf 'github_token: "%s"\n' "$GITHUB_TOKEN" \
57+
| ./bin/botlockbox seal \
58+
--config /tmp/botlockbox.yaml \
59+
--identity /tmp/identity.txt
60+
61+
# -----------------------------------------------------------------------
62+
# Start proxy
63+
# -----------------------------------------------------------------------
64+
- name: Start botlockbox serve
65+
run: |
66+
./bin/botlockbox serve \
67+
--config /tmp/botlockbox.yaml \
68+
--identity /tmp/identity.txt \
69+
--ca-cert /tmp/botlockbox-ca.pem \
70+
--pidfile /tmp/botlockbox.pid &
71+
72+
- name: Wait for proxy to be ready
73+
run: |
74+
for i in $(seq 1 40); do
75+
if nc -z 127.0.0.1 8080 2>/dev/null; then
76+
echo "Proxy is ready (attempt $i)"
77+
exit 0
78+
fi
79+
sleep 0.25
80+
done
81+
echo "Proxy did not become ready in time" >&2
82+
exit 1
83+
84+
- name: Trust botlockbox CA system-wide
85+
run: |
86+
sudo cp /tmp/botlockbox-ca.pem /usr/local/share/ca-certificates/botlockbox.crt
87+
sudo update-ca-certificates
88+
89+
# -----------------------------------------------------------------------
90+
# Tests
91+
# -----------------------------------------------------------------------
92+
93+
# Baseline: unauthenticated request WITHOUT the proxy → 401
94+
- name: Baseline - unauthenticated request returns 401
95+
run: |
96+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://api.github.com/user)
97+
echo "Status without proxy: $STATUS"
98+
[ "$STATUS" = "401" ]
99+
100+
# Core test: botlockbox injects the real token even when gh carries a dummy token.
101+
# GH_TOKEN=dummy forces gh to send "Authorization: Bearer dummy", which the
102+
# proxy overwrites with the sealed real token before forwarding to GitHub.
103+
- name: Proxy injects real token - gh api /user succeeds with dummy GH_TOKEN
104+
env:
105+
GH_TOKEN: dummy-credential
106+
HTTPS_PROXY: http://127.0.0.1:8080
107+
run: |
108+
LOGIN=$(gh api /user --jq '.login')
109+
echo "Logged in as: $LOGIN"
110+
[ -n "$LOGIN" ]
111+
112+
# Verify the injected identity matches the token owner
113+
- name: Proxy injects real token - curl returns valid user JSON
114+
run: |
115+
RESPONSE=$(curl -s \
116+
--proxy http://127.0.0.1:8080 \
117+
--cacert /tmp/botlockbox-ca.pem \
118+
https://api.github.com/user)
119+
echo "$RESPONSE" | python3 -c "
120+
import sys, json
121+
data = json.load(sys.stdin)
122+
assert 'login' in data, f'missing login field: {data}'
123+
print('login:', data['login'])
124+
"
125+
126+
# Confirm without proxy the dummy token is rejected → 401
127+
- name: Dummy token without proxy returns 401
128+
run: |
129+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
130+
-H "Authorization: Bearer dummy-credential" \
131+
https://api.github.com/user)
132+
echo "Status with dummy token (no proxy): $STATUS"
133+
[ "$STATUS" = "401" ]
134+
135+
# -----------------------------------------------------------------------
136+
# Reload test
137+
# -----------------------------------------------------------------------
138+
- name: Reload secrets via SIGHUP
139+
run: |
140+
# Re-seal with the same token (simulates a rotation)
141+
printf 'github_token: "%s"\n' "$GITHUB_TOKEN" \
142+
| ./bin/botlockbox seal \
143+
--config /tmp/botlockbox.yaml \
144+
--identity /tmp/identity.txt
145+
146+
# Signal reload
147+
./bin/botlockbox reload --pidfile /tmp/botlockbox.pid
148+
sleep 1
149+
150+
# Proxy must still serve requests correctly after reload
151+
LOGIN=$(GH_TOKEN=dummy-credential HTTPS_PROXY=http://127.0.0.1:8080 \
152+
gh api /user --jq '.login')
153+
echo "After reload, logged in as: $LOGIN"
154+
[ -n "$LOGIN" ]
155+
env:
156+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

cmd/botlockbox/serve.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ func runServe(args []string) {
111111
configPath := fs.String("config", "botlockbox.yaml", "path to botlockbox.yaml")
112112
identityPath := fs.String("identity", "", "path to age identity file (required)")
113113
pidfilePath := fs.String("pidfile", "", "path to write PID file (optional; used with 'botlockbox reload')")
114+
caCertPath := fs.String("ca-cert", "", "path to write the ephemeral MITM CA public certificate PEM (optional; trust this cert in clients)")
114115
fs.Usage = func() {
115116
fmt.Fprintln(os.Stderr, "Usage: botlockbox serve [flags]")
116117
fmt.Fprintln(os.Stderr, "Decrypts secrets and starts the MITM proxy.")
@@ -147,6 +148,13 @@ func runServe(args []string) {
147148
os.Exit(1)
148149
}
149150

151+
if *caCertPath != "" {
152+
if err := os.WriteFile(*caCertPath, injector.CACertPEM, 0644); err != nil {
153+
fmt.Fprintf(os.Stderr, "error writing CA cert: %v\n", err)
154+
os.Exit(1)
155+
}
156+
}
157+
150158
if *pidfilePath != "" {
151159
if err := writePIDFile(*pidfilePath); err != nil {
152160
fmt.Fprintf(os.Stderr, "error writing PID file: %v\n", err)

internal/proxy/ephemeral_ca.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@ import (
1414

1515
// GenerateEphemeralCA creates a fresh CA cert and key entirely in memory.
1616
// Never written to disk. 24h lifetime limits blast radius.
17-
func GenerateEphemeralCA() (*tls.Certificate, error) {
17+
// Returns the TLS certificate and the PEM-encoded public certificate (safe to share with clients).
18+
func GenerateEphemeralCA() (*tls.Certificate, []byte, error) {
1819
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
1920
if err != nil {
20-
return nil, err
21+
return nil, nil, err
2122
}
2223
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
2324
if err != nil {
24-
return nil, err
25+
return nil, nil, err
2526
}
2627
template := &x509.Certificate{
2728
SerialNumber: serial,
@@ -37,18 +38,19 @@ func GenerateEphemeralCA() (*tls.Certificate, error) {
3738
}
3839
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
3940
if err != nil {
40-
return nil, err
41+
return nil, nil, err
4142
}
43+
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
4244
keyDER, err := x509.MarshalECPrivateKey(key)
4345
if err != nil {
44-
return nil, err
46+
return nil, nil, err
4547
}
4648
cert, err := tls.X509KeyPair(
47-
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}),
49+
certPEM,
4850
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}),
4951
)
5052
if err != nil {
51-
return nil, err
53+
return nil, nil, err
5254
}
53-
return &cert, nil
55+
return &cert, certPEM, nil
5456
}

internal/proxy/injector.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ type Injector struct {
2424
rules []config.Rule
2525
envelope *secrets.SealedEnvelope
2626
lockedSecrets map[string]*memguard.Enclave
27+
28+
// CACertPEM is the PEM-encoded public certificate of the ephemeral MITM CA.
29+
// Safe to write to disk or share with clients that need to trust the proxy.
30+
CACertPEM []byte
2731
}
2832

2933
// Handle is the goproxy request handler.

internal/proxy/proxy.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
// New creates a goproxy server configured to inject credentials per the rules.
1313
// It returns the HTTP handler, the Injector (for live secret rotation via SwapSecrets), and any error.
1414
func New(cfg *config.Config, result *secrets.UnsealResult) (http.Handler, *Injector, error) {
15-
ephemeralCA, err := GenerateEphemeralCA()
15+
ephemeralCA, caCertPEM, err := GenerateEphemeralCA()
1616
if err != nil {
1717
return nil, nil, fmt.Errorf("generating ephemeral CA: %w", err)
1818
}
@@ -34,6 +34,7 @@ func New(cfg *config.Config, result *secrets.UnsealResult) (http.Handler, *Injec
3434
rules: cfg.Rules,
3535
envelope: result.Envelope,
3636
lockedSecrets: result.LockedSecrets,
37+
CACertPEM: caCertPEM,
3738
}
3839
p.OnRequest().DoFunc(injector.Handle)
3940
InstallResponseScrubber(p)

0 commit comments

Comments
 (0)