Skip to content

Information Security — Milestone Proposal ​

Status: In Progress · April 2026

Defines the security baseline required before the platform can handle real-money transactions at scale. Covers data protection, access control, secrets management, and threat modelling.

IS-4, IS-5, IS-6, IS-7, IS-9, IS-10, IS-11, IS-12 implemented. IS-1, IS-2, IS-3, IS-8 remain. IS-9/IS-11 transition gaps closed (resolveActor, campaigns public_id, session-submissions guard) — strict enforcement requires deploying REQUIRE_SESSION_AUTH=true + SESSION_SECRET.


Problem ​

The platform manages USDC escrow, streamer KYC documents, wallet addresses, and operator API credentials. Current security posture is functional but informal — adequate for a closed beta, insufficient for open production.

Key gaps identified:

  • No formal threat model
  • Secrets stored in .env files with no rotation policy
  • No audit log for sensitive operations (KYC status changes, deal funding, payout confirms)
  • No rate-limiting on auth or sensitive endpoints
  • KYC documents stored in Cloudflare R2 with no access expiry on presigned URLs
  • No security headers policy (CSP, HSTS, referrer policy)
  • No penetration test or external security review
  • All routes lack server-side auth middleware — actor_id is trusted from the request body
  • Sequential integer IDs in URLs enable resource enumeration and cross-operator probing
  • Write endpoints lack row-level ownership checks
  • Admin-only routes have no server-enforced gate

Proposed Milestone — M-IS ​

A dedicated security milestone to be completed before opening the platform beyond the current invite-only cohort.

IS-1 · Threat Model ​

  • Document assets, trust boundaries, and attack surfaces
  • Cover: operator portal, streamer portal, API, smart contract, R2 storage, Pusher channel auth
  • Output: threat model doc + risk register with severity ratings

IS-2 · Secrets & Credentials ​

  • Migrate all secrets from .env files to a secrets manager (Doppler or Cloudflare secrets)
  • Define rotation schedule for: R2 keys, Pusher secrets, Resend API key, operator API keys
  • Audit all places secrets are logged or emitted in error responses

IS-3 · Audit Log ​

  • Add an audit_log table: (id, actor_type, actor_id, action, target_type, target_id, metadata JSONB, created_at)
  • Log: KYC status changes, deal funding, payout confirms, allocation creation, operator account provisioning
  • Expose read-only audit trail in the admin panel

IS-4 · Rate Limiting & Brute-Force Protection done ​

  • ✅ POST /api/auth/send-pin — 3 requests / 10 min per email
  • ✅ POST /api/auth/verify-pin — 5 attempts / 15 min per email
  • Global API rate limit per IP for unauthenticated endpoints (pending)

IS-5 · R2 Document Security done ​

  • ✅ KYC documents are API-proxied (never presigned) — access is gated per-request
  • ✅ Cache-Control: private, no-store on download responses — no browser caching
  • R2 bucket public-access policy audit (pending)

IS-6 · Security Headers done ​

  • ✅ hono/secure-headers middleware applied globally on the API (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
  • Frontend (Cloudflare Pages) security headers pending _headers config

IS-7 · Dependency Audit & SBOM done ​

  • ✅ npm audit --audit-level=high added to deploy-api.yml (advisory — non-blocking until baseline is clean)
  • SBOM generation for Docker image (pending)

IS-8 · External Penetration Test ​

  • Engage an external security firm for a scoped pentest covering the API and smart contract
  • Resolve all critical and high findings before public launch
  • Smart contract formal verification or third-party audit (separate from Forge test suite)

IS-9 · Authentication Middleware done (transition mode) ​

Problem. Every route handler today receives actor_id from the request body and trusts it without server-side verification. Any caller can impersonate any operator or streamer.

Approach.

  1. On successful PIN verification (POST /api/auth/verify-pin), issue a signed session token (HMAC-SHA256 over { actor_type, actor_id, iat, exp } using a SESSION_SECRET env var). Return it as a Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict header and as a JSON field for mobile/SPA usage.
  2. Add a verifySession(req, env) helper that reads the cookie (or Authorization: Bearer header), verifies the HMAC, and returns { actor_type, actor_id } or null.
  3. Add a Hono middleware that runs before all non-public routes, calls verifySession, and returns 401 on failure.
  4. Remove all actor_id / operator_id / streamer_id from request bodies on protected routes — derive them exclusively from the verified session.

Public routes (exempt from middleware): POST /api/auth/*, GET /api/campaigns, GET /api/campaigns/:id, GET /api/streamers (directory), GET /api/streamers/:u (public profile fields only), POST /api/webhooks/*, GET /api/oauth/*.

Migration path. Can be deployed feature-flagged: new clients send the session cookie, old clients continue to pass actor_id in body during a transition window. Once all clients updated, remove the body fallback.

Implementation status (April 2026):

  • ✅ session.ts — issueSession, verifySession, sessionCookieHeader, resolveActor
  • ✅ Session cookie issued on PIN verify, kick-token, and impersonate/redeem auth flows
  • ✅ Global Hono middleware enforces session when REQUIRE_SESSION_AUTH=true
  • ✅ SESSION_SECRET absent → no-op (transition mode active)
  • ✅ resolveActor() helper — session wins; body fallback in transition mode; null in strict mode
  • ✅ All write handlers use resolveActor (offers PATCH, deals PATCH + POST /deal, negotiations force-agree, campaigns POST, applications POST apply)
  • ⏳ Remaining: set REQUIRE_SESSION_AUTH=true + SESSION_SECRET in production env once frontend is confirmed sending session cookies on all write requests

IS-10 · Opaque Public Identifiers done (core tables) ​

Problem. All resource IDs are sequential integers. An attacker can enumerate every deal, offer, and submission by incrementing the URL integer, discover total platform activity, and cross-reference with missing auth guards to act on foreign resources.

Design. Add a public_id TEXT UNIQUE NOT NULL column to each affected table. Generate on insert via a PostgreSQL function using the Crockford base-32 alphabet (0123456789ABCDEFGHJKMNPQRSTVWXYZ — excludes I, L, O, U to prevent visual confusion). 256 mod 32 = 0 gives perfectly uniform distribution with no rejection sampling. The internal INTEGER primary key is retained for all foreign keys and joins — never exposed in URLs.

TablePrefixExampleStatus
offersOFF-OFF-A3BF9K✅ done
negotiationsNEG-NEG-2WBTZ6✅ done
dealsD-D-K9X3F2✅ done
session_submissionsSS-SS-P1KV9C✅ done
campaignsC-C-4NHJX8✅ done (migration 0094)

Implementation status (April 2026):

  • ✅ generate_public_id(prefix TEXT) PostgreSQL function (migration 0093)
  • ✅ public_id columns added, backfilled, NOT NULL + UNIQUE indexed, DEFAULT set on all five tables
  • ✅ publicId.ts — resolvePublicId() resolver; all route regexes updated
  • ✅ Frontend id types changed number → string for Offer, Negotiation, Deal, SessionSubmission, CampaignApplication, Campaign
  • ✅ Migration 0094_campaign_public_id.sql — C- prefix on campaigns table
TablePrefixExampleStatus
offersOFF-OFF-A3BF9K✅ done
negotiationsNEG-NEG-2WBTZ6✅ done
dealsD-D-K9X3F2✅ done
session_submissionsSS-SS-P1KV9C✅ done
campaignsC-C-4NHJX8✅ done (migration 0094)

IS-11 · Row-Level Ownership Guards done (transition mode) ​

Problem. Even where actor_id is checked, the check trusts the client-supplied value. Once IS-9 is in place (session-derived actor), ownership checks need to be added where missing.

Affected write routes and their required check:

RouteCheckStatus
PUT /api/operators/:slugSession actor is that operator✅
PUT /api/streamers/:uSession actor is that streamer✅
POST /api/streamers/:u/addressesSession is that streamer✅
DELETE /api/streamers/:u/addresses/:idSession is that streamer✅
DELETE /api/streamers/:u/channels/:idSession is that streamer✅
POST /api/streamers/:u/videosSession is that streamer✅
DELETE /api/streamers/:u/videos/:idSession is that streamer✅
POST /api/campaignsSession is operator (resolveActor)✅
PUT /api/campaigns/:idSession operator owns campaign✅
DELETE /api/campaigns/:idSession operator owns campaign✅
POST /api/campaigns/:id/offersSession actor matches initiated_by party✅
PATCH /api/offers/:idSession wins via resolveActor✅
PATCH /api/negotiations/:id/termsSession actor is the named party✅
POST /api/negotiations/:id/agreeSession actor is the named party✅
POST /api/negotiations/:id/force-agreeSession wins via resolveActor✅
POST /api/negotiations/:id/cancelSession actor is party to negotiation✅
POST /api/negotiations/:id/dealSession wins via resolveActor✅
PATCH /api/deals/:idSession wins via resolveActor✅
POST /api/campaigns/:id/applicationsSession wins via resolveActor✅
PUT /api/session-submissions/:idSession operator owns the offer✅

Returns 404 rather than 403 on ownership failure — a 403 confirms the resource exists, which itself leaks information.

All 20 write routes are fully guarded. resolveActor() in session.ts provides the canonical session-first / body-fallback / strict-reject logic across handlers.


IS-12 · Admin Route Protection done ​

Problem. Admin-only routes (POST /api/auth/impersonate, operator/streamer list endpoints, stream session list, webhook subscription management) are accessible without any authentication today.

Approach. Add X-Admin-Secret header validation to admin-designated routes. The secret is already defined as ADMIN_SECRET in env (declared in index.ts) but never checked in handlers.

ts
function requireAdmin(req: Request, env: Env): Response | null {
  const secret = req.headers.get("X-Admin-Secret");
  if (!secret || secret !== env.ADMIN_SECRET)
    return errorResponse("Forbidden", 403);
  return null;
}

Routes requiring admin gate:

RouteReasonStatus
POST /api/auth/impersonateCreates impersonation tokens for any user✅
GET /api/operators (full list)Internal only — public endpoint exposes all operator names/emails✅
GET/POST /api/invitations (all)Creates invitation links✅
GET /api/streams (global list)Internal analytics✅
GET /api/streams/:id/probesInternal stream probe data✅
DELETE /api/webhooks/kick/subscriptions/:idRemoves platform tracking✅
POST /api/webhooks/kick/subscriptions/verifyTriggers Kick API calls✅
POST /api/webhooks/kick/add-unregisteredAdds new tracked streamers✅

The admin frontend already sends X-Admin-Secret from VITE_ADMIN_SECRET. Returns 501 when ADMIN_SECRET env var is unconfigured, 403 otherwise.


Prioritisation ​

ItemPriorityEffortStatus
IS-9 Auth middlewareCriticalMedium✅ done — needs REQUIRE_SESSION_AUTH=true env flip
IS-11 Row-level ownership guardsCriticalMedium✅ done — all 20 routes guarded
IS-12 Admin route protectionCriticalLow✅ done
IS-10 Opaque public IDsHighMedium✅ done — all tables including campaigns
IS-2 Secrets managementHighLow⏳ not started
IS-3 Audit logHighMedium⏳ not started
IS-1 Threat modelMediumMedium⏳ not started
IS-8 External pentestRequired before launchHigh⏳ not started

Remaining work to close the IS-9/10/11 transition ​

  1. Flip strict mode — set REQUIRE_SESSION_AUTH=true and SESSION_SECRET in production env once the frontend is confirmed to be sending session cookies on all write requests. This converts IS-9 and IS-11 from "guarded when session present" to "always enforced". All code gaps are now closed; this is the only remaining step.

IS-2 (Secrets), IS-3 (Audit log), and IS-8 (Pentest) are the remaining pre-launch milestones.


Out of Scope ​

  • End-to-end encryption of chat / negotiation messages (no chat feature planned)
  • SOC 2 certification (V2 consideration)
  • Bug bounty programme (post-launch)

Verifluence Documentation