< back to series
DAY 8

Security v2 — Secrets Proxy and Credential Isolation

Mar 25, 2026

Update (March 29, 2026): Day 9 extends this design with tool-tier isolation under tito-toolbox, nftables egress fencing, AppArmor confinement, and a narrow tito-git deploy path for repo pushes. Read Day 9 for the full v3 model.

Day 4 identified the problem: all API tokens were sitting in tito's environment. Day 8 implements the first mitigation layer.

The design

A system user openclaw-secrets owns the real credentials at /etc/openclaw-secrets/env (chmod 600). tito gets "proxy-managed" placeholders instead. A mitmproxy instance on 127.0.0.1:18080 injects the real credentials per destination host before forwarding requests.

Implementation

1. Create secrets user and file

bashsudo useradd --system --no-create-home --shell /usr/sbin/nologin openclaw-secrets
sudo mkdir -p /etc/openclaw-secrets
sudo chown openclaw-secrets:openclaw-secrets /etc/openclaw-secrets
sudo chmod 700 /etc/openclaw-secrets

2. Injection addon

/opt/openclaw-proxy/inject_addon.py reads secrets once at startup and injects headers per host:

pythonfrom mitmproxy import ctx, http

SECRETS = {}
try:
    with open('/etc/openclaw-secrets/env') as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith('#') or '=' not in line:
                continue
            k, _, v = line.partition('=')
            SECRETS[k.strip()] = v.strip().strip('"').strip("'")
except Exception:
    pass

def s(key):
    return SECRETS.get(key, '')

INJECT_RULES = {
    "places.googleapis.com": {
        "X-Goog-Api-Key": s("GOOGLE_PLACES_API_KEY"),
    },
    "striping-app.<account>.workers.dev": {
        "Authorization":           f"Bearer {s('CRM_API_KEY')}",
        "CF-Access-Client-Id":     s("CLOUDFLARE_ACCESS_CLIENT_ID"),
        "CF-Access-Client-Secret": s("CLOUDFLARE_ACCESS_CLIENT_SECRET"),
    },
    "api.cloudflare.com": {
        "Authorization": f"Bearer {s('CLOUDFLARE_D1_API_TOKEN')}",
    },
}

class InjectAddon:
    def request(self, flow: http.HTTPFlow) -> None:
        host = flow.request.pretty_host
        if host not in INJECT_RULES:
            return
        for header, value in INJECT_RULES[host].items():
            if value:
                flow.request.headers[header] = value

addons = [InjectAddon()]

Hosts not in INJECT_RULES pass through unmodified and receive no injected credentials.

3. Systemd service

ini[Unit]
Description=OpenClaw Secrets Proxy (mitmproxy)
After=network.target

[Service]
User=openclaw-secrets
Group=openclaw-secrets
ExecStart=/opt/openclaw-proxy/mitmdump \
    --listen-host 127.0.0.1 \
    --listen-port 18080 \
    --mode regular \
    --set confdir=/etc/openclaw-proxy/certs \
    --scripts /opt/openclaw-proxy/inject_addon.py
Restart=on-failure
NoNewPrivileges=yes
ProtectHome=yes

[Install]
WantedBy=multi-user.target

4. tito's environment

iniTELEGRAM_BOT_TOKEN=<real, the one credential tito holds>
CLOUDFLARE_D1_API_TOKEN=proxy-managed
CRM_API_KEY=proxy-managed
GOOGLE_PLACES_API_KEY=proxy-managed

HTTP_PROXY=http://127.0.0.1:18080
HTTPS_PROXY=http://127.0.0.1:18080
NO_PROXY=api.telegram.org

What this still doesn't fix

SSH deploy keys still live in ~/.ssh/. Git over SSH doesn't route through HTTP_PROXY.

Telegram bot token sits in the URL path, not an HTTP header. The proxy can't inject URL path segments. This is the one real credential tito keeps.

HTTP_PROXY is an env var. Python code using raw sockets bypasses it. This gets addressed in day 9 with kernel-level enforcement.