# SECURITY.md

**Coin Droppa** is a permissionless airdrop arcade. This document is the public threat model and defensive design, written to be readable end-to-end without exposing private operations data.

> tl;dr — Replace fragile `personal_sign` with EIP-712 typed data. Bind every signature to chain + contract + nonce + expiry. Replace `Math.random()` with a CSPRNG. Sprinkle Thinkst-style canary tokens everywhere a scraper would look. Make every tripwire silent to the attacker and loud to me.

---

## 1. Threat Model

### Assets we're protecting
| Asset | Worst case |
|---|---|
| Platform signer private key | Attacker mints unbounded jackpots, drains every vault |
| Per-cabinet ERC20 hoards | Drained without paying the vig |
| Player MATIC (entry fees) | Phished off-protocol via a malicious sig |
| Reputation / floor curation | Sanctioned-address activity in the public feed |
| Protocol uptime | DoS on the signer function takes the whole arcade offline |

### Attackers we expect
1. **Scrapers** — bots iterating the factory, requesting sigs en masse to find the 1% jackpot.
2. **Replay artists** — capture a valid sig, reuse against another cabinet / after the original tx.
3. **Recon / scanner bots** — `/.git/config`, `/admin`, `/wp-login.php`, etc.
4. **Sanctioned addresses** — laundering value through airdrop claims.
5. **Front-runners** — watching mempool for `claim()` calls and re-broadcasting at higher gas (low value here because rewards aren't sequence-dependent, but worth defending).
6. **Supply-chain** — compromised CDN-hosted `ethers.js` swapping the signer.

### Out of scope (for v1)
- Wallet-side phishing of users (mitigated indirectly by EIP-712 domain binding).
- L1/Polygon consensus failures.
- Insider key compromise — addressed via key rotation procedure, not a tripwire.

---

## 2. What Gemini Built (and where it fell short)

The foundation is decent: minimal-proxy factory pattern, signature-gated claims, soulbound badges, dynamic vig tiers. But the security primitives were textbook-fragile:

| Gemini's choice | Why it fails | Fix here |
|---|---|---|
| `wallet.signMessage(keccak(abi.encodePacked(...)))` | Plain `personal_sign`; no domain binding. A sig for one contract can theoretically be valid for another with the same packed-bytes layout. | **EIP-712 typed data**, domain-bound to `chainId` + `verifyingContract`. |
| `Math.random() < 0.01` for jackpot roll | Math.random is a Mulberry32-class PRNG; not crypto-grade. Timing + heuristics make it predictable. | `crypto.randomBytes(4) % 100`. OS CSPRNG. |
| No nonce, no expiry | Sig is replayable until the on-chain `hasClaimed` flag flips. Cross-cabinet replay theoretically possible. | Per-claim 32-byte nonce + 5-min expiry, both signed and verified on-chain. |
| `Access-Control-Allow-Origin: *` | Any site can request a sig for an attacker-controlled address. | Origin allowlist. |
| Verbose error messages (`"BROKE!"`, `"POLYGON CONGESTED"`) | Each is a side-channel: bot learns "address valid, balance too low" vs "RPC down". | Generic `REQUEST_DECLINED` / 429 / 503. |
| No rate limiting | A single bot can burn the entire jackpot probability table in minutes. | Per-(address, IP) sliding window + tarpit on abuse. |
| `approveCabinet()` referenced in admin.html | **Function doesn't exist on the deployed factory.** Cabinets auto-approve. Admin UI was broken. | Frontend hide-list (localStorage) — economic curation, not on-chain. |

---

## 3. Cryptographic Hardening

### 3.1 EIP-712 Signed Claim
```
domain = {
  name: "CoinDroppa",
  version: "2",
  chainId: 137,
  verifyingContract: <cabinet>   // bound per-request
}
types.Claim = [
  { name: "claimer",    type: "address" },
  { name: "multiplier", type: "uint256" },
  { name: "nonce",      type: "bytes32" },
  { name: "expiry",     type: "uint256" }
]
```

The cabinet verifier:
- Recomputes the typed-data hash with its own `address(this)` → sig for cabinet A can't be replayed against cabinet B.
- Rejects `block.timestamp > expiry`.
- Tracks `usedNonces[nonce]` to block replay even within the validity window.

See **CODEX_HANDOFF.md → Phase 2** for the contract migration.

### 3.2 CSPRNG Jackpot Roll
```js
const roll = crypto.randomBytes(4).readUInt32BE(0) % 100;
const isJackpot = roll === 0;
```
4 bytes from `/dev/urandom` (via Node's `crypto`). Modulo bias for `% 100` over a `uint32` is ~10⁻⁸ — negligible.

### 3.3 Origin / CORS Allowlist
`Access-Control-Allow-Origin` echoes back only allowlisted origins (`coindroppa.com`, `localhost:8888`). Any other origin → first allowlisted host, which the browser blocks because it doesn't match. CSP enforces frame-ancestors so the lobby can't be embedded except by `cabinet.html` (which explicitly opts in via per-route header).

### 3.4 Quiet Errors
Every failure mode that previously leaked information now returns one of three opaque responses:
- `400 REQUEST_DECLINED` — malformed input.
- `403 REQUEST_DECLINED` — refused (any reason).
- `429 SLOW_DOWN` — rate-limited.
- `503 RETRY` — RPC outage.

The internal reason is in the logs (and the canary feed for the interesting ones); the client never sees it.

---

## 4. Canary System

**Inspired by [Thinkst Canarytokens](https://canarytokens.org)** — every tripwire is silent to the attacker. They get the response they expected; we get the forensic ping.

### 4.1 Tripwire types

| Tripwire | Trigger | Response to attacker | Severity |
|---|---|---|---|
| **Canary cabinet** | Sig request for a decoy address embedded in the JS bundle | Honeypot sig (looks valid, on-chain rejects) | HIGH |
| **Sanctioned address** | Address on the OFAC list requests a sig | Honeypot sig | CRITICAL |
| **Unknown cabinet** | Well-formed address but not in factory | `403 REQUEST_DECLINED` | LOW |
| **Honeypot endpoint** | Hit on `/admin-v2`, `/.git/config`, `/api/keys`, `/get-signature-v1` | Plausible fake JSON (with embedded canary tokens) | HIGH |
| **Rate-limit abuse** | >20 sig requests in 60s from one (addr, IP) | `429 SLOW_DOWN` | MEDIUM |
| **404 with referrer** | Client-side ping from the 404 page | None | LOW |
| **Orchestrator heartbeat** | Periodic alive-signal | n/a | LOW |

### 4.2 Public vs private decoys
- **Public decoys** (`canary.json`, `canary.html`) — published so legitimate security researchers know to skip them. There are 2 right now; rotate quarterly.
- **Private decoys** (`PRIVATE_CANARY_CABINETS` env) — never disclosed. Any hit is a high-confidence attacker signal.

### 4.3 Alert fan-out
```
fireCanary(evt)
  ├── console.warn("CANARY_TRIPPED", JSON.stringify(evt))   ← Netlify logs
  ├── fetch(CANARY_WEBHOOK_URL, body: discord/slack format)  ← real-time
  └── netlifyBlobs("canary-events").setJSON(...)             ← UI feed
```
HMAC every payload before persisting so log tampering is detectable.

### 4.4 The honeypot signature trick
The most "interview-able" piece: when an attacker requests a sig for a decoy cabinet, we return a sig of identical structure to a real one. The signer is a deterministic burner key (`sha256("coin_droppa_canary_burner_" + cabinet)`), so:
- The response looks valid to the attacker. No "you've been caught" signal.
- The on-chain contract rejects it because `recover(sig) != signerAddress`.
- The attacker burns gas trying to claim. Their on-chain footprint becomes evidence.

---

## 5. Operational Defenses

### 5.1 Autonomous orchestrator
Runs every 5 minutes (Netlify scheduled function). Replaces the "human admin clicks approve" model:
- Audits all cabinets; classifies fresh / hot / idle / drained / expired_raid / defeated_raid.
- Auto-archives cabinets with zero claims after 7 days (off the default lobby).
- Mints `REGULAR` soulbound badges to 7-day streak players.
- Spawns a weekly Prime Boss raid Friday 21:00 ET (gated on `PRIME_RAID_ENABLED`).
- Emits a heartbeat tripwire so we detect *silence*, not just noise.

### 5.2 Rate limiting
Per-(address, IP), sliding 60s window:
- **Soft cap**: 6/min/address, 30/min/IP → `429 SLOW_DOWN`.
- **Hard cap**: 20/min any key → `429` + **canary trip**.

Lambda-local memory; multi-region accuracy requires upgrading to a shared store (Upstash / Netlify Blobs sketch in CODEX_HANDOFF Phase 5).

### 5.3 Security headers (netlify.toml)
- `Content-Security-Policy` — script-src locked to self + jsDelivr (where ethers loads). Tighten further once ethers is self-hosted.
- `X-Frame-Options: SAMEORIGIN` (except `/cabinet.html` which is the embed widget).
- `Referrer-Policy: strict-origin-when-cross-origin`.
- `Permissions-Policy` — disables camera/mic/geo.

---

## 6. What's NOT done yet

These are scoped in `CODEX_HANDOFF.md`. Listed here so I'm not pretending it's complete:

1. **CabinetV2 deployment** — V2 contracts now exist in the repo with on-chain EIP-712 + nonce verification, but they still need to be deployed and used by live cabinets. `TYPED_SIG_FALLBACK=true` keeps legacy vaults working during migration.
2. **Real OFAC feed** — current `SANCTIONED_ADDRESSES` is env-var-driven. Wire to Chainalysis Free Sanctions API.
3. **Cross-region rate limit** — move from lambda-local map to Netlify Blobs / Upstash Redis.
4. **Key rotation runbook** — signer key + treasury key.
5. **WAF in front** — Cloudflare / Netlify Edge function gating obvious bot traffic before it ever reaches the signer.

---

## 7. Review Notes

Things to be ready to talk about:

- **Why canarytokens-style and not just WAF rules?** WAF rules tell attackers "you're blocked." Canaries tell *us* "you tried." Asymmetric — attacker pays the cost of detection.
- **Why honeypot a sig instead of returning an error?** Errors leak the existence of the wire. Honeypot sigs preserve attacker uncertainty AND generate on-chain evidence (gas burn).
- **Why EIP-712 over EIP-191?** Domain binding. A 712 sig is structurally invalid for a different `verifyingContract`. 191 messages are just bytes — a hex-string near-collision in two different contracts is theoretically catastrophic.
- **What's the weakest link still?** The signer key. Compromise = total drain. Mitigations: hardware key + per-cabinet daily mint cap on-chain (Phase 3). Right now we'd notice via the canary heartbeat going dark, but that's slow.
- **What would an adversary go after first?** The Netlify env vars. `SIGNER_PRIVATE_KEY` is the whole game. After that: replay across the legacy fallback path while it's enabled.

Defensive instinct that came from doing offense: **assume the attacker has read every file you've written, including this one.** The private decoys, the HMAC key rotation interval, and the alert webhook are intentionally excluded. This artifact is the brochure, not the runbook.
