# Security Architecture Audit — 2026-05-18

**Scope:** Public-facing security claims on `/docs/security` are now reproducible. Every section of the docs page maps to a row in this audit; every row points at the file + line range where the implementation lives; every implementation can be verified by reading the linked source.

**Methodology:** Defense-by-inspection. The goal is that any third party — an operator auditor, a journalist, a delegator considering FlareWatch, or a future maintainer — can verify each claim without our help, in under five minutes per claim, by reading the linked source.

**Companion audit cadence:** Monthly probe runs documented in [`docs/audits/02-security.md`](./02-security.md). The most recent full-site audit landed on 2026-05-12; this document layers a transparency proof on top of it.

---

## 1. Threat model — what we defend against, and what we do not

**What FlareWatch is:**
- A read-only blockchain dashboard for Flare Network.
- Authenticates wallets via [Sign-In With Ethereum (SIWE, EIP-4361)](https://eips.ethereum.org/EIPS/eip-4361).
- Does NOT custody funds. The website never holds private keys; the site never signs transactions.
- The site has one mutation surface: writing per-wallet preferences (push subscriptions, nicknames, watched-wallet lists) to its own KV store after a SIWE signature proves the caller owns the address.

**What we defend against (in scope):**
- Account impersonation — an attacker producing a SIWE signature that isn't from the real wallet owner.
- Session hijacking — stealing or forging the auth cookie to act as the user.
- Per-wallet data exfiltration — a user reading or writing another user's KV records.
- Endpoint abuse — DoS, scraping, server-side abuse via unauthenticated reads.
- Supply-chain compromise — dependencies, fonts, scripts, GitHub Actions.
- Cron-route abuse — unauthorized triggering of scheduled jobs.
- Admin-surface privilege escalation — non-allowlisted wallets gaining admin access.

**What we do NOT defend against (out of scope):**
- A compromised user wallet — if a user's wallet private key is stolen, FlareWatch cannot prevent the resulting account takeover. The SIWE signature would be valid.
- Browser-side malware on the user's device — keyloggers, malicious extensions, fake wallet popups. These compromise the wallet, which is upstream of us.
- Custodial loss — we do not custody, so this is structurally impossible.
- Phishing sites impersonating FlareWatch — domain registration and Cloudflare configuration mitigate; we cannot enforce browser-level URL discipline.

---

## 2. Authentication — Sign-In With Ethereum

### 2.1 Canonical SIWE statement pinned

**Claim:** Every SIWE message must contain a single, exact statement string. Any deviation is rejected.

**Implementing file:** `services/auth/siweVerify.ts` line 77 defines `FLAREWATCH_SIWE_STATEMENT` as a constant. Line 87 enforces equality (`message.statement !== FLAREWATCH_SIWE_STATEMENT` → reject).

**Why it matters:** Defends against an attacker reusing a SIWE signature obtained for a different application (e.g. a phishing dapp that asked the user to sign "Welcome to ImposterSite"). The statement is part of the signed bytes, so a signature valid on a different statement is not valid on ours.

**Verify yourself:**
1. Open `services/auth/siweVerify.ts`.
2. Search for `FLAREWATCH_SIWE_STATEMENT`. Confirm it's a constant, not derived from user input.
3. Find the check in `verifyMessage()` that compares the incoming `message.statement` against the constant.

### 2.2 Domain allowlist enforced

**Claim:** The SIWE message's `domain` field must match an environment-pinned allowlist (e.g. `flarewatch.io`). Cross-origin or attacker-controlled domains are rejected.

**Implementing file:** `services/auth/siweVerify.ts` lines 32–73 build the allowlist from `SIWE_ALLOWED_DOMAINS` environment variable. Lines 99–106 reject any message whose domain isn't in the allowlist.

**Why it matters:** Closes the same-origin-policy gap on signature reuse. If the user signed a message claiming to be from `evil.com`, the signature is invalid on `flarewatch.io` because the bytes signed include `evil.com` in the domain field.

**Verify yourself:**
1. Read the `SIWE_ALLOWED_DOMAINS` env var validation in `lib/env.ts`.
2. Trace `verifyMessage()` and find the `if (!allowedDomains.has(message.domain))` reject path.

### 2.3 Chain ID pinned

**Claim:** SIWE message `chainId` must equal Flare's chain ID (14). Messages for any other chain are rejected.

**Implementing file:** `services/auth/siweVerify.ts` line 66 (constant) and the verification check that follows.

**Why it matters:** Defends against signature reuse from a different chain (e.g. Ethereum mainnet, Sepolia, an L2). A message signed for chain 1 won't pass verification for chain 14.

### 2.4 Expiration required

**Claim:** Every SIWE message must carry an `expirationTime` field. Messages without one are rejected; messages whose `expirationTime` has passed are rejected.

**Implementing file:** `services/auth/siweVerify.ts` lines 84–91 enforce the field presence and freshness check.

**Why it matters:** Limits the replay window for a captured signature.

### 2.5 Message and signature size caps

**Claim:** SIWE messages > 4096 bytes are rejected. Signatures > 256 chars are rejected.

**Implementing file:** `services/auth/siweVerify.ts` lines 28–29 define `MAX_MESSAGE_LENGTH = 4096` and `MAX_SIGNATURE_LENGTH = 256`; lines 52–55 enforce them at entry.

**Why it matters:** Defense-in-depth against parser DoS (oversized inputs) and against unbounded compute in the signature verifier.

### 2.6 Nonce lifecycle

**Claim:** Each SIWE attempt consumes a fresh, server-issued, single-use nonce. Replay is impossible because the nonce is atomically deleted on consumption.

**Implementing file:**
- Issuance: `services/auth/nonce.ts` line 12 (`siweGenerateNonce()` from the `siwe` library).
- Storage: KV with 5-minute TTL — `NONCE_TTL_SECONDS = 300` at line 4.
- Atomic consumption: line 20 uses `kv.getdel()` — a single-round-trip "get the value and delete it" KV operation. No TOCTOU window.
- Verification ordering: `services/auth/siweVerify.ts` lines 110–134 verify the signature BEFORE consuming the nonce. If verification fails, the nonce stays in KV and is consumed by a later successful attempt. If verification succeeds, the nonce is consumed and cannot be reused.

**Verify yourself:**
1. Open `services/auth/nonce.ts`. Confirm the consume path uses `getdel`, not separate `get` + `del` calls.
2. Open `services/auth/siweVerify.ts`. Trace: `verifySignature()` (line ~113) → `consumeNonce()` (line ~126). Confirm signature comes first.

---

## 3. Sessions

### 3.1 Cookie configuration

**Claim:** Auth cookies are HTTP-only, Secure (in production), with `__Host-` prefix and `sameSite` attributes that prevent CSRF.

**Implementing file:** `services/auth/jwt.ts` lines 18–58:
- Auth cookie: `__Host-` prefix in production (line 53), `sameSite=lax`, 24-hour TTL.
- Refresh cookie: `__Host-` prefix in production, `sameSite=strict`, 7-day TTL (Safeguard S136 parallel cookies).
- Both are `httpOnly: true` and `secure: true` in production.

**Why it matters:**
- `httpOnly` prevents JavaScript (and therefore XSS payloads) from reading the cookie.
- `Secure` ensures the cookie never travels over plain HTTP.
- `__Host-` enforces secure + path=/ + no Domain attribute (Safeguard 64a hardening).
- `sameSite=strict` on the refresh cookie blocks all cross-site requests from carrying it.
- `sameSite=lax` on the auth cookie permits top-level GET navigations (for proper UX) while blocking POST CSRF.

### 3.2 Session revocation

**Claim:** Signing out invalidates the session globally. A revoked session's cookie cannot be reused even if previously captured.

**Implementing file:** `services/auth/sessions.ts` lines 59–78 (`revokeSession()`) writes a tombstone record with `revoked: true`. Every authenticated request reads the session record before honoring it (`getAuthSession()` checks the revoked flag).

**Why it matters:** Stolen cookies become useless the moment the user signs out. Server-side revocation, not client-side cookie clearing.

**Verify yourself:**
1. Trace `/api/auth/logout` handler. Confirm it calls `revokeSession()`.
2. Trace `getAuthSession()`. Confirm it reads the session record and returns null if `revoked === true`.

### 3.3 Quick-unlock (PIN)

**Claim:** Local PIN unlock for returning visitors uses PBKDF2-SHA256 with 310,000 iterations. Five failed attempts trigger a 15-minute lockout. Comparisons are timing-safe.

**Implementing file:** `services/auth/quickUnlock.ts`:
- Hash function: PBKDF2-SHA256 with `PBKDF2_ITERATIONS = 310_000` (line 49, in hash function at line 154).
- Lockout: `MAX_PIN_ATTEMPTS = 5`, `LOCKOUT_DURATION_SECONDS = 900` (lines 47–48), enforced at lines 225–226.
- Timing-safe comparison: `crypto.timingSafeEqual` in the verify path.

**Why it matters:**
- PBKDF2 with 310k iterations meets [OWASP 2023 recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) for password storage.
- 5-attempt lockout + 15-minute window makes online brute-force impractical (a 4-digit PIN would take days to enumerate, a 6-digit PIN longer than a typical attacker session).
- Timing-safe comparison closes the timing-side-channel on hash comparison.

---

## 4. Per-wallet ownership gating

**Claim:** Every API route that reads or writes per-wallet KV data verifies the caller owns the wallet address being accessed. Cross-user data leak is impossible.

**Implementing file:** `services/auth/requireWalletOwnership.ts` lines 13–30. The function is called at the entry of every KV-touching route.

**Why it matters:** Defends Safeguard 64b. Without this gate, a signed-in user could read or modify another wallet's preferences, push subscriptions, or watched-wallet lists.

**Address normalization (Safeguard 52):** Every comparison uses `.toLowerCase()` before equality check. KV keys are stored lowercase. This makes the check case-insensitive but byte-equal — there's no representation where one wallet's data can be reached by a similar-looking address.

**Verify yourself:**
1. Open `services/auth/requireWalletOwnership.ts`. Confirm both addresses are lowercased before comparison.
2. Pick a sensitive route — e.g. `app/api/push/subscribe/route.ts`. Confirm it calls `requireWalletOwnership()` before any KV operation.
3. Try to fetch `/api/wallet/<other-wallet-address>/preferences` from a signed-in browser. The route returns 403.

---

## 5. Admin surface

**Claim:** All `/api/admin/*` routes and the `/admin` page are gated by an explicit allowlist of admin wallet addresses (`ADMIN_WALLETS` in `constants/wallets.ts`). Adding an admin requires a code deploy, not a runtime configuration change.

**Implementing file:**
- Gate function: `services/auth/requireAdminWallet.ts` lines 24–42. Discriminated-union return shape so API routes can map the result to a NextResponse.
- Allowlist source: `constants/wallets.ts`. Values are canonical-lowercase per the file's leading comment.
- Page gate: `app/admin/page.tsx` does an inline check against the same constant.
- Manual cron-fire endpoint: `app/api/admin/cron-fire/route.ts` requires `requireAdminWallet()` + proxies `CRON_SECRET` to the destination cron route.

**Why it matters:**
- A KV compromise cannot grant admin access — the allowlist is in source code.
- An attacker who compromises an admin wallet still cannot move funds (FlareWatch is non-custodial); the worst they can do is fire cron jobs early, edit feature flags, or read aggregated KV stats.

**Verify yourself:**
1. Read `constants/wallets.ts`. The exact addresses on the allowlist are public (visible in the repo).
2. Pick any `/api/admin/*` route. Confirm `requireAdminWallet()` is called before any side effect.
3. Pick the `/api/admin/cron-fire` route. Confirm it both gates on admin AND validates that the `cron` parameter is a known `CRON_NAMES` value before invoking.

---

## 6. HTTP headers

**Implementing file:** `next.config.mjs` lines 57–122 set the application headers. `vercel.json` may also set headers at the edge (verify both surfaces).

**Headers and their proofs:**

| Header | Value | Defense | File line |
|---|---|---|---|
| Strict-Transport-Security | `max-age=31536000; includeSubDomains; preload` | Forces HTTPS for 1 year, eligible for browser preload lists | ~102 |
| X-Frame-Options | `DENY` | Prevents the site from being framed (clickjacking) | ~107 |
| Referrer-Policy | `strict-origin-when-cross-origin` | Strips referrer URLs on cross-origin requests | ~110 |
| X-Content-Type-Options | `nosniff` | Prevents MIME-type sniffing | — |
| X-Permitted-Cross-Domain-Policies | `none` | Blocks legacy Adobe Flash / PDF cross-domain access | — |
| Permissions-Policy | 22 features explicitly denied | Disables sensor APIs (camera, mic, geolocation, USB, etc.) | ~153 |

**Frame-ancestors (CSP):** `'none'` — explicitly disallows any embedding. Two layers of clickjacking defense (X-Frame-Options + frame-ancestors).

---

## 7. Content Security Policy — honest tradeoffs

**Claim:** CSP is configured to restrict script origins, connection origins, and frame ancestors.

**Implementing file:** `next.config.mjs` lines 57–87.

**Honest caveat — this is the most important section of this audit:**

The CSP directive `script-src` includes both `'unsafe-inline'` and `'unsafe-eval'`. The directives are present **by deliberate necessity**, not oversight:

- **`'unsafe-inline'`** is required by React's hydration model. React injects inline `<script>` tags during the streaming-SSR hydration handshake. Without `unsafe-inline`, the entire site fails to hydrate.
- **`'unsafe-eval'`** is required by:
  - WalletConnect's relayer client, which uses `eval()` for dynamic message-codec construction.
  - TradingView's charting library, which uses `Function()` constructors internally.

**What this means in plain English:** CSP does NOT prevent XSS arising from injected inline scripts on this site. If an attacker can inject HTML into a server-rendered page (via a dependency vulnerability, an edge-function misconfiguration, or a stored-XSS bug in the codebase), inline `<script>` tags in that HTML WILL execute.

**Defense remaining:**
- We do not echo user-controlled content into HTML without React's automatic escaping. React's JSX renderer escapes string interpolations by default.
- We never use `dangerouslySetInnerHTML` with user-controlled content. Audit: grep `dangerouslySetInnerHTML` in the repo. If any uses appear, they should be reviewed against this audit.
- Dependencies are scanned weekly via Dependabot.
- Bundle is built by Vercel from a verified GitHub commit; no CDN or runtime script injection surface.
- Edge config and Cloudflare have no rewriting rules that could inject HTML.

**What we are NOT going to do (and why):**
Switching to a strict CSP using `nonce`-based script allowlisting would require:
- Rewriting React hydration to use the `nonce` attribute (possible with Next.js, but introduces hydration nonce mismatches).
- Patching WalletConnect to drop `eval()` (upstream issue, no fix path).
- Patching TradingView to drop `Function()` (closed-source, no fix path).

The aggregate cost is days of work, with high regression risk, in exchange for a defense (inline-XSS blocking) that we already have via React's automatic escaping and the absence of `dangerouslySetInnerHTML`. Not worth the trade.

**This caveat must appear on the public security page.** Documenting our limits is the credibility move; pretending we have a clean CSP would be misleading.

---

## 8. Deliberate omissions — COOP and CORP

**Claim:** Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy are NOT set to `same-origin`. This is intentional.

**Implementing file:** `next.config.mjs` lines 170–191. The comment block explains the removal.

**Why:**
- COOP `same-origin` breaks WalletConnect's wallet-popup window flow (a separate browsing context that must communicate with the opener).
- CORP `same-origin` breaks TradingView's iframe embed and prevents cross-origin assets from loading.
- Wallet connect popups (MetaMask, Trust, Rabby) all rely on cross-origin window communication.

**Defense remaining:**
- X-Frame-Options DENY + CSP frame-ancestors 'none' prevent clickjacking.
- The popup/iframe surfaces are isolated and don't carry our auth cookies (cross-origin sameSite=strict on refresh cookie, sameSite=lax on auth cookie).

**This omission must appear on the public security page.** An auditor reading our headers will notice COOP/CORP absent; we explain why rather than leaving it unexplained.

---

## 9. Rate limiting

**Implementing file:**
- Engine: `lib/rateLimit.ts`.
- Bucket registry: `constants/rateLimits.ts` lines 19–150.

**Bucket inventory (representative — full list in constants):**

| Bucket | Limit | Window | Purpose |
|---|---|---|---|
| `auth:nonce` | 5/min | per IP | Auth issuance — tightest |
| `auth:verify` | 5/min | per IP | Auth verification — tightest |
| `quick-unlock-challenge` | 5/min | per IP | PIN unlock challenges |
| `mirror-attribution` | 60/min | per IP | Per-tx MIRROR attribution lookups |
| `epoch-reward-attribution` | 60/min | per IP | Per-tx EPOCH_REWARD attribution lookups (new 2026-05-18) |
| `track` | (default) | per IP | Pageview + entry-event beacons |
| `mirror-backfill-progress` | 600/min | per IP | Polling-heavy KV reads |
| Plus dozens more covering KV, RPC proxies, cron diagnostics, etc. | | | |

**IP-extraction policy:** `extractClientIp()` reads `X-Forwarded-For` (first IP) then `X-Real-IP` then falls back to the literal string `"unknown"`. On Vercel (the only deployment target), Vercel's edge sets `X-Forwarded-For` reliably, so identifier collisions on `"unknown"` are not a real-world concern.

**Failure mode:** If KV is unreachable, the rate-limit check fails open (logs a warn, permits the request). This is deliberate — we'd rather serve requests than deny everyone during a KV outage. Sensitive operations have additional gates (auth signature verification, ownership checks) that don't fail open.

**Verify yourself:**
1. Read `constants/rateLimits.ts`. Confirm every sensitive bucket has an explicit entry.
2. Pick any `/api/auth/*` route. Confirm it calls `checkRateLimit("auth:...", ip)` before any work.

---

## 10. Audit logging

**Claim:** Every successful sign-in and sign-out is logged with a truncated IP, a truncated user-agent, and a timestamp. Retention is 30 days. Reads of the audit log require wallet ownership.

**Implementing file:** `services/auth/auditLog.ts`:
- Key format: `audit:{address.toLowerCase()}` (line 19).
- Event shape: `{ type: 'sign_in' | 'sign_out', ts: number, ip?: string }` writes; ring-buffer max 50 events per wallet.
- IP truncation: line 22–29. IPv4 → first 2 octets (`192.168.x.x`-shaped). IPv6 → first 4 groups.
- UA truncation: line 37, 80-character cap.
- TTL: `TTL_SECONDS = 30 * 24 * 60 * 60` (line 16).

**Why those exact truncations:**
- /16 CIDR is coarse enough that an attacker reading the log can't pin the user's home address (only a city-level region), but specific enough that an operator investigating an incident can spot anomalies (e.g., sign-ins from two different /16 ranges in a single day).
- 80 characters of UA captures the browser family and major version without exposing fingerprintable extensions or version-string minor revisions.

**Read access:** `/api/auth/audit-log` requires `getAuthSession()` + the session's address must match the requested address. No cross-wallet audit-log reads.

---

## 11. Cron-route authentication

**Claim:** All 15 scheduled cron routes verify the `Authorization: Bearer ${CRON_SECRET}` header using a timing-safe comparison. Unauthenticated requests are rejected.

**Implementing file:** `lib/cronAuth.ts`. The `validateCronAuth()` function uses `crypto.timingSafeEqual()` after a length-equality precheck.

**Verified coverage (2026-05-18):** All 15 routes in `app/api/cron/*` include `validateCronAuth()` at handler entry. Confirmed by:

```sh
for f in app/api/cron/*/route.ts; do
  if ! grep -q "validateCronAuth" "$f"; then
    echo "MISSING: $f"
  fi
done
```

Output: empty. No missing routes.

**CRON_SECRET handling:**
- Lives only in Vercel project env, never committed.
- Never logged.
- Proxied by `/api/admin/cron-fire` (manual cron trigger) using the same Authorization header pattern.

**Rotation:** Rotate when an operator with access to CRON_SECRET leaves. Procedure: generate a new 32-byte hex string, update the Vercel env var, redeploy. There is no "previous secret" fallback for crons because cron schedules are bound to the current secret — rotation is atomic, schedule remains valid post-rotation.

---

## 12. Push notifications

**Implementing file:** `services/push/`, `app/api/push/subscribe/route.ts`, `app/api/push/preferences/route.ts`, etc.

**Claims:**
- Web Push subscriptions are stored per-wallet under `wallet:{address}` records, not in a global table.
- Subscribe / preferences routes call `requireWalletOwnership()` before any KV write.
- VAPID public key is in the client bundle (required by Web Push protocol); VAPID private key stays server-only and signs every push notification.
- Per-device and total-device caps prevent subscription bloat (`PUSH_MAX_PER_DEVICE`, `PUSH_MAX_TOTAL`).
- Subscriptions are deduplicated by `endpoint + p256dh key` to heal browser-side endpoint rotation events.

**Verify yourself:**
1. Read `app/api/push/subscribe/route.ts`. Confirm `requireWalletOwnership()` is called.
2. Read `lib/env.ts`. Confirm `VAPID_PRIVATE_KEY` is in the server-only env block (no `NEXT_PUBLIC_` prefix); `NEXT_PUBLIC_VAPID_PUBLIC_KEY` is in the public block (correct for protocol).

---

## 13. Secrets management

**Implementing file:** `lib/env.ts` lines 17–121.

**Claims:**
- Server-only secrets live in the env block validated at module load: `SIWE_JWT_SECRET`, `SIWE_JWT_SECRET_PREVIOUS` (for rotation), `CRON_SECRET`, `VAPID_PRIVATE_KEY`, `PUSH_NOTIFICATION_SECRET`, third-party API keys (Etherscan, Flarescan, GitHub).
- `validateEnvironment()` runs at module load and throws if any REQUIRED var is missing in production.
- No secrets are written to client bundles. Public variables MUST carry the `NEXT_PUBLIC_` prefix (Next.js convention enforced by the framework).
- No secrets are logged. The structured logger redacts known secret-shaped values.

**JWT secret rotation:**
- Two parallel secrets, `current` and `previous`. New tokens carry a `kid` header with the current secret's fingerprint.
- During verification, the `kid` selects which secret to use. Tokens issued before rotation continue to verify against `previous` for a grace window.
- After the grace window passes, `SIWE_JWT_SECRET_PREVIOUS` is unset and old tokens stop verifying — by design.

**Verify yourself:**
1. `grep -rn 'sk_live_\|sk_test_\|secret.*=.*"' --include='*.ts' --include='*.tsx'` should return only env-var references, never literal values.
2. Read `lib/env.ts`. Confirm `validateEnvironment()` is called at module load (line ~70).

---

## 14. Supply-chain security

**Dependencies:** Scanned by Dependabot continuously. Open alerts are tracked as tasks. High/critical advisories block deploys via CI gate.

**GitHub Actions:** Third-party actions are pinned to commit SHA, not tag. Policy documented in `CLAUDE.md`.

**npm provenance:** CI checks `npm audit --severity high` on every build; non-zero exit fails the build.

**SBOM:** CycloneDX SBOM generated per build (artifact only; not yet enforced as policy).

**Self-hosted assets:**
- Inter font: self-hosted via `next/font/google` (downloaded at build time, served from same origin). Verify: no `fonts.googleapis.com` requests in network panel.
- Satoshi font: self-hosted via `next/font/local`. Verify: font files in `public/fonts/`.
- Wallet kit JS: bundled with the app, not loaded from CDN.

**Verify yourself:**
1. Open browser devtools. Visit any FlareWatch page. Filter network panel by domain. Confirm no requests to `googleapis.com`, `fontshare.com`, `cdnjs.cloudflare.com`, etc.
2. View page source. Confirm no `<script src="https://...">` tags pointing at external CDNs (TradingView is the one exception — it loads its charting library from `s3.tradingview.com`, allowlisted in CSP).

---

## 15. Read-only address browsing

**Claim:** A visitor can paste any 0x address into the landing page input and view that wallet's public on-chain data (staking, yields, rewards, DeFi positions). Doing so does NOT expose any of that address owner's private data (push subscriptions, audit log, sign-in events).

**Implementing file:**
- Read-only state: client-only, set via `services/readOnlyWallet.ts` (`addReadOnlyWallet()` updates the Zustand store; no server roundtrip on paste).
- Server-side gating: any server API that returns wallet-specific stored data (push prefs, watched-wallet lists, nicknames, audit log) calls `requireWalletOwnership()` and refuses without a SIWE session matching the address.
- On-chain reads (staking, FTSO rewards, DeFi positions, P-Chain delegations) are public chain state, available to anyone with an RPC connection. We surface that data via our pruned-node RPC + indexer + multicall pipeline.

**Why this is safe:**
- The set of data exposed to read-only viewers is exactly the set of data exposed to anyone running their own RPC node. We're a presentation layer over public chain state.
- Stored preferences (push tokens, nicknames) require SIWE authentication to read. A read-only viewer gets `null`-equivalents for those fields.

---

## 16. How to audit us

The FlareWatch repository is **currently private**. The verification steps in this section are written for two audiences:

- **Auditors with source access** (operator, future contributors, on-request external reviewers). Steps 1–8 below are runnable against a local checkout of the repo.
- **Outside observers without source access** (delegators, journalists, casual researchers). The publicly-observable checks listed at the end of this section don't require source access — they verify our HTTP-layer claims from any browser or terminal.

### Requesting source access

Serious security researchers, grant reviewers, journalists, and prospective auditors can request read-only repository access by emailing `hello@flarewatch.io` with a brief note on intent. We grant access under a standard responsible-disclosure understanding; we have not turned down any good-faith request to date.

### Eight-step source-side verification (for auditors with access)

1. **Clone the repo.** `git clone <your-granted-URL> && cd flarewatch`.
2. **Verify SIWE statement is pinned.** Open `services/auth/siweVerify.ts`. Read the `FLAREWATCH_SIWE_STATEMENT` constant and the equality check that consumes it.
3. **Verify nonce atomicity.** Open `services/auth/nonce.ts`. Confirm `kv.getdel()` (single op), not `get` + `del`.
4. **Verify cron auth coverage.** Run `for f in app/api/cron/*/route.ts; do grep -q "validateCronAuth" "$f" || echo "MISSING: $f"; done`. Output should be empty.
5. **Verify admin gate coverage.** Run `for f in app/api/admin/*/route.ts; do grep -q "requireAdminWallet" "$f" || echo "MISSING: $f"; done`. Output should be empty.
6. **Verify ownership gate coverage on KV writes.** Pick any sensitive route under `app/api/wallet/`, `app/api/push/`, `app/api/auth/`. Trace through to the first KV operation and confirm `requireWalletOwnership()` is called first.
7. **Verify no hardcoded secrets.** Run `grep -rn 'sk_live_\|sk_test_\|Bearer [A-Za-z0-9]\{40\}' --include='*.ts' --include='*.tsx' app/ services/ lib/`. Output should be empty.
8. **Verify no external script CDNs (except TradingView).** Run `grep -rn 'script src="https' --include='*.tsx' app/ components/`. Output should be empty (TradingView loads dynamically, not as a static tag).

### Publicly-observable checks (no source access required)

These verify our HTTP-layer security posture against the live site from any browser DevTools or terminal:

1. **HTTP security headers.** `curl -sI https://flarewatch.io | grep -iE 'strict-transport|x-frame|x-content|referrer|permissions|cross-origin'`. Confirm `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, the Permissions-Policy line with 22 features.
2. **CSP enforcement.** `curl -sI https://flarewatch.io | grep -i 'content-security-policy'`. Read the policy. Confirm `frame-ancestors 'none'`. Note: `script-src` will include `'unsafe-inline'` and `'unsafe-eval'` — this is disclosed in section 7 of this doc.
3. **security.txt.** Visit `https://flarewatch.io/.well-known/security.txt`. Confirm the file exists, lists the disclosure contact, and complies with RFC 9116.
4. **HSTS preload status.** Visit `https://hstspreload.org/?domain=flarewatch.io` and confirm preload eligibility (browsers will refuse plain HTTP for our domain).
5. **TLS configuration.** Visit `https://www.ssllabs.com/ssltest/analyze.html?d=flarewatch.io`. Expect an A or A+ grade with no protocol or cipher warnings.
6. **Open-redirect / SSRF probe.** Try a few common patterns against our endpoints (e.g. `?redirect=https://evil.com`). Confirm we don't open-redirect and don't fetch attacker-controlled URLs server-side.
7. **Rate-limit probe.** Hammer `/api/auth/nonce` from one IP. Expect a 429 after ~5 requests in a minute.
8. **No third-party script CDNs.** Open DevTools network panel on flarewatch.io. Filter by domain. Confirm no requests to `googleapis.com`, `fontshare.com`, or other font/script CDNs. TradingView (`s3.tradingview.com`) is the one allowed external script source; it's listed in CSP.

If you find anything in this section that doesn't match what we claim — please tell us via `hello@flarewatch.io`. We'd much rather hear it from a friendly reporter than from a security mailing list.

---

## 17. Operator-controlled tradeoffs (transparency)

These items are NOT bugs and NOT gaps. They are deliberate decisions documented here so an auditor reading our code can verify the rationale.

| Decision | Rationale | Where documented |
|---|---|---|
| CSP allows `'unsafe-inline'` and `'unsafe-eval'` for script-src | React hydration + WalletConnect + TradingView require them. Defense via React's auto-escaping + no `dangerouslySetInnerHTML` | `next.config.mjs:95-100` |
| COOP/CORP `same-origin` headers NOT set | Break wallet popups, TradingView iframe, WalletConnect | `next.config.mjs:170-191` |
| No CSRF tokens | SIWE signature IS the auth proof; state-changing ops require auth + rate limit | This document, section 3.1 |
| Admin allowlist is hardcoded in source | A KV compromise cannot grant admin access | `constants/wallets.ts` leading comment |
| Audit log IP truncated to /16 | Privacy/observability tradeoff: enough for incident response, not enough for personal-location inference | `services/auth/auditLog.ts:22-29` |
| Rate-limit `extractClientIp()` trusts X-Forwarded-For | Vercel deployment only; Vercel's edge proxy sets this reliably | `lib/rateLimit.ts:95` |
| KV failure fails open for rate limits and audit log writes; fails closed for auth | Observability data is non-load-bearing; auth gates must not fail open | `lib/rateLimit.ts`, `services/auth/quickUnlock.ts:163-171` |

---

## 18. Audit history

| Date | Auditor | Findings | Report |
|---|---|---|---|
| 2026-05-12 | Operator + Claude | 0 critical, 0 high, 5 medium, 6 low. All closed in-session or within hours. | [`docs/security-audit-2026-05-12.md`](../security-audit-2026-05-12.md) |
| 2026-05-12 (same day) | Operator + Claude | Defense-in-depth follow-up: 22-feature Permissions-Policy, CI provenance gates, S141 lint rule, security.txt. | [`docs/security-audit-2026-05-12.md`](../security-audit-2026-05-12.md#follow-up) |
| 2026-05-18 | Operator + Claude | This document. Proof-citation audit; no implementation findings. | (this file) |

**Next scheduled audit:** Monthly cadence per [`docs/audits/02-security.md`](./02-security.md). Run history table at the bottom of that file.

---

## Operator's signing statement

This document was produced as part of normal operations on 2026-05-18 by an operator + AI pair-programming session. It represents the codebase state at commit hash (latest as of writing) and the audit methodology described in section 16. Any subsequent code changes will land in the next monthly audit run.

For security reports or vulnerability disclosure, see `/security.txt` at the site root or email `hello@flarewatch.io`.
