Threat Model: STRIDE
Formal STRIDE analysis of Yantra Gaming. Complements the narrative threat
actors in security.md; this document is the structured
form auditors, SOC 2 assessors, and enterprise procurement ask for.
Methodology: STRIDE per component, mitigations traced to code. Each row
is independently reviewable, a reviewer should be able to open the cited
file and see the control in place.
Scope:
- apps/rgs-server (production artefact)
- apps/operator-portal (production artefact)
- packages/operator-sdk (ships to operators)
- Schema, money-path tables, and the HMAC wire protocol
Explicitly out of scope:
- apps/mock-operator: dev-only, never deployed
- apps/game-client: no trust boundary; auth is enforced by rgs-server
- Operator's own KYC/AML/deposit flows
1. Trust boundaries
┌─────────────────────────────────────────────────────────────────────┐
│ Internet │
└─────────────────────────────────────────────────────────────────────┘
│ TLS 1.3 │ TLS 1.3 │ TLS 1.3
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌──────────────┐
│ Operator │◀── HMAC ───▶│ rgs-server │◀─ Session ─│ Player browser│
│ backend │ │ (trust boundary│ JWT │ (low-trust) │
└────────────────┘ │ center) │ └──────────────┘
└──┬─────────┬───┘
│ SQL │ HTTPS + HMAC
▼ ▼
┌──────────┐ operator wallet
│ Postgres │ (external)
└──────────┘
Five trust boundaries:
1. Internet ↔ rgs-server: HMAC-signed, TLS-wrapped HTTP.
2. Player browser ↔ rgs-server: session JWT on socket handshake.
3. rgs-server ↔ operator wallet: HMAC-signed outbound HTTP.
4. rgs-server ↔ Postgres: in-VPC network, TLS if crossing regions.
5. Operator portal ↔ rgs-server admin API: HS256 portal JWT.
2. STRIDE: rgs-server ingress (HMAC + TLS)
| Threat |
Vector |
Mitigation |
Code |
| Spoofing |
Attacker sends a request pretending to be an operator |
HMAC-SHA256 on every /v1/* request, recomputed server-side and compared constant-time. Reject if missing X-Yantra-Key-Id, X-Yantra-Timestamp, or X-Yantra-Signature. |
middleware/operator-auth.ts |
| Tampering |
MitM alters the request body |
Body hash is part of the HMAC canonical string (method \n path \n timestamp \n sha256(body)). Any body mutation produces a signature mismatch. |
utils/signing.ts::signPayload |
| Tampering |
MitM alters the response |
TLS 1.3, responses are not HMAC-signed outbound; TLS integrity suffices. |
Transport |
| Repudiation |
Operator claims they never sent a request |
Every inbound call creates a WalletCall-style audit row (inbound_idempotency for session/admin; WalletCall for outbound). Signed body + timestamp + operatorId make repudiation infeasible. |
prisma/schema.prisma |
| Information disclosure |
Attacker reads traffic |
TLS 1.3 minimum; HSTS max-age=63072000; preload; helmet() defaults with strict CSP default-src 'none'. |
index.ts, docs/security.md |
| Denial of service |
Flood of unsigned requests forces signature verification cost |
Verification is cheap (one HMAC-SHA256). Upstream: global express-rate-limit at 600 req/min/IP; per-operator rate limit; WAF in production fronts all ingress. |
index.ts::rateLimit, middleware/operator-rate-limit.ts |
| Denial of service |
Replay attacker floods cached requestUuids |
InboundIdempotency unique index on (operatorId, endpoint, requestUuid); DB-side dedupe. 24 h TTL keeps table size bounded. |
middleware/idempotency.ts |
| Elevation of privilege |
Attacker crafts a request that bypasses auth middleware to reach a handler |
/v1/* router mounts operatorAuth before any handler; no route escapes. The only unauthenticated routes are /healthz, /readyz, /metrics. |
routes/index.ts |
3. STRIDE: replay and timing
| Threat |
Vector |
Mitigation |
Code |
| Replay (spoofing) |
Attacker captures a valid signed request and resends it |
Timestamp in canonical string + ±30s window rejects stale requests. Idempotency cache on (operatorId, endpoint, requestUuid) blocks re-execution even inside the window. |
middleware/operator-auth.ts, middleware/idempotency.ts |
| Clock skew (DoS) |
Operator clock drifts; legitimate requests rejected |
SIGNATURE_WINDOW_SECONDS tunable (default 30). Production deployments sync against NTP + Chrony. Alert on sustained >10s skew (see observability). |
config.ts, docs/observability.md |
| Timing oracle (info disclosure) |
Attacker measures response time to infer secret bytes |
crypto.timingSafeEqual for all signature/credential/token compares. Non-constant-time JSON parsing is irrelevant, no secret is in JSON body. |
utils/signing.ts, utils/secrets.ts |
4. STRIDE: session JWT (player iframe)
| Threat |
Vector |
Mitigation |
Code |
| Spoofing |
Player forges a session JWT to act as another player |
HS256 signature with SESSION_JWT_SECRET; reject on any decode/verify failure. Claims are bound: operatorId + sessionId + playerRef all cross-checked against the GameSession row. |
middleware/session-auth.ts |
| Tampering |
Player edits JWT claims (e.g., operatorId) |
HS256 integrity breaks on any claim mutation. |
jsonwebtoken library |
| Repudiation |
Player denies placing a bet |
Every bet has a Bet row with playerRef, signed transactionUuid, signed WebSocket connection, and an operator-side /wallet/bet record. Three independent attestations. |
Bet model, WalletCall model |
| Info disclosure |
Another player reads another player's bets |
Socket rooms scoped by session:${sessionId} and operator:${operatorId}:${gameCode}:${currency}. No cross-session reads. |
socket/gameSocket.ts |
| DoS |
Player spams place_bet |
One bet per player per round (§10.5 of game-rules). Per-operator rate limits on the HTTP surface; socket disconnect on misbehaviour. |
services/GameEngine.ts, middleware/operator-rate-limit.ts |
| Elevation |
Player tries to access admin routes |
Session JWT has a distinct secret and claim shape; admin routes use portalAuth with a separate secret. No JWT issued to a player verifies as an admin. |
middleware/portal-auth.ts |
5. STRIDE: outbound wallet call (rgs-server → operator)
| Threat |
Vector |
Mitigation |
Code |
| Spoofing |
Attacker on the operator's ingress pretends to be us |
Same HMAC scheme as inbound, reversed credentials (OperatorCredential.type = WALLET_HMAC_OUTBOUND). Operator verifies before acting. |
wallet/HttpWalletAdapter.ts |
| Tampering |
MitM alters bet/win body in flight |
Body hash in HMAC canonical string (same construction). TLS 1.3 on top. |
utils/signing.ts |
| Repudiation |
Operator claims we never called their wallet |
Every outbound call writes a WalletCall row before the HTTP request fires, updates after. Request + response preserved. |
wallet/WalletClient.ts |
| Info disclosure |
Wallet response body leaks balance of another player |
The RGS does not forward wallet responses to other players. The requesting socket only receives balance_update for its own playerRef. |
socket/gameSocket.ts, services/GameEngine.ts |
| DoS |
Operator is slow → our sessions hang → upstream cascade |
5s timeout on outbound calls → synthetic rollback. CircuitBreaker opens after N consecutive failures per operator; new bets from that operator rejected until recovery. |
wallet/HttpWalletAdapter.ts, wallet/CircuitBreaker.ts |
| Elevation |
Operator exploits a bug in our response handler |
Response is strictly validated against wallet-spec Zod schemas; unknown statuses are treated as retryable-unknown (safe-by-default). |
packages/wallet-spec/src/index.ts |
6. STRIDE: stored secrets
| Threat |
Vector |
Mitigation |
Code |
| Spoofing |
Attacker obtains plaintext from DB dump |
OperatorCredential.cipherBlob is AES-GCM encrypted with SECRETS_MASTER_KEY_B64. Master key injected at runtime, never stored with the DB. |
utils/secrets.ts |
| Tampering |
Attacker modifies encrypted blob to corrupt secret |
AES-GCM authentication tag detects tampering; decrypt throws. |
utils/secrets.ts |
| Repudiation |
n/a |
Every credential has createdAt, notBefore, notAfter, revokedAt: audit trail of issuance and revocation. |
prisma/schema.prisma::OperatorCredential |
| Info disclosure |
Log line contains secret |
Secrets never appear in log lines; redaction is enforced by the logger wrapper. Test: grep CI logs for known secret patterns. |
logger.ts |
| DoS |
Master key loss |
Backed by KMS in production; loss requires restoring from KMS backup or emergency key rotation (§6.2 of runbook). |
docs/runbook.md#62-secrets_master_key_b64 |
| Elevation |
Low-privilege user reads master key from env |
SECRETS_MASTER_KEY_B64 visible only to the process UID. In Kubernetes, injected via a Secret with RBAC scoped to the rgs-server service account. |
Deployment manifest (per-licensee) |
7. STRIDE: RNG and provably-fair
| Threat |
Vector |
Mitigation |
Code |
| Spoofing (cheating) |
RGS publishes an outcome not derivable from committed seed |
Commit-reveal: serverSeedHash published at session start; raw serverSeed revealed post-round. Player can recompute HMAC(serverSeed, clientSeed:nonce) and prove mismatch. |
packages/rng-core/src/, games/<code>/src/outcome.ts, services/ProofService.ts |
| Tampering |
Developer changes the RNG to favour house |
Per-scope change-gate: CI rng-change-gate fails PRs touching packages/rng-core/src/ without CERT-ATTEST-CORE: or games/<code>/src/{outcome,settle,config}.ts without CERT-ATTEST-<GAMECODE>:. Per-game test vectors (games/<code>/fixtures/rng-test-vectors.json) break if the algorithm changes. |
.github/workflows/ci.yml, tests/games/<code>/rng-test-vectors.spec.ts |
| Repudiation |
Operator denies a round outcome |
Round row captures serverSeed, clientSeed, nonce, outcomeSide, outcomeSum. GET /v1/rounds/:id/proof reveals the seed post-settle. Cryptographically non-repudiable. |
routes/rounds.ts, services/ProofService.ts |
| Info disclosure |
serverSeed revealed before rounds that used it are settled |
ProofService gates reveal on round.state ∈ {SETTLED, VOIDED}. |
services/ProofService.ts |
| DoS |
Entropy source exhausted |
OS CSPRNG is effectively non-exhaustible; randomBytes never blocks post-boot. /readyz fails closed during entropy-starved boot. |
packages/rng-core/src/, games/<code>/docs/rng-spec.md §3 |
| Elevation |
Player influences outcome |
serverSeed is secret until reveal; nonce is server-tracked. Player-provided clientSeed is public input and the scheme's security does not depend on its unpredictability. |
packages/rng-core/src/ |
8. STRIDE: multi-tenant isolation
| Threat |
Vector |
Mitigation |
Code |
| Spoofing |
Operator A reads/writes operator B's data |
forOperator(id) Prisma wrapper, every read/write scoped to tenant. Any unscoped query fails code review (grep-based check). Per-operator EngineRegistry instance. |
db.ts, services/EngineRegistry.ts |
| Tampering |
Portal user from operator A edits operator B's config |
portalAuth middleware resolves operatorId from the portal JWT; every admin route filters by that operatorId. |
middleware/portal-auth.ts |
| Info disclosure |
Log/metric line contains another operator's data |
Metric labels include operator dimension but no PII. Logs key by operatorId (opaque slug). |
telemetry/metrics.ts, logger.ts |
| DoS |
Operator A's circuit-breaker trip starves operator B |
CircuitBreaker is per-operator; one trip does not affect others. EngineRegistry runs one engine per operator × game × currency. |
wallet/CircuitBreaker.ts, services/EngineRegistry.ts |
| Elevation |
Portal user for op A becomes a super-admin and sees op B |
Super-admin role (KETAPOLA_STAFF) is a distinct claim in the portal JWT; granted only via DB-side role assignment. Audited by OperatorConfigAuditLog. |
middleware/portal-auth.ts, prisma/schema.prisma::OperatorUser |
9. STRIDE: operator portal (admin)
| Threat |
Vector |
Mitigation |
Code |
| Spoofing |
Credential stuffing on portal login |
bcrypt password hash; login rate-limited; MFA recommended (not shipped, operator-specific SSO integration). |
routes/admin-auth.ts |
| Tampering |
XSS → session hijack → config change |
Admin API is JSON-only; portal is a React SPA with a strict CSP; all config edits write to OperatorConfigAuditLog with portalUserId. |
index.ts::helmet, prisma/schema.prisma |
| Repudiation |
Portal user denies a config change |
Every change captured in OperatorConfigAuditLog: userId, oldValue, newValue, at. |
routes/admin.ts |
| Info disclosure |
Portal user reads another operator's round audits |
Scoped to the portal JWT's operatorId. Super-admin gets explicit operator-switch UI with audit. |
middleware/portal-auth.ts, routes/admin.ts |
| DoS |
Portal user floods the admin API |
Per-operator rate limit. Admin-heavy routes (e.g., report export) are debounced at the UI. |
middleware/operator-rate-limit.ts |
| Elevation |
SQL injection via config edit |
Prisma parameterised queries; Zod validation on every input; no string concatenation into raw SQL except the TRUNCATE in test harness. |
routes/admin.ts, prisma/* |
10. LINDDUN: privacy threats
STRIDE covers confidentiality, integrity, availability. For GDPR / MGA PPD /
AGCO 2.10 / Brazil SPA purposes, privacy-specific threats are modelled
separately using LINDDUN (Linkability, Identifiability, Non-repudiation,
Detectability, Disclosure of information, Unawareness, Non-compliance).
Scope: player-level data flows through the RGS. Operator staff data is
covered by §9 STRIDE-portal.
| LINDDUN category |
Threat |
Mitigation |
Code / policy |
| L, Linkability |
Multiple rounds from the same player can be linked across sessions to build a behavioural profile |
Only playerRef (operator-opaque id) is stored on the RGS; no PII (name, email, IP beyond session-scoped rate-limit audit). Linkability exists by design within one operator (that's the RGS's job), but not across operators, operatorId scopes every query. |
prisma/schema.prisma: no PII columns on GameSession / Round / Bet |
| L, Linkability |
IP address in rate-limit logs could be linked to playerRef |
Rate-limit logs expire in Redis at 1-hour TTL; long-term audit rows do not store IP. |
middleware/operator-rate-limit.ts |
| I, Identifiability |
playerRef is a UUID from the operator, but some operators use email hashes, identifying |
Contract: playerRef MUST be an opaque identifier. Docs in integration-guide.md explicitly forbid reusing email / username as playerRef. |
docs/integration-guide.md |
| I, Identifiability |
RGS logs contain playerRef + outcome + amount, enough to identify a specific player if combined with operator data |
playerRef is only ever shared with the operator who minted it. Cross-operator reporting aggregates at the operatorId level and never emits playerRef. |
routes/reports.ts |
| N, Non-repudiation (flip side) |
GDPR Article 17 right to erasure vs GLI-19 7-year retention |
Resolved by pseudonymisation: the RGS only stores playerRef. Deletion of the PII → playerRef mapping in the operator wallet leaves the RGS ledger intact but non-attributable. The RGS never receives, and never needs to delete, identifying information. |
docs/runbook.md: "Handling Article 17 requests" |
| D, Detectability |
Existence of a player record could be inferred (timing, error responses) |
Session-create and wallet-bet responses are constant-shape and constant-time-ish, same response for "player doesn't exist" as for "player exists but session denied". Timing channels are minimised by the constant-time signature compare. |
routes/session.ts, utils/signing.ts |
| Di, Disclosure |
Backup / DR replica exfiltration |
Postgres backups are encrypted at rest (KMS-managed); inter-region replication uses TLS 1.3; backup retention policy documented in runbook.md. |
docs/runbook.md: "Backup + DR" |
| Di, Disclosure |
Log aggregation (Loki / ELK) captures structured logs containing playerRef + bet amounts |
Structured-log sink is tagged with the same GDPR retention policy as the primary DB; playerRef is the same opaque id; logs never contain raw PII. |
telemetry/logger.ts |
| U, Unawareness |
Player does not know the RGS logs every bet and round |
Operator's privacy policy discloses the RGS as a processor; documented in integration-guide.md under "Data-processing agreement template"; MGA PPD 2024 + UKGC LCCP require this disclosure on the operator side. |
Contractual |
| Nc, Non-compliance |
Data-residency rule violated (e.g. Germany player data stored in US) |
Per-operator Operator.dataResidencyRegion column selects the primary DB region at engine-instance creation. Multi-region Postgres (planned v1.1) enforces; single-region deployments default to residency-compliant region per operator. |
services/EngineRegistry.ts: regional routing (planned v1.1) |
| Nc, Non-compliance |
RG limits not enforced per regulation |
jurisdiction-rules ruleset enforces per-jurisdiction floors at bet time; breach of limit is rejected with a specific RGLimitBreach reason; rejection is webhook-notified to the operator. |
services/RGLimitsEnforcer.ts, packages/jurisdiction-rules/ |
LINDDUN review cadence matches STRIDE: per-release, annual, and on-incident.
A LINDDUN-specific review is triggered by any schema change that touches
GameSession, Round, Bet, WalletCall, or any new log field that might
carry player-identifiable information.
11. Open risks (not mitigated in this repo)
| Risk |
Why open |
Decision |
| No MFA on portal logins |
Operator-specific SSO; out of scope |
Document as a requirement for licensees |
| HS256 JWTs for operator-launch request |
Shared secret; vulnerable if leaked |
§9 of B2B_ROADMAP documents RS256+JWKS migration path |
No rate limit on /metrics |
Prometheus scrape needs unauth access |
Scope via network ACL, /metrics should only be reachable from the monitoring VPC |
| No bot-challenge on player socket connect |
Socket.IO connection requires a valid session JWT, already gated upstream |
Challenge would be at session-creation; Cloudflare Turnstile optional via env |
| No formal pen-test artefacts in-repo |
Pen-testing is an operational programme, not an engineering deliverable. Annual third-party pen-test + rolling bug bounty are tracked in B2B_ROADMAP.md §16 |
Commissioned per deployment; reports held under NDA outside the repo |
12. Review cadence
- Per-release: review §2–§9 STRIDE tables and §10 LINDDUN table for new attack surface.
- Annual: full re-walk with a fresh reviewer.
- On incident: if an incident exposes a gap, add the row to the relevant
§2–§10 table as part of post-incident review (per incidents.md).
13. Version
| Field |
Value |
| Model version |
1.1.0 |
| Methodology |
STRIDE-per-component + LINDDUN (privacy) |
| Last reviewed |
2026-04-24 |
| Owner |
Security lead (joint with compliance for SOC 2 + GDPR scope) |