Security Model
Security Model
Section titled “Security Model”Status: Authoritative security reference.
Cross-references: ARCHITECTURE.md (encryption model), LEASING.md (lease isolation), WEBHOOK_DESIGN.md (HMAC verification).
Threat model
Section titled “Threat model”| Threat | Protection |
|---|---|
| Tenant B reads Tenant A’s forge token | Envelope encryption + tenant-scoped DB queries |
| Attacker forges webhook events | HMAC-SHA256 signature verification |
| Attacker calls admin API | SHA-256 hashed API keys |
| Attacker brute-forces API key | Key length + hash-based comparison |
| Worker crash exposes credentials | Tokens never logged, never persisted in plaintext |
| DoS via webhook flood | Rate limiting per tenant + HMAC cost |
| Multi-tenant data leak | Durable Object isolation + tenant-scoped DB queries |
Tenant credential management
Section titled “Tenant credential management”Envelope encryption
Section titled “Envelope encryption”See ARCHITECTURE.md for the full model.
Cloudflare Secret (master key, 256-bit) ← CREDENTIAL_MASTER_KEY ↓ AES-GCM (random 96-bit IV per token)Forge token (PAT or OAuth token, encrypted, stored in Supabase)Format
Section titled “Format”New encrypted records use the prefix enc:v1: followed by base64url-encoded
IV and ciphertext, separated by a colon:
enc:v1:<iv_base64url>:<ct_base64url>Legacy records are raw base64 (the old btoa(token) format) and are
transparently accepted by the decryptor.
Key provisioning
Section titled “Key provisioning”The master key is a 256-bit (32-byte) random value, base64-encoded, provisioned as a Cloudflare Secret:
wrangler secret put CREDENTIAL_MASTER_KEY <<< "<base64-of-32-random-bytes>"Generate one locally with:
node -e "console.log(Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64'))"If CREDENTIAL_MASTER_KEY is absent when an encrypt or decrypt is called, the
worker throws a clear error ("CREDENTIAL_MASTER_KEY is not configured") — no
silent fallback.
Token lifecycle
Section titled “Token lifecycle”OnCreate (new connection): 1. Encrypt token with master key via AES-GCM (random IV) 2. Store `enc:v1:<iv>:<ct>` in Supabase forge_connections.encrypted_token
OnRead (engine tick): 1. Read encrypted_token from Supabase 2. If prefixed `enc:v1:` → AES-GCM decrypt with master key 3. If raw base64 → legacy decode (backward compat) 4. Use token in-memory for forge API calls 5. Clear from memory when tick completes
OnRotate: 1. Encrypt new token with master key (new random IV) 2. Atomically update encrypted_token in Supabase 3. Old value is never decrypted again (no overlap window)Token lifecycle
Section titled “Token lifecycle”OnCreate: 1. Generate random 256-bit DEK 2. Encrypt DEK with master key → (encryptedDEK, nonce) 3. Encrypt token with DEK → (encryptedToken, nonce) 4. Store (encryptedDEK, nonce, encryptedToken, nonce) in Supabase
OnRead (engine tick): 1. Read encrypted DEK + nonce from Supabase 2. Decrypt DEK with master key (from Cloudflare Secrets) 3. Read encrypted token + nonce from Supabase 4. Decrypt token with DEK 5. Use token in-memory for forge API calls 6. Clear from memory when tick completes
OnRotate: 1. Encrypt new token with same DEK 2. Atomically swap encrypted token in Supabase 3. Old token invalid at next tick (no overlap window)Never exposed
Section titled “Never exposed”| Context | Rule |
|---|---|
| API responses | Tokens are never returned by any API endpoint |
| Logs | Decrypted tokens are never written to logs |
| Error messages | Tokens are redacted in errors (<REDACTED>) |
| Crash dumps | No token data in stack traces |
| Client-side | Tokens never reach the browser (no JS API exposes them) |
Credential rotation
Section titled “Credential rotation”Rotating a tenant’s forge token must be atomic — there must be no window where both the old and new tokens are valid:
- Encrypt new token with existing DEK.
- Atomically update the encrypted token row in Supabase.
- Next engine tick uses the new token; old token is never decrypted again.
Webhook signature verification
Section titled “Webhook signature verification”See WEBHOOK_DESIGN.md for the full routing flow.
HMAC-SHA256 verification
Section titled “HMAC-SHA256 verification”1. Extract signature header: X-Hub-Signature-256: sha256=<hex>2. Extract tenant from request (tenant_id → forge_connection)3. Look up webhook_secret from Supabase4. Compute: HMAC-SHA256(webhook_secret, raw_request_body)5. Constant-time compare with header signature6. If mismatch → 401 Unauthorized7. If match → proceedConstant-time comparison
Section titled “Constant-time comparison”Always use crypto.timingSafeEqual() (JS) or hmac.Equal() (Go) to prevent
timing attacks. Never use == or === for signature comparison.
Tenant secret management
Section titled “Tenant secret management”Each forge connection has a webhook_secret column — a random 32-byte value
generated during onboarding. Secrets are:
- Stored encrypted in Supabase (same envelope encryption as forge tokens).
- Never returned by API endpoints.
- Rotatable via the API (new secret takes effect immediately).
API key authentication
Section titled “API key authentication”Key storage
Section titled “Key storage”API keys are never stored in plaintext. The workflow:
Tenant sends: X-API-Key: my-secret-key
Worker: 1. Hash the key: SHA-256(salt + key) 2. Compare hash against tenants.api_key_hash in Supabase 3. If match → attach tenant_id to request context 4. If no match → 401 UnauthorizedKey generation
Section titled “Key generation”When creating a tenant, a random 32-byte API key is generated. The key is
returned to the caller exactly once (in the CreateTenant response). The
salt is stored alongside the hash in the tenants table.
Admin key
Section titled “Admin key”The admin key (ADMIN_KEY in Cloudflare Secrets) is a special key that grants
full access to all tenant endpoints. It is:
- Injected as a Cloudflare Secret at deploy time.
- Never stored in Supabase (checked before DB lookup).
- Used for internal/management operations.
OAuth2 flow
Section titled “OAuth2 flow”Provider support
Section titled “Provider support”| Provider | OAuth2 scopes | Recommendation |
|---|---|---|
| Gitea 1.23+ | Granular (read:repository, write:repository) | Default auth path |
| Forgejo < 1.22 | No scopes implemented (full admin) | Use PAT paste |
| Codeberg | No scopes implemented | Use PAT paste |
Flow (Gitea 1.23+)
Section titled “Flow (Gitea 1.23+)”1. Tenant clicks "Connect with OAuth2" on dashboard2. Redirect to: https://forge.example.com/login/oauth/authorize? client_id={app_id}&redirect_uri={callback}&response_type=code&scope=read:repository,write:repository3. Tenant authorizes on their forge instance4. Forge redirects to callback with authorization code5. Worker exchanges code for access + refresh tokens6. Tokens are encrypted and stored in forge_connections7. Access token is used for API calls; refresh token renews on expiryFlow (Forgejo/Codeberg — PAT fallback)
Section titled “Flow (Forgejo/Codeberg — PAT fallback)”1. Tenant pastes a scoped PAT in the dashboard2. Token is encrypted via envelope encryption3. Stored in forge_connections.token_encrypted4. Used for API callsJWT session management
Section titled “JWT session management”Token format
Section titled “Token format”{ "sub": "user_id", "org_id": "org_id", "exp": 1722786400}- Signed with Cloudflare Secret key.
- Stored in httpOnly cookie.
- 30-day TTL, refreshable.
Endpoints
Section titled “Endpoints”| Method | Path | Description |
|---|---|---|
| GET | /auth/github |
Redirect to GitHub OAuth |
| GET | /auth/github/callback |
Exchange code for token, create session |
| GET | /api/v1/me |
Get current user |
| POST | /api/v1/logout |
Invalidate session cookie |
Secret storage
Section titled “Secret storage”Cloudflare Secrets
Section titled “Cloudflare Secrets”| Secret | Purpose |
|---|---|
CREDENTIAL_MASTER_KEY |
Envelope encryption master key (256-bit, base64) |
GONDOLIER_MASTER_KEY |
Deprecated alias; use CREDENTIAL_MASTER_KEY |
SUPABASE_URL |
Supabase REST API URL |
SUPABASE_SERVICE_ROLE_KEY |
Supabase service role (full DB access) |
ADMIN_KEY |
Admin API key (not hashed) |
Injected at deploy time via wrangler secret put. Never in repo.
Supabase encryption
Section titled “Supabase encryption”Database-encrypted fields:
| Column | Table | Algorithm |
|---|---|---|
encrypted_token |
forge_connections | enc:v1:<iv>:<ct> AES-GCM |
webhook_secret |
forge_connections | Raw (DB encryption) |
Data isolation between tenants
Section titled “Data isolation between tenants”API layer
Section titled “API layer”Every API request must carry a tenant context. The AuthMiddleware extracts the
tenant_id from the API key hash and attaches it to the request context. All
database queries filter by this tenant_id.
Database layer
Section titled “Database layer”Row-level isolation via tenant_id foreign keys:
forge_connections.tenant_id → tenants.idmanaged_repos.tenant_id → tenants.idaudit_log.tenant_id → tenants.id
All queries include WHERE tenant_id = $1 (or the admin bypass).
Durable Objects
Section titled “Durable Objects”Each DO instance is keyed by (tenant_id, repo_slug). One DO per managed
repo. Storage is isolated per DO instance — zero cross-tenant access.
Engine ticks
Section titled “Engine ticks”A worker tick processes one tenant at a time. The decrypted token for one tenant never enters the memory scope of another tenant’s tick.
Rate limiting as DoS protection
Section titled “Rate limiting as DoS protection”API rate limiting
Section titled “API rate limiting”Per-tenant sliding window via Cloudflare KV:
- Default: 100 requests per 60-second window.
- Key:
rl:{tenant_id}. - On limit exceeded: 429 Too Many Requests.
Webhook rate limiting
Section titled “Webhook rate limiting”HMAC verification is computationally cheap (SHA-256), but a DoS attacker could flood the endpoint. Mitigations:
- HMAC failure returns 401 immediately (no DB lookup).
- Per-tenant rate limiting via KV.
- Unknown tenant (before DB lookup) returns 404 without HMAC verification.
Forge API rate limiting
Section titled “Forge API rate limiting”Per-(tenant, forge_instance) sliding window:
- Key:
rl:{tenant_id}:{forge_host}. - On 429/5xx from forge: exponential backoff.
- One tenant’s rate limit issues never impact another tenant.
Production safety rules
Section titled “Production safety rules”- Never log plaintext tokens. Audit every
log.*,fmt.*,slog.*call in any diff touching credentials. - Fail safe. External dependency failures must not crash the process.
- Fail isolated. One tenant’s failure must not affect other tenants.
- Token rotation is atomic. No overlap window between old and new tokens.
- Encrypt all tenant credentials at rest. No plaintext storage.