Submatter/docs/operations/webhook-secret-rotation.md

79 lines
3.3 KiB
Markdown
Raw Permalink Normal View History

# Webhook secret rotation — `FORGE_WEBHOOK_SECRET`
One-page rotation SOP for `POST /api/webhooks/forge` HMAC secret.
## Where it lives
- **Coolify (prod)**: `web` service env `FORGE_WEBHOOK_SECRET` in `docker/docker-compose.coolify.yml:web.environment` → Coolify UI → Environment Variables. Not committed; value lives only in Coolify env store and Forgejo webhook config.
- **Local dev**: `.env` at repo root (`FORGE_WEBHOOK_SECRET=...`). Copy from `.env.example` pattern — never commit real value.
- **Consumer**: `apps/web/app/api/webhooks/forge/route.ts:62` reads `process.env.FORGE_WEBHOOK_SECRET` and verifies `X-Hub-Signature-256` (GitHub) or `X-Forgejo-Signature` / `X-Hub-Signature` HMAC-SHA256 with `timingSafeEqual`. Rate-limit 60/min per IP. Missing secret → 500 `webhook_not_configured`.
## Rotate
1. Generate new secret (64 hex chars = 32 bytes):
```bash
openssl rand -hex 32
```
Copy output → new `FORGE_WEBHOOK_SECRET`.
2. Update Forgejo / GitHub webhook:
- Forgejo: Repo → Settings → Webhooks → target webhook → **Secret** → paste new value → Save.
- GitHub: Repo → Settings → Webhooks → target webhook → **Secret** → paste new value → Save / Update webhook.
3. Update Coolify:
- Coolify → `echodo` service `web` → Environment Variables → set `FORGE_WEBHOOK_SECRET=<new-hex>` → Save.
- Redeploy `web` (Coolify Deploy). Verify `docker/docker-compose.coolify.yml` not edited directly for prod; Coolify env overlay wins.
4. For local dev, update `.env`:
```bash
FORGE_WEBHOOK_SECRET=<new-hex> pnpm --filter @tasks/web dev
```
5. Keep old secret for 5 min overlap if Forgejo allows dual-secret testing; otherwise coordinate deploy + webhook save within same minute.
## Test
Generate signature and POST to local or prod URL. Route accepts `sha256=<hex>` or bare `<hex>`.
```bash
SECRET="<new-hex>"
BODY='{"action":"closed","pull_request":{"merged":true,"body":"echodo-convoy: 00000000-0000-0000-0000-000000000000","number":123}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
# Local
curl -i -X POST http://localhost:3000/api/webhooks/forge \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"
# Forgejo variant (same HMAC, different header)
curl -i -X POST http://localhost:3000/api/webhooks/forge \
-H 'Content-Type: application/json' \
-H "X-Forgejo-Signature: sha256=$SIG" \
-d "$BODY"
# Prod
curl -i -X POST https://echodo.stillwell.cloud/api/webhooks/forge \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"
```
## Verify
- Valid HMAC + `action=closed` + `pull_request.merged=true` + `body` contains `echodo-convoy: <uuid>` matching a convoy in `pr_review` → 200 `{"ok":true}` and convoy transitions `pr_review → merged` (`automation`, `pr#N merged`) visible in `convoy_events` and Mission Control within 5s.
- Invalid signature → 401 `{"ok":false,"error":"invalid_signature"}`.
- Missing `echodo-convoy:` marker or `merged=false` or `status !== pr_review` → 200 `{"ok":true}` no-op (no leak).
- Missing `FORGE_WEBHOOK_SECRET` env → 500 `webhook_not_configured`.
## References
- Route: `apps/web/app/api/webhooks/forge/route.ts`
- State machine: `packages/shared/src/convoy-lifecycle/state-machine.ts:90`
- Checklist: `docs/operations/live-e2e-checklist.md`