diff --git a/docs/INCIDENT_RESPONSE.md b/docs/INCIDENT_RESPONSE.md index 62a89e1..8630d4d 100644 --- a/docs/INCIDENT_RESPONSE.md +++ b/docs/INCIDENT_RESPONSE.md @@ -130,6 +130,195 @@ This document outlines procedures for responding to security alerts detected by > > Please investigate immediately and update the alert status. +## Automated response (circuit breaker) + +The procedures above are what a human does. This section covers what the system +does on its own, before anyone reads the alert. + +When a security scan (`/api/cron/security-scan` or `/api/admin/security/scan`) +raises enough CRITICAL alerts against a single pool, the circuit breaker pauses +that pool so no further money moves until an admin has looked at it. Every +decision it takes, including the ones it decides against, is recorded in the +`incidents` table and surfaced in the admin audit log. + +### What "auto-pause" means, and what it cannot do + +The pause has two halves, and only the first can be automatic. + +| Half | Automatic? | Effect | +|------|-----------|--------| +| Platform pause | Yes | `pools.status` becomes `paused` with a reason and timestamp. The app stops offering deposits and payouts immediately. Reversible from the admin endpoint. | +| On-chain pause | Yes, when pre-authorised | Submitted by the platform using an authorization the admin signed in advance. Without one, the admin signs `rotational::pause` themselves. | + +The contract asserts `admin.require_auth()` and that the caller equals the pool's +stored admin, which is the creator's wallet. The platform holds no key that +satisfies it today: `SPONSOR_SECRET_KEY` only pays network fees, and a fee bump +authorises nothing inside the transaction. So an executed incident is recorded +with `onchain_status = 'pending'` and the admin signs the contract call from the +review screen, after which the hash is recorded against the incident. + +That is a key-custody gap rather than a contract limitation, and it is closed +with **pre-signed authorization entries**, with no contract change. + +### How the automatic on-chain pause works + +A `SorobanAuthorizationEntry` is signed independently of the transaction +envelope, so the party who authorises a call and the party who submits it can be +different. The admin signs one entry covering exactly `pause(admin)` on exactly +their pool's contract. The platform stores it and, when the breaker trips, wraps +it in a transaction it pays for and signs the envelope of. + +Two signatures, two jobs: the admin authorises the call, the platform authorises +the fee. The platform never holds the admin's key, and the credential it does +hold can do one thing. + +``` +admin's wallet platform + | | + | signs pause(admin) entry | + |------------------------------->| stored, single use, expires + | | + | breaker trips + | wraps entry in a tx, pays the fee + |------------------> Soroban +``` + +An alternative exists and was deliberately not taken: `require_auth` for a +classic `G` address uses Stellar multisig at the medium threshold, so an admin +could add a platform signer with enough weight instead. That is simpler to +operate but a far wider grant, since the weight applies to the account in +general rather than to one call. + +### Authorising it + +``` +GET /api/admin/pause-authorizations?poolId=&callerAddress=
+POST /api/admin/pause-authorizations { admin_address, pool_id, entry_xdr } +POST /api/admin/pause-authorizations { action: "revoke", id, signature, signed_at } +``` + +`lib/pause-authorization.ts` builds and signs the entry in the browser through +the wallet kit. The server validates what actually arrived rather than trusting +the client: the entry must be address-credentialed, invoke `pause`, take the +signer as its only argument, and carry no sub-invocations, so it cannot smuggle a +second call. It is also matched against the pool's contract and admin, and +refused if it expires too soon to be useful. + +The entry XDR is never returned by `GET`, and the table has no read policy for +anyone but the service role. It is a bearer credential: whoever holds it can +pause the pool, which would be a griefing vector against the pool's own members. + +### Revoking needs a signature, not an address + +Registering an authorization is self-validating: an entry that was not signed by +the pool real admin is refused by the inspector no matter who posted it, and the +contract would reject it anyway. Revoking is different, because revoking disarms +the automatic pause. An attacker preparing to drain a pool could otherwise switch +off the defence using only public data, since a pool id and its creator address +are both readable. + +So revocation asks the wallet to sign a short, timestamped message naming the +exact authorization, and the server verifies it under SEP-53 against the pool +admin as recorded, not against any address in the request. A captured proof stops +working within minutes and does not transfer to another authorization. +`lib/pause-authorization.ts` has the client side, `lib/server/wallet-proof.ts` +the verification. + +### What happens when there is no authorization + +The platform pause still happens, immediately. The incident is recorded with +`onchain_status = 'pending'`, the admin is told why in their notification, and +they sign the contract call themselves from the review screen. The pool is +protected either way; pre-authorising only removes the wait. + +Entries are single-use and expire, so an admin who wants the automatic pause to +keep working re-signs one occasionally. `GET` reports `armed: true` while a +usable one exists. + +### emergency_withdraw is never automatic + +Nothing in the automated path can move funds. The breaker's action type has +exactly two values, `pause` and `none`, and a unit test asserts that set has not +grown. `emergency_withdraw` stays a manual, admin-only contract call. + +### Thresholds and cooldown + +| Setting | Default | What it does | +|---------|---------|--------------| +| `INCIDENT_AUTO_PAUSE_ENABLED` | unset (dry-run) | Arms the breaker. Only the exact string `true` arms it. | +| `INCIDENT_CRITICAL_THRESHOLD` | 2 | Critical alerts against one pool needed to trip it. | +| `INCIDENT_THRESHOLD_WINDOW_MS` | 3600000 (1h) | How far back alerts count towards the threshold. | +| `INCIDENT_MAX_PAUSES_PER_WINDOW` | 1 | Auto-pauses allowed per pool inside the cooldown window. | +| `INCIDENT_COOLDOWN_WINDOW_MS` | 86400000 (24h) | The cooldown window. | + +The cooldown is what prevents pause-flap. With the defaults, a pool is +auto-paused at most once a day; if it trips again it stays paused and waits for +an admin instead of oscillating. The gate is checked before the action, so a +pool in cooldown is never paused and then reverted. Only pauses that actually +happened count towards it, so a dry-run period does not silently consume a +pool's allowance. + +### Rolling it out with dry-run + +Dry-run is the default and is the intended rollout mechanism, not a switch to +skip past. In dry-run the breaker still decides, still writes the incident and +still notifies the admin. It just does not pause anything. + +Both scan endpoints report `incidentResponse`, which always answers whether an +action *would* have fired, independently of dry-run: + +```jsonc +{ + "incidentResponse": { + "dryRun": true, + "wouldFire": 2, // pools that met the thresholds + "paused": 0, // pools actually paused + "cooldownBlocked": 1, // held back by the cooldown + "decisions": [ /* one per pool, with the reason */ ], + "incidentIds": ["..."] + } +} +``` + +Run it that way for a while, read the incidents it would have created, and only +then set `INCIDENT_AUTO_PAUSE_ENABLED=true`. + +### Admin review and recovery + +``` +GET /api/admin/incidents?poolId=&callerAddress=
+POST /api/admin/incidents/ +``` + +The POST body takes `admin_address` and an `action`: + +| Action | What it does | +|--------|--------------| +| `resolve` | Closes the incident with a required note. The pool stays paused. | +| `resume` | Closes it and returns the pool to `active`. | +| `record_onchain` | Attaches the hash of the `pause` or `unpause` transaction the admin signed. | + +Both endpoints verify the caller against the pool's `creator_address` +server-side, the same check `/api/admin/audit-log` uses. + +`resume` lifts the platform pause only. If the admin had already signed an +on-chain pause, the response returns `onchainUnpauseRequired: true` and the +contract stays paused until they sign `unpause` themselves. + +### Where to look + +| Piece | File | +|-------|------| +| Decision logic (pure, unit tested) | `frontend/lib/incident-response.ts` | +| Tests | `frontend/lib/incident-response.test.ts` | +| Execution against Supabase | `frontend/lib/server/incident-actions.ts` | +| Admin review and recovery | `frontend/app/api/admin/incidents/` | +| On-chain pause submission | `frontend/lib/server/pause-onchain.ts` | +| Signing an authorization (browser) | `frontend/lib/pause-authorization.ts` | +| Authorization endpoints | `frontend/app/api/admin/pause-authorizations/` | +| Schema | `supabase/migrations/20260827120000_incident_response.sql` | +| Authorization schema | `supabase/migrations/20260827130000_pause_authorizations.sql` | + ## Review and Post-Incident After resolving any CRITICAL or WARNING alert: diff --git a/frontend/.env.example b/frontend/.env.example index 66c90b5..fd31427 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -73,3 +73,36 @@ CRON_SECRET= # Full public URL of the deployed app (no trailing slash). Used by cron routes # to call sibling API endpoints (e.g. the push dispatch endpoint). NEXT_PUBLIC_APP_URL=https://joint-save.vercel.app + +# ── Automated incident response (security circuit breaker) ──────────────────── +# When a security scan flags enough critical alerts against one pool, the +# breaker pauses that pool so no further money moves until an admin reviews it. +# See docs/INCIDENT_RESPONSE.md. + +# Arms the breaker. Left unset (or anything other than the exact string "true") +# it stays in dry-run: decisions are recorded, incidents are written and admins +# are notified, but no pool is ever paused. Run it that way first and read the +# incidents it would have created before turning it on. +INCIDENT_AUTO_PAUSE_ENABLED=false + +# Critical alerts against one pool needed to trip the breaker. Default 2. +# INCIDENT_CRITICAL_THRESHOLD=2 + +# How far back critical alerts count towards the threshold, in ms. Default 1h. +# INCIDENT_THRESHOLD_WINDOW_MS=3600000 + +# Anti-flap: auto-pauses allowed per pool inside the window below. Default 1, +# so a pool that trips again stays paused for an admin instead of flapping. +# INCIDENT_MAX_PAUSES_PER_WINDOW=1 + +# The cooldown window, in ms. Default 24h. +# INCIDENT_COOLDOWN_WINDOW_MS=86400000 + +# Network the contracts live on, used when the breaker submits the on-chain +# pause. Defaults to testnet, matching components/web3-provider.tsx. +# STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 + +# Submitting the on-chain pause needs SPONSOR_SECRET_KEY (above) to pay the fee. +# It never authorises the pause itself: that comes from an authorization the +# pool admin pre-signed. Without it the breaker still pauses at the platform +# level and asks the admin to sign the contract call. diff --git a/frontend/app/api/admin/incidents/[id]/route.ts b/frontend/app/api/admin/incidents/[id]/route.ts new file mode 100644 index 0000000..8567973 --- /dev/null +++ b/frontend/app/api/admin/incidents/[id]/route.ts @@ -0,0 +1,179 @@ +/** + * POST /api/admin/incidents/[id] + * + * Admin review and recovery for one incident. Three actions, all of them + * requiring the pool's creator: + * + * - `resolve`: close the incident with a note. The pool stays paused. + * - `resume`: close it and put the pool back to active. + * - `record_onchain`: attach the hash of the `pause` or `unpause` + * transaction the admin signed with their own wallet. + * + * There is deliberately no action here that moves funds. `emergency_withdraw` + * remains a manual, admin-only contract call and is never reachable from an + * automated path or from this endpoint. + * + * Authorization follows `/api/disputes/[id]/resolve`: the caller's address is + * compared server-side against the pool's `creator_address`. + */ + +import { NextRequest, NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" +import { writeLimiter } from "@/lib/rate-limit" + +type IncidentAdminAction = "resolve" | "resume" | "record_onchain" + +const ACTIONS: IncidentAdminAction[] = ["resolve", "resume", "record_onchain"] + +/** Stellar transaction hashes are 64 hex characters. */ +const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const limited = writeLimiter(req) + if (limited) return limited + + const { id } = await ctx.params + + let body: { + admin_address?: string + action?: string + resolution_notes?: string + tx_hash?: string + } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const adminAddress = + typeof body.admin_address === "string" ? body.admin_address.toLowerCase() : "" + const action = body.action as IncidentAdminAction | undefined + const notes = typeof body.resolution_notes === "string" ? body.resolution_notes.trim() : "" + const txHash = typeof body.tx_hash === "string" ? body.tx_hash.trim() : "" + + if (!id || !adminAddress) { + return NextResponse.json( + { error: "incident id and admin_address are required" }, + { status: 400 } + ) + } + if (!action || !ACTIONS.includes(action)) { + return NextResponse.json( + { error: `action must be one of: ${ACTIONS.join(", ")}` }, + { status: 422 } + ) + } + + const admin = getAdminClient() + + const { data: incident } = await admin.from("incidents").select("*").eq("id", id).maybeSingle() + if (!incident) { + return NextResponse.json({ error: "Incident not found" }, { status: 404 }) + } + + const { data: pool } = await admin + .from("pools") + .select("id, name, status, creator_address") + .eq("id", incident.pool_id) + .maybeSingle() + if (!pool || pool.creator_address?.toLowerCase() !== adminAddress) { + return NextResponse.json( + { error: "Only the pool admin can act on this incident" }, + { status: 403 } + ) + } + + const now = new Date().toISOString() + + // ── Record the on-chain transaction the admin signed ────────────────────── + if (action === "record_onchain") { + if (!TX_HASH_PATTERN.test(txHash)) { + return NextResponse.json( + { error: "tx_hash must be a 64-character hex hash" }, + { status: 422 } + ) + } + + const { data: updated, error } = await admin + .from("incidents") + .update({ + onchain_status: "confirmed", + onchain_tx_hash: txHash, + updated_at: now, + }) + .eq("id", id) + .select("*") + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + await admin.from("pool_activity").insert({ + pool_id: incident.pool_id, + activity_type: "security_onchain_pause", + user_address: adminAddress, + tx_hash: txHash, + description: `Admin signed the on-chain pause for incident ${id}`, + }) + + return NextResponse.json({ incident: updated }) + } + + // ── Resolve, optionally resuming the pool ───────────────────────────────── + if (incident.status === "resolved") { + return NextResponse.json({ error: "Incident is already resolved" }, { status: 409 }) + } + if (!notes) { + return NextResponse.json({ error: "resolution_notes is required" }, { status: 422 }) + } + + const { data: updated, error: updateError } = await admin + .from("incidents") + .update({ + status: "resolved", + resolved_by: adminAddress, + resolution_notes: notes, + resolved_at: now, + updated_at: now, + }) + .eq("id", id) + .select("*") + .single() + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } + + let resumed = false + if (action === "resume" && pool.status === "paused") { + const { data: resumedPool, error: resumeError } = await admin + .from("pools") + .update({ status: "active", pause_reason: null, paused_at: null }) + .eq("id", incident.pool_id) + .eq("status", "paused") + .select("id") + + if (resumeError) { + return NextResponse.json({ error: resumeError.message }, { status: 500 }) + } + resumed = (resumedPool ?? []).length > 0 + } + + await admin.from("pool_activity").insert({ + pool_id: incident.pool_id, + activity_type: resumed ? "security_incident_resumed" : "security_incident_resolved", + user_address: adminAddress, + description: `${resumed ? "Pool resumed" : "Incident resolved"}: ${notes.slice(0, 140)}`, + }) + + return NextResponse.json({ + incident: updated, + resumed, + /** + * Resuming here only lifts the platform pause. If the admin already signed + * an on-chain pause, the contract is still paused and needs its own + * `unpause` call, signed by the same wallet. + */ + onchainUnpauseRequired: resumed && incident.onchain_status === "confirmed", + }) +} diff --git a/frontend/app/api/admin/incidents/route.ts b/frontend/app/api/admin/incidents/route.ts new file mode 100644 index 0000000..f0b695b --- /dev/null +++ b/frontend/app/api/admin/incidents/route.ts @@ -0,0 +1,83 @@ +/** + * GET /api/admin/incidents?poolId=&callerAddress=
+ * + * The review queue for a pool: every automatic decision the security circuit + * breaker took, including the ones it decided against and the ones dry-run held + * back. This is what an admin opens after a notification says their pool was + * paused. + * + * Authorization mirrors `/api/admin/audit-log`: the caller's address is checked + * server-side against the pool's `creator_address` before anything is returned. + */ + +import { NextRequest, NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" +import { readLimiter } from "@/lib/rate-limit" +import { jsonPrivate } from "@/lib/cache-headers" + +/** Newest first, and capped: this is a review screen, not an export. */ +const MAX_ROWS = 100 + +export async function GET(req: NextRequest) { + const limited = readLimiter(req) + if (limited) return limited + + const poolId = req.nextUrl.searchParams.get("poolId") + if (!poolId) { + return NextResponse.json({ error: "poolId is required" }, { status: 400 }) + } + + const callerAddress = req.nextUrl.searchParams.get("callerAddress") + if (!callerAddress) { + return NextResponse.json({ error: "callerAddress is required" }, { status: 400 }) + } + + const admin = getAdminClient() + + const { data: pool, error: poolErr } = await admin + .from("pools") + .select("id, name, status, creator_address, pause_reason, paused_at") + .eq("id", poolId) + .maybeSingle() + + if (poolErr || !pool) { + return NextResponse.json({ error: "Pool not found" }, { status: 404 }) + } + + if (callerAddress.toLowerCase() !== pool.creator_address.toLowerCase()) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { data: incidents, error: incidentsErr } = await admin + .from("incidents") + .select("*") + .eq("pool_id", poolId) + .order("created_at", { ascending: false }) + .limit(MAX_ROWS) + + if (incidentsErr) { + return NextResponse.json({ error: "Failed to fetch incidents" }, { status: 500 }) + } + + const rows = incidents ?? [] + + return jsonPrivate({ + pool: { + id: pool.id, + name: pool.name, + status: pool.status, + pause_reason: pool.pause_reason, + paused_at: pool.paused_at, + }, + incidents: rows, + summary: { + total: rows.length, + open: rows.filter((i) => i.status === "open").length, + executed: rows.filter((i) => i.executed).length, + dryRun: rows.filter((i) => i.dry_run).length, + // Pauses the platform applied that still need the admin's signature + // on-chain. The contract stays live until they sign. + awaitingOnchain: rows.filter((i) => i.onchain_status === "pending").length, + }, + }) +} diff --git a/frontend/app/api/admin/pause-authorizations/route.ts b/frontend/app/api/admin/pause-authorizations/route.ts new file mode 100644 index 0000000..2b38f57 --- /dev/null +++ b/frontend/app/api/admin/pause-authorizations/route.ts @@ -0,0 +1,259 @@ +/** + * Pre-signed authorizations that let the circuit breaker pause a pool on-chain. + * + * ``` + * GET /api/admin/pause-authorizations?poolId=&callerAddress=
+ * POST /api/admin/pause-authorizations { admin_address, pool_id, entry_xdr } + * POST /api/admin/pause-authorizations { action: "revoke", id, signature, signed_at } + * ``` + * + * The pool admin signs a `SorobanAuthorizationEntry` covering exactly + * `pause(admin)` on their own pool and hands it over. The platform stores it and + * submits it if the breaker trips, paying the fee itself. It never holds the + * admin's key, and the entry authorises nothing but that one call. + * + * The entry is validated here rather than trusted: what the client claims it + * signed and what it actually signed are checked against each other, and against + * the pool's contract and admin. + * + * The entry XDR is never returned by GET. It is a bearer credential: anyone + * holding it could pause the pool, which would be a griefing vector against the + * pool's own members. + * + * Registering is self-validating: an entry not signed by the pool's real admin + * is refused by the inspector no matter who posted it, and the contract would + * reject it anyway. Revoking is not, and revoking disarms the automatic on-chain + * pause. An attacker preparing to drain a pool could otherwise switch off the + * defence using only public data, so revocation asks for a wallet signature + * rather than an address in a request body. + */ + +import { NextRequest, NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" +import { readLimiter, writeLimiter } from "@/lib/rate-limit" +import { jsonPrivate } from "@/lib/cache-headers" +import { currentLedger, inspectPauseAuthorization } from "@/lib/server/pause-onchain" +import { checkWalletProof } from "@/lib/server/wallet-proof" +import { revokePauseAuthorizationMessage } from "@/lib/wallet-proof" + +/** + * An entry has to be good for a while to be worth storing. Below this the admin + * would be re-signing constantly and the breaker would rarely find one usable. + * About a day at six seconds per ledger. + */ +const MIN_USEFUL_LEDGERS = 14_400 + +export async function GET(req: NextRequest) { + const limited = readLimiter(req) + if (limited) return limited + + const poolId = req.nextUrl.searchParams.get("poolId") + const callerAddress = req.nextUrl.searchParams.get("callerAddress") + if (!poolId || !callerAddress) { + return NextResponse.json({ error: "poolId and callerAddress are required" }, { status: 400 }) + } + + const admin = getAdminClient() + + const { data: pool } = await admin + .from("pools") + .select("id, creator_address, contract_address") + .eq("id", poolId) + .maybeSingle() + if (!pool) return NextResponse.json({ error: "Pool not found" }, { status: 404 }) + if (callerAddress.toLowerCase() !== pool.creator_address.toLowerCase()) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { data: rows, error } = await admin + .from("pause_authorizations") + // Note the absent entry_xdr: it never leaves the server. + .select("id, expiration_ledger, used_at, used_by_incident, revoked_at, created_at") + .eq("pool_id", poolId) + .order("created_at", { ascending: false }) + .limit(50) + + if (error) { + return NextResponse.json({ error: "Failed to fetch authorizations" }, { status: 500 }) + } + + const ledger = await currentLedger() + const authorizations = (rows ?? []).map((row) => ({ + ...row, + status: + row.revoked_at !== null + ? "revoked" + : row.used_at !== null + ? "used" + : ledger !== null && row.expiration_ledger <= ledger + ? "expired" + : "active", + })) + + return jsonPrivate({ + currentLedger: ledger, + authorizations, + /** True when the breaker could pause this pool on-chain right now. */ + armed: authorizations.some((a) => a.status === "active"), + }) +} + +export async function POST(req: NextRequest) { + const limited = writeLimiter(req) + if (limited) return limited + + let body: { + admin_address?: string + pool_id?: string + entry_xdr?: string + action?: string + id?: string + signature?: string + signed_at?: number + } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const adminAddress = typeof body.admin_address === "string" ? body.admin_address.trim() : "" + const admin = getAdminClient() + + // ── Revoke ──────────────────────────────────────────────────────────────── + if (body.action === "revoke") { + if (!body.id) { + return NextResponse.json({ error: "id is required to revoke" }, { status: 400 }) + } + + const { data: existing } = await admin + .from("pause_authorizations") + .select("id, pool_id") + .eq("id", body.id) + .maybeSingle() + if (!existing) { + return NextResponse.json({ error: "Authorization not found" }, { status: 404 }) + } + + const { data: pool } = await admin + .from("pools") + .select("creator_address") + .eq("id", existing.pool_id) + .maybeSingle() + if (!pool?.creator_address) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + // The signature is checked against the pool's admin as the contract knows + // it, not against the address the caller sent, so a spoofed admin_address + // buys nothing. + const proof = checkWalletProof({ + address: pool.creator_address, + message: revokePauseAuthorizationMessage(body.id, Number(body.signed_at)), + signature: body.signature, + signedAt: body.signed_at, + }) + if (!proof.ok) { + return NextResponse.json({ error: proof.reason }, { status: 403 }) + } + + const { error } = await admin + .from("pause_authorizations") + .update({ revoked_at: new Date().toISOString() }) + .eq("id", body.id) + .is("revoked_at", null) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + await admin.from("pool_activity").insert({ + pool_id: existing.pool_id, + activity_type: "security_pause_authorization_revoked", + user_address: pool.creator_address, + description: `Admin revoked pause authorization ${body.id}`, + }) + + return NextResponse.json({ revoked: true }) + } + + // ── Register ────────────────────────────────────────────────────────────── + if (!adminAddress) { + return NextResponse.json({ error: "admin_address is required" }, { status: 400 }) + } + + const poolId = typeof body.pool_id === "string" ? body.pool_id : "" + const entryXdr = typeof body.entry_xdr === "string" ? body.entry_xdr.trim() : "" + if (!poolId || !entryXdr) { + return NextResponse.json({ error: "pool_id and entry_xdr are required" }, { status: 400 }) + } + + const { data: pool } = await admin + .from("pools") + .select("id, creator_address, contract_address") + .eq("id", poolId) + .maybeSingle() + if (!pool) return NextResponse.json({ error: "Pool not found" }, { status: 404 }) + if (pool.creator_address?.toLowerCase() !== adminAddress.toLowerCase()) { + return NextResponse.json( + { error: "Only the pool admin can authorise the automatic pause" }, + { status: 403 } + ) + } + if (!pool.contract_address) { + return NextResponse.json( + { error: "This pool has no contract address on record" }, + { status: 409 } + ) + } + + const inspection = inspectPauseAuthorization(entryXdr) + if (!inspection.ok) { + return NextResponse.json({ error: inspection.reason }, { status: 422 }) + } + if (inspection.contractAddress !== pool.contract_address) { + return NextResponse.json( + { error: "The authorization is for a different contract than this pool's" }, + { status: 422 } + ) + } + if (inspection.adminAddress?.toLowerCase() !== pool.creator_address.toLowerCase()) { + return NextResponse.json( + { error: "The authorization was signed by an address that is not this pool's admin" }, + { status: 422 } + ) + } + + const ledger = await currentLedger() + const expirationLedger = inspection.expirationLedger ?? 0 + if (ledger !== null && expirationLedger - ledger < MIN_USEFUL_LEDGERS) { + return NextResponse.json( + { + error: + "That authorization expires too soon to be useful. Sign one valid for at least a day.", + }, + { status: 422 } + ) + } + + const { data: created, error } = await admin + .from("pause_authorizations") + .insert({ + pool_id: poolId, + contract_address: pool.contract_address, + admin_address: pool.creator_address, + entry_xdr: entryXdr, + expiration_ledger: expirationLedger, + }) + .select("id, expiration_ledger, created_at") + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + await admin.from("pool_activity").insert({ + pool_id: poolId, + activity_type: "security_pause_authorized", + user_address: adminAddress, + description: `Admin pre-authorised the automatic pause until ledger ${expirationLedger}`, + }) + + return NextResponse.json({ authorization: created }, { status: 201 }) +} diff --git a/frontend/app/api/admin/security/scan/route.ts b/frontend/app/api/admin/security/scan/route.ts index 3d7e596..833e909 100644 --- a/frontend/app/api/admin/security/scan/route.ts +++ b/frontend/app/api/admin/security/scan/route.ts @@ -10,12 +10,15 @@ import { type MemberRecord, type AdminActionRecord, } from "@/lib/security-rules" +import { runIncidentResponse } from "@/lib/server/incident-actions" /** * POST /api/admin/security/scan * * Runs all monitoring rules against recent activity (last 24 hours). - * Returns array of triggered alerts. + * Returns array of triggered alerts, plus what the incident-response circuit + * breaker decided about them. In dry-run (the default) the decisions are + * reported and recorded but no pool is paused. * Rate limited: max 1 scan per 5 minutes (uses writeLimiter). */ export async function POST(req: NextRequest) { @@ -125,9 +128,19 @@ export async function POST(req: NextRequest) { } } + // Escalate critical alerts into recovery actions. The scan result is still + // worth returning if this fails, so it is contained. + let incidentResponse = null + try { + incidentResponse = await runIncidentResponse(admin, alerts, "admin") + } catch (incidentError) { + console.error("Incident response failed:", incidentError) + } + return jsonPrivate({ scanTime: now.toISOString(), alerts, + incidentResponse, summary: { total: alerts.length, critical: alerts.filter((a) => a.severity === "critical").length, diff --git a/frontend/app/api/cron/security-scan/route.ts b/frontend/app/api/cron/security-scan/route.ts index 313c70b..eef2724 100644 --- a/frontend/app/api/cron/security-scan/route.ts +++ b/frontend/app/api/cron/security-scan/route.ts @@ -8,6 +8,7 @@ import { type MemberRecord, type AdminActionRecord, } from "@/lib/security-rules" +import { runIncidentResponse } from "@/lib/server/incident-actions" /** * POST /api/cron/security-scan @@ -15,6 +16,8 @@ import { * Runs every 6 hours automatically via cron. * Stores all results in security_alerts table. * Sends immediate notifications to platform admins for CRITICAL alerts. + * Runs the incident-response circuit breaker over the critical alerts, which + * may auto-pause a pool (see lib/incident-response.ts). Dry-run by default. * * Protected by a shared secret in the x-cron-secret header. */ @@ -152,6 +155,16 @@ export async function POST(req: NextRequest) { } } + // Escalate critical alerts into recovery actions. A failure here must not + // lose the scan: the alerts are already persisted and are the more + // important record. + let incidentResponse = null + try { + incidentResponse = await runIncidentResponse(admin, alerts, "cron") + } catch (incidentError) { + console.error("Incident response failed:", incidentError) + } + // Log the cron job execution await admin.from("cron_job_logs").insert({ job_name: "security-scan", @@ -165,6 +178,7 @@ export async function POST(req: NextRequest) { scanTime: now.toISOString(), alertsStored: alerts.length, criticalAlerts: criticalAlerts.length, + incidentResponse, }) } catch (error) { console.error("Cron security scan error:", error) diff --git a/frontend/app/api/pools/[id]/archive/route.ts b/frontend/app/api/pools/[id]/archive/route.ts index 1cd7cf2..49d9a2c 100644 --- a/frontend/app/api/pools/[id]/archive/route.ts +++ b/frontend/app/api/pools/[id]/archive/route.ts @@ -13,6 +13,8 @@ import { NextRequest, NextResponse } from "next/server" import { getAdminClient } from "@/lib/supabase-admin" import { writeLimiter } from "@/lib/rate-limit" +import { checkWalletProof } from "@/lib/server/wallet-proof" +import { archivePoolMessage } from "@/lib/wallet-proof" import { isArchiveReason, type ArchiveReason } from "@/lib/archival" export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { @@ -21,7 +23,13 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string const { id } = await ctx.params - let body: { admin_address?: string; reason?: string; note?: string } + let body: { + admin_address?: string + signature?: string + signed_at?: number + reason?: string + note?: string + } try { body = await req.json() } catch { @@ -66,6 +74,19 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string ) } + // Ownership alone is not proof: admin_address is caller-supplied. The + // signature is verified against the pool's admin as the database knows it, so + // naming someone else's address buys nothing. + const proof = checkWalletProof({ + address: pool.creator_address, + message: archivePoolMessage(id, Number(body.signed_at)), + signature: body.signature, + signedAt: body.signed_at, + }) + if (!proof.ok) { + return NextResponse.json({ error: proof.reason }, { status: 403 }) + } + if (pool.archived_at) { return NextResponse.json({ error: "Pool is already archived" }, { status: 409 }) } diff --git a/frontend/app/api/pools/[id]/unarchive/route.ts b/frontend/app/api/pools/[id]/unarchive/route.ts index 0838f3c..55073d9 100644 --- a/frontend/app/api/pools/[id]/unarchive/route.ts +++ b/frontend/app/api/pools/[id]/unarchive/route.ts @@ -12,6 +12,8 @@ import { NextRequest, NextResponse } from "next/server" import { getAdminClient } from "@/lib/supabase-admin" import { writeLimiter } from "@/lib/rate-limit" +import { checkWalletProof } from "@/lib/server/wallet-proof" +import { unarchivePoolMessage } from "@/lib/wallet-proof" import type { ArchiveReason } from "@/lib/archival" export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { @@ -20,7 +22,7 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string const { id } = await ctx.params - let body: { admin_address?: string; note?: string } + let body: { admin_address?: string; signature?: string; signed_at?: number; note?: string } try { body = await req.json() } catch { @@ -52,6 +54,19 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string ) } + // Ownership alone is not proof: admin_address is caller-supplied. The + // signature is verified against the pool's admin as the database knows it, so + // naming someone else's address buys nothing. + const proof = checkWalletProof({ + address: pool.creator_address, + message: unarchivePoolMessage(id, Number(body.signed_at)), + signature: body.signature, + signedAt: body.signed_at, + }) + if (!proof.ok) { + return NextResponse.json({ error: proof.reason }, { status: 403 }) + } + if (!pool.archived_at) { return NextResponse.json({ error: "Pool is not archived" }, { status: 409 }) } diff --git a/frontend/components/group/archived-pool-banner.tsx b/frontend/components/group/archived-pool-banner.tsx index 628a177..15365c1 100644 --- a/frontend/components/group/archived-pool-banner.tsx +++ b/frontend/components/group/archived-pool-banner.tsx @@ -14,6 +14,8 @@ import { } from "@/components/ui/dialog" import { Archive, ArchiveRestore, Loader2 } from "lucide-react" import { toastManager } from "@/lib/toast" +import { signArchiveProof, signUnarchiveProof } from "@/lib/archive-proof" +import { STELLAR_NETWORK_PASSPHRASE, useStellar } from "@/components/web3-provider" import type { ArchiveReason } from "@/lib/archival" interface ArchivedPoolBannerProps { @@ -44,18 +46,31 @@ export function ArchivedPoolBanner({ onRestored, }: ArchivedPoolBannerProps) { const t = useTranslations("group.archived") + const { kit } = useStellar() const [restoring, setRestoring] = useState(false) const reason: ArchiveReason = archiveReason ?? "admin_archived" const handleRestore = async () => { - if (!adminAddress) return toastManager.error(t("unarchiveError")) + if (!adminAddress || !kit) return toastManager.error(t("unarchiveError")) setRestoring(true) try { + // The wallet signs before the request goes out: the endpoint authorises + // on this signature, not on the address in the body. + const proof = await signUnarchiveProof({ + kit, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + adminAddress, + poolId: groupId, + }) const res = await fetch(`/api/pools/${groupId}/unarchive`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ admin_address: adminAddress }), + body: JSON.stringify({ + admin_address: adminAddress, + signature: proof.signature, + signed_at: proof.signedAt, + }), }) if (!res.ok) throw new Error(await res.text()) toastManager.success(t("unarchiveSuccess")) @@ -125,17 +140,29 @@ interface ArchivePoolButtonProps { */ export function ArchivePoolButton({ groupId, adminAddress, onArchived }: ArchivePoolButtonProps) { const t = useTranslations("group.archived") + const { kit } = useStellar() const [open, setOpen] = useState(false) const [archiving, setArchiving] = useState(false) const handleArchive = async () => { - if (!adminAddress) return toastManager.error(t("archiveError")) + if (!adminAddress || !kit) return toastManager.error(t("archiveError")) setArchiving(true) try { + // Signed before the request, for the same reason as the restore above. + const proof = await signArchiveProof({ + kit, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + adminAddress, + poolId: groupId, + }) const res = await fetch(`/api/pools/${groupId}/archive`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ admin_address: adminAddress }), + body: JSON.stringify({ + admin_address: adminAddress, + signature: proof.signature, + signed_at: proof.signedAt, + }), }) if (!res.ok) throw new Error(await res.text()) toastManager.success(t("archiveSuccess")) diff --git a/frontend/lib/archive-proof.ts b/frontend/lib/archive-proof.ts new file mode 100644 index 0000000..a0e56a1 --- /dev/null +++ b/frontend/lib/archive-proof.ts @@ -0,0 +1,53 @@ +/** + * Proof that an archive or unarchive really came from the pool's admin. + * + * Archiving takes a pool out of Explore and out of everyone's active list, and + * unarchiving puts it back. Neither moves funds, but both change what every + * member sees, and the endpoints cannot take an address in a request body as + * evidence of anything: a pool id and its creator's address are public, so a + * caller could otherwise archive a circle they have nothing to do with. + * + * The wallet signs a short, timestamped message naming the exact pool, and the + * server rebuilds it and checks the signature against the pool's admin as + * recorded. The two messages differ, so a proof gathered to archive cannot be + * turned around and replayed to unarchive. + * + * Runs in the browser: it needs the wallet. + */ + +import type { StellarWalletsKit } from "@creit.tech/stellar-wallets-kit" +import { archivePoolMessage, unarchivePoolMessage } from "@/lib/wallet-proof" + +export interface ArchiveProof { + signature: string + signedAt: number +} + +interface SignParams { + kit: StellarWalletsKit + networkPassphrase: string + adminAddress: string + poolId: string +} + +async function sign( + params: SignParams, + build: (poolId: string, signedAt: number) => string +): Promise { + const signedAt = Date.now() + const { signedMessage } = await params.kit.signMessage(build(params.poolId, signedAt), { + address: params.adminAddress, + networkPassphrase: params.networkPassphrase, + }) + return { signature: signedMessage, signedAt } +} + +/** Signs the proof needed to archive a pool. */ +export function signArchiveProof(params: SignParams): Promise { + return sign(params, archivePoolMessage) +} + +/** Signs the proof needed to bring an archived pool back. */ +export function signUnarchiveProof(params: SignParams): Promise { + return sign(params, unarchivePoolMessage) +} diff --git a/frontend/lib/incident-response.test.ts b/frontend/lib/incident-response.test.ts new file mode 100644 index 0000000..d0f9bd1 --- /dev/null +++ b/frontend/lib/incident-response.test.ts @@ -0,0 +1,464 @@ +// Unit tests for the critical-alert circuit breaker. +// +// The behaviour under test decides whether a live savings pool gets paused +// automatically, so the cases that matter most are the ones where it must NOT +// act: below threshold, during cooldown, and while in dry-run. +import { test } from "node:test" +import assert from "node:assert" +import { + AUTH_LEDGER_SAFETY_MARGIN, + DEFAULT_INCIDENT_CONFIG, + decideAutoPause, + decideIncidentResponse, + groupCriticalAlertsByPool, + loadIncidentConfig, + selectPauseAuthorization, + summarizeDecisions, + type IncidentAction, + type IncidentConfig, + type PoolAlertGroup, + type PoolState, + type StoredPauseAuthorization, +} from "./incident-response" +import type { RuleId, SecurityAlert } from "./security-rules" + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +function alert(overrides: Partial = {}): SecurityAlert { + return { + id: "a1", + rule_id: "rapid_emergency_withdraw" as RuleId, + severity: "critical", + description: "3 emergency withdrawals in 1 hour", + affected_pools: ["pool-1"], + affected_wallets: ["GWALLET"], + status: "new", + resolved_by: null, + resolution_notes: null, + created_at: "2026-08-27T00:00:00.000Z", + resolved_at: null, + ...overrides, + } +} + +function group(overrides: Partial = {}): PoolAlertGroup { + return { + poolId: "pool-1", + ruleIds: ["rapid_emergency_withdraw"], + descriptions: ["3 emergency withdrawals in 1 hour"], + alertCount: 2, + highestSeverity: "critical", + ...overrides, + } +} + +function pool(overrides: Partial = {}): PoolState { + return { id: "pool-1", name: "Ahorro familiar", status: "active", ...overrides } +} + +/** Armed config: same thresholds as the default, but allowed to act. */ +const ARMED: IncidentConfig = { ...DEFAULT_INCIDENT_CONFIG, dryRun: false } + +// ── Grouping ──────────────────────────────────────────────────────────────── + +test("grouping: ignores anything below critical", () => { + const groups = groupCriticalAlertsByPool([ + alert({ severity: "warning" }), + alert({ severity: "info" }), + ]) + assert.deepStrictEqual(groups, []) +}) + +test("grouping: counts every critical alert against the pool", () => { + const groups = groupCriticalAlertsByPool([alert(), alert(), alert()]) + assert.strictEqual(groups.length, 1) + assert.strictEqual(groups[0].alertCount, 3) +}) + +test("grouping: an alert naming several pools counts fully for each", () => { + const groups = groupCriticalAlertsByPool([alert({ affected_pools: ["pool-1", "pool-2"] })]) + assert.strictEqual(groups.length, 2) + assert.strictEqual(groups[0].alertCount, 1) + assert.strictEqual(groups[1].alertCount, 1) +}) + +test("grouping: collects distinct rules without repeating them", () => { + const groups = groupCriticalAlertsByPool([ + alert({ rule_id: "rapid_emergency_withdraw" }), + alert({ rule_id: "mass_member_removal" }), + alert({ rule_id: "rapid_emergency_withdraw" }), + ]) + assert.deepStrictEqual(groups[0].ruleIds, ["rapid_emergency_withdraw", "mass_member_removal"]) + assert.strictEqual(groups[0].alertCount, 3) +}) + +test("grouping: is ordered by pool id, so decisions are reported stably", () => { + const groups = groupCriticalAlertsByPool([ + alert({ affected_pools: ["pool-c"] }), + alert({ affected_pools: ["pool-a"] }), + alert({ affected_pools: ["pool-b"] }), + ]) + assert.deepStrictEqual( + groups.map((g) => g.poolId), + ["pool-a", "pool-b", "pool-c"] + ) +}) + +test("grouping: skips empty pool ids instead of grouping under one", () => { + const groups = groupCriticalAlertsByPool([alert({ affected_pools: ["", "pool-1"] })]) + assert.strictEqual(groups.length, 1) + assert.strictEqual(groups[0].poolId, "pool-1") +}) + +// ── Escalation threshold ──────────────────────────────────────────────────── + +test("threshold: one alert below the threshold does not fire", () => { + const decision = decideAutoPause(group({ alertCount: 1 }), pool(), 0, ARMED) + assert.strictEqual(decision.action, "none") + assert.strictEqual(decision.skipReason, "below_threshold") + assert.strictEqual(decision.wouldFire, false) + assert.strictEqual(decision.executed, false) +}) + +test("threshold: reaching it exactly fires", () => { + const decision = decideAutoPause(group({ alertCount: 2 }), pool(), 0, ARMED) + assert.strictEqual(decision.action, "pause") + assert.strictEqual(decision.wouldFire, true) + assert.strictEqual(decision.executed, true) + assert.strictEqual(decision.skipReason, null) +}) + +test("threshold: the reason names the rules and the count", () => { + const decision = decideAutoPause( + group({ alertCount: 3, ruleIds: ["mass_member_removal"] }), + pool(), + 0, + ARMED + ) + assert.match(decision.reason, /3 critical alert/) + assert.match(decision.reason, /mass_member_removal/) +}) + +// ── Cooldown, the anti-flap gate ──────────────────────────────────────────── + +test("cooldown: a pool already auto-paused in the window is not paused again", () => { + const decision = decideAutoPause(group(), pool(), 1, ARMED) + assert.strictEqual(decision.action, "none") + assert.strictEqual(decision.skipReason, "cooldown") + assert.strictEqual(decision.executed, false) +}) + +test("cooldown: a blocked pool still reports that it would have fired", () => { + // Otherwise a dry run would under-report exactly the pools that keep tripping. + const decision = decideAutoPause(group(), pool(), 5, ARMED) + assert.strictEqual(decision.wouldFire, true) + assert.strictEqual(decision.recentPauses, 5) +}) + +test("cooldown: a higher allowance lets a second pause through", () => { + const config: IncidentConfig = { ...ARMED, maxPausesPerWindow: 2 } + assert.strictEqual(decideAutoPause(group(), pool(), 1, config).action, "pause") + assert.strictEqual(decideAutoPause(group(), pool(), 2, config).skipReason, "cooldown") +}) + +test("cooldown: the message says how many pauses and over what window", () => { + const decision = decideAutoPause(group(), pool(), 1, ARMED) + assert.match(decision.reason, /1 auto-pause/) + assert.match(decision.reason, /24h/) +}) + +test("cooldown: is checked before the action, never paused then reverted", () => { + const decision = decideAutoPause(group({ alertCount: 99 }), pool(), 1, ARMED) + assert.strictEqual(decision.action, "none") + assert.strictEqual(decision.executed, false) +}) + +// ── Pool state ────────────────────────────────────────────────────────────── + +test("pool state: an already paused pool is left alone", () => { + const decision = decideAutoPause(group(), pool({ status: "paused" }), 0, ARMED) + assert.strictEqual(decision.skipReason, "already_paused") + assert.strictEqual(decision.executed, false) +}) + +test("pool state: a completed pool is not paused", () => { + const decision = decideAutoPause(group(), pool({ status: "completed" }), 0, ARMED) + assert.strictEqual(decision.skipReason, "pool_not_active") +}) + +test("pool state: an alert naming an unknown pool is reported, not acted on", () => { + const decision = decideAutoPause(group(), null, 0, ARMED) + assert.strictEqual(decision.skipReason, "unknown_pool") + assert.strictEqual(decision.executed, false) + assert.strictEqual(decision.wouldFire, true) +}) + +// ── Dry run ───────────────────────────────────────────────────────────────── + +test("dry run: decides to pause but does not execute", () => { + const decision = decideAutoPause(group(), pool(), 0, DEFAULT_INCIDENT_CONFIG) + assert.strictEqual(decision.action, "pause") + assert.strictEqual(decision.wouldFire, true) + assert.strictEqual(decision.executed, false) +}) + +test("dry run: changes nothing except execution", () => { + const dry = decideAutoPause(group(), pool(), 0, DEFAULT_INCIDENT_CONFIG) + const armed = decideAutoPause(group(), pool(), 0, ARMED) + assert.deepStrictEqual({ ...dry, executed: null }, { ...armed, executed: null }) +}) + +test("dry run: is the default, so a fresh deployment cannot pause a pool", () => { + assert.strictEqual(DEFAULT_INCIDENT_CONFIG.dryRun, true) +}) + +// ── Configuration ─────────────────────────────────────────────────────────── + +test("config: an empty environment yields the safe defaults", () => { + assert.deepStrictEqual(loadIncidentConfig({}), DEFAULT_INCIDENT_CONFIG) +}) + +test("config: auto-pause arms only on the exact string 'true'", () => { + assert.strictEqual(loadIncidentConfig({ INCIDENT_AUTO_PAUSE_ENABLED: "true" }).dryRun, false) + for (const value of ["True", "TRUE", "1", "yes", "", " true"]) { + assert.strictEqual( + loadIncidentConfig({ INCIDENT_AUTO_PAUSE_ENABLED: value }).dryRun, + true, + `"${value}" must not arm the breaker` + ) + } +}) + +test("config: reads valid overrides", () => { + const config = loadIncidentConfig({ + INCIDENT_CRITICAL_THRESHOLD: "5", + INCIDENT_MAX_PAUSES_PER_WINDOW: "3", + }) + assert.strictEqual(config.criticalThreshold, 5) + assert.strictEqual(config.maxPausesPerWindow, 3) +}) + +test("config: a malformed or out-of-range value falls back instead of throwing", () => { + for (const bad of ["0", "-1", "abc", "2.5", "1000000"]) { + assert.strictEqual( + loadIncidentConfig({ INCIDENT_CRITICAL_THRESHOLD: bad }).criticalThreshold, + DEFAULT_INCIDENT_CONFIG.criticalThreshold, + `"${bad}" must fall back to the default` + ) + } +}) + +// ── Whole-scan behaviour ──────────────────────────────────────────────────── + +test("scan: decides per pool, mixing fired, cooled down and skipped", () => { + const groups = [ + group({ poolId: "pool-1", alertCount: 2 }), + group({ poolId: "pool-2", alertCount: 2 }), + group({ poolId: "pool-3", alertCount: 1 }), + ] + const pools = new Map([ + ["pool-1", pool({ id: "pool-1" })], + ["pool-2", pool({ id: "pool-2" })], + ["pool-3", pool({ id: "pool-3" })], + ]) + const recent = new Map([["pool-2", 1]]) + + const decisions = decideIncidentResponse(groups, pools, recent, ARMED) + assert.deepStrictEqual( + decisions.map((d) => [d.poolId, d.action, d.skipReason]), + [ + ["pool-1", "pause", null], + ["pool-2", "none", "cooldown"], + ["pool-3", "none", "below_threshold"], + ] + ) +}) + +test("summary: reports what would have fired, not just what did", () => { + const groups = [group({ poolId: "pool-1" }), group({ poolId: "pool-2" })] + const pools = new Map([ + ["pool-1", pool({ id: "pool-1" })], + ["pool-2", pool({ id: "pool-2" })], + ]) + const recent = new Map([["pool-2", 1]]) + + const dry = summarizeDecisions( + decideIncidentResponse(groups, pools, recent, DEFAULT_INCIDENT_CONFIG), + DEFAULT_INCIDENT_CONFIG + ) + assert.strictEqual(dry.dryRun, true) + assert.strictEqual(dry.wouldFire, 2) + assert.strictEqual(dry.paused, 0) + assert.strictEqual(dry.cooldownBlocked, 1) + + const armed = summarizeDecisions(decideIncidentResponse(groups, pools, recent, ARMED), ARMED) + assert.strictEqual(armed.paused, 1) + assert.strictEqual(armed.wouldFire, 2) +}) + +// ── The hard boundary ─────────────────────────────────────────────────────── + +test("boundary: pausing is the only action the breaker can ever return", () => { + // emergency_withdraw moves member funds and stays admin-only and manual. It + // is not reachable from here, and this asserts the action set has not grown: + // adding a funds-moving variant would fail this test before review. + const allowed: IncidentAction[] = ["pause", "none"] + + const states: (PoolState | null)[] = [ + pool(), + pool({ status: "paused" }), + pool({ status: "completed" }), + null, + ] + const actions = new Set() + for (const state of states) { + for (const count of [0, 1, 2, 99]) { + for (const config of [ARMED, DEFAULT_INCIDENT_CONFIG]) { + actions.add(decideAutoPause(group({ alertCount: count }), state, count, config).action) + } + } + } + + for (const action of actions) { + assert.ok(allowed.includes(action as IncidentAction), `unexpected automatic action: ${action}`) + } +}) + +// ── On-chain authorization selection ──────────────────────────────────────── +// +// These entries are bearer credentials the admin signed ahead of time. Spending +// the wrong one, or one that belongs to another pool, is the failure that would +// matter, so the rejection reasons are asserted individually. + +const CONTRACT = "CCONTRACT1" +const ADMIN = "GADMIN1" +const LEDGER = 1000 + +function auth(overrides: Partial = {}): StoredPauseAuthorization { + return { + id: "auth-1", + contractAddress: CONTRACT, + adminAddress: ADMIN, + expirationLedger: LEDGER + 5000, + usedAt: null, + revokedAt: null, + ...overrides, + } +} + +const EXPECTED = { contractAddress: CONTRACT, adminAddress: ADMIN } + +test("authorization: picks a valid entry", () => { + const choice = selectPauseAuthorization([auth()], LEDGER, EXPECTED) + assert.strictEqual(choice.authorization?.id, "auth-1") + assert.deepStrictEqual(choice.rejected, []) +}) + +test("authorization: none available yields null rather than throwing", () => { + const choice = selectPauseAuthorization([], LEDGER, EXPECTED) + assert.strictEqual(choice.authorization, null) +}) + +test("authorization: an already spent entry is never reused", () => { + const choice = selectPauseAuthorization( + [auth({ usedAt: "2026-08-27T00:00:00.000Z" })], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.authorization, null) + assert.deepStrictEqual(choice.rejected, [{ id: "auth-1", reason: "used" }]) +}) + +test("authorization: a revoked entry is refused", () => { + const choice = selectPauseAuthorization( + [auth({ revokedAt: "2026-08-27T00:00:00.000Z" })], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.rejected[0].reason, "revoked") +}) + +test("authorization: an expired entry is refused", () => { + const choice = selectPauseAuthorization( + [auth({ expirationLedger: LEDGER - 1 })], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.rejected[0].reason, "expired") +}) + +test("authorization: one expiring inside the safety margin is not spent", () => { + // It would very likely be stale by the time the transaction lands, burning + // its nonce for nothing. + const choice = selectPauseAuthorization( + [auth({ expirationLedger: LEDGER + AUTH_LEDGER_SAFETY_MARGIN - 1 })], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.rejected[0].reason, "expiring_too_soon") +}) + +test("authorization: exactly at the safety margin is usable", () => { + const choice = selectPauseAuthorization( + [auth({ expirationLedger: LEDGER + AUTH_LEDGER_SAFETY_MARGIN })], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.authorization?.id, "auth-1") +}) + +test("authorization: an entry for another contract is refused", () => { + const choice = selectPauseAuthorization( + [auth({ contractAddress: "CSOMEOTHER" })], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.authorization, null) + assert.strictEqual(choice.rejected[0].reason, "wrong_contract") +}) + +test("authorization: an entry signed by a since-rotated admin is refused", () => { + const choice = selectPauseAuthorization([auth({ adminAddress: "GOTHER" })], LEDGER, EXPECTED) + assert.strictEqual(choice.rejected[0].reason, "wrong_admin") +}) + +test("authorization: admin comparison is case-insensitive, like the rest of the app", () => { + const choice = selectPauseAuthorization( + [auth({ adminAddress: ADMIN.toLowerCase() })], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.authorization?.id, "auth-1") +}) + +test("authorization: spends the entry expiring soonest", () => { + const choice = selectPauseAuthorization( + [ + auth({ id: "far", expirationLedger: LEDGER + 9000 }), + auth({ id: "near", expirationLedger: LEDGER + 1000 }), + auth({ id: "mid", expirationLedger: LEDGER + 5000 }), + ], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.authorization?.id, "near") +}) + +test("authorization: ties break deterministically by id", () => { + const choice = selectPauseAuthorization([auth({ id: "b" }), auth({ id: "a" })], LEDGER, EXPECTED) + assert.strictEqual(choice.authorization?.id, "a") +}) + +test("authorization: every rejection is reported, not silently dropped", () => { + const choice = selectPauseAuthorization( + [ + auth({ id: "used", usedAt: "2026-08-27T00:00:00.000Z" }), + auth({ id: "old", expirationLedger: LEDGER - 1 }), + auth({ id: "good" }), + ], + LEDGER, + EXPECTED + ) + assert.strictEqual(choice.authorization?.id, "good") + assert.deepStrictEqual(choice.rejected.map((r) => r.reason).sort(), ["expired", "used"]) +}) diff --git a/frontend/lib/incident-response.ts b/frontend/lib/incident-response.ts new file mode 100644 index 0000000..d1bfdd4 --- /dev/null +++ b/frontend/lib/incident-response.ts @@ -0,0 +1,434 @@ +/** + * Circuit breaker for critical security alerts. + * + * The monitoring rules in `lib/security-rules.ts` detect trouble and persist + * alerts, but detection on its own never stops anything. This module decides + * what to do about it: when a pool accumulates enough critical alerts, the + * breaker trips and the pool is paused so no further money moves while a human + * looks at it. + * + * Everything here is pure. It takes alerts, pool state and a count of recent + * auto-pauses, and returns a decision. No database, no clock of its own, no + * network. That is deliberate: an automated action that halts a live savings + * pool is exactly the kind of logic that has to be exercised in tests without + * standing up Supabase, and exactly the kind that must behave identically in + * dry-run and for real. + * + * Two boundaries are load-bearing and are enforced by the type system rather + * than by convention: + * + * - **The only automatic action is a pause.** `IncidentAction` has no variant + * for withdrawing funds. `emergency_withdraw` stays admin-only and manual, as + * the issue requires, and no decision this module can return could reach it. + * - **A decision is always reported, even in dry-run.** `wouldFire` says what + * the breaker would have done; `executed` says what it was allowed to do. + * A dry-run period therefore produces real data about how the thresholds + * behave before anyone lets them act. + */ + +import type { AlertSeverity, RuleId, SecurityAlert } from "@/lib/security-rules" + +// ── Configuration ──────────────────────────────────────────────────────────── + +export interface IncidentConfig { + /** Critical alerts against one pool needed to trip the breaker. */ + criticalThreshold: number + /** How far back critical alerts are counted towards the threshold. */ + thresholdWindowMs: number + /** Window over which auto-pauses are counted for the cooldown. */ + cooldownWindowMs: number + /** Auto-pauses allowed per pool inside the cooldown window. */ + maxPausesPerWindow: number + /** When true, decisions are recorded and reported but never acted on. */ + dryRun: boolean +} + +export const DEFAULT_INCIDENT_CONFIG: IncidentConfig = { + criticalThreshold: 2, + thresholdWindowMs: 60 * 60 * 1000, + cooldownWindowMs: 24 * 60 * 60 * 1000, + maxPausesPerWindow: 1, + // Off by default. Pausing a live pool is opt-in, per deployment, and the + // README explains how to turn it on once a dry-run period looks sane. + dryRun: true, +} + +/** Upper bounds, so a fat-fingered env var cannot disable the safety rails. */ +const LIMITS = { + criticalThreshold: { min: 1, max: 100 }, + thresholdWindowMs: { min: 60_000, max: 7 * 24 * 60 * 60 * 1000 }, + cooldownWindowMs: { min: 60_000, max: 30 * 24 * 60 * 60 * 1000 }, + maxPausesPerWindow: { min: 1, max: 50 }, +} as const + +function readInt( + raw: string | undefined, + fallback: number, + bounds: { min: number; max: number } +): number { + if (raw === undefined || raw.trim() === "") return fallback + const parsed = Number(raw) + if (!Number.isInteger(parsed) || parsed < bounds.min || parsed > bounds.max) { + return fallback + } + return parsed +} + +/** + * Reads the breaker's configuration from the environment. + * + * Anything missing, unparseable or out of range falls back to the default + * rather than throwing: a malformed variable must not take the security scan + * down with it, and the safe default is "do less", never "do more". + * + * Dry-run is the only flag that has to be turned off explicitly. It is disabled + * solely by the exact string "false", so a typo leaves the breaker in log-only + * mode instead of silently arming it. + */ +export function loadIncidentConfig( + env: Record = process.env +): IncidentConfig { + return { + criticalThreshold: readInt( + env.INCIDENT_CRITICAL_THRESHOLD, + DEFAULT_INCIDENT_CONFIG.criticalThreshold, + LIMITS.criticalThreshold + ), + thresholdWindowMs: readInt( + env.INCIDENT_THRESHOLD_WINDOW_MS, + DEFAULT_INCIDENT_CONFIG.thresholdWindowMs, + LIMITS.thresholdWindowMs + ), + cooldownWindowMs: readInt( + env.INCIDENT_COOLDOWN_WINDOW_MS, + DEFAULT_INCIDENT_CONFIG.cooldownWindowMs, + LIMITS.cooldownWindowMs + ), + maxPausesPerWindow: readInt( + env.INCIDENT_MAX_PAUSES_PER_WINDOW, + DEFAULT_INCIDENT_CONFIG.maxPausesPerWindow, + LIMITS.maxPausesPerWindow + ), + dryRun: env.INCIDENT_AUTO_PAUSE_ENABLED !== "true", + } +} + +// ── Inputs ─────────────────────────────────────────────────────────────────── + +/** The critical alerts raised against a single pool by one scan. */ +export interface PoolAlertGroup { + poolId: string + ruleIds: RuleId[] + descriptions: string[] + alertCount: number + highestSeverity: AlertSeverity +} + +/** What the breaker needs to know about the pool it is about to act on. */ +export interface PoolState { + id: string + name: string | null + status: "active" | "completed" | "paused" +} + +// ── Decisions ──────────────────────────────────────────────────────────────── + +/** + * The complete set of actions the breaker may take on its own. + * + * Adding anything that moves funds here would be a mistake, and a test asserts + * this union stays exactly as it is. + */ +export type IncidentAction = "pause" | "none" + +export type SkipReason = + "below_threshold" | "already_paused" | "pool_not_active" | "cooldown" | "unknown_pool" + +export interface IncidentDecision { + poolId: string + /** What the breaker concluded, independent of whether it is allowed to act. */ + action: IncidentAction + /** True when the thresholds were met, reported even in dry-run. */ + wouldFire: boolean + /** True only when the action is actually carried out (armed and not skipped). */ + executed: boolean + /** Present when `action` is "none": why the breaker held off. */ + skipReason: SkipReason | null + /** Human-readable, persisted on the incident and on the pool. */ + reason: string + ruleIds: RuleId[] + alertCount: number + severity: AlertSeverity + /** Auto-pauses already recorded for this pool inside the cooldown window. */ + recentPauses: number +} + +// ── Grouping ───────────────────────────────────────────────────────────────── + +/** + * Groups a scan's critical alerts by the pool they affect. + * + * An alert can name several pools, and each named pool carries the alert's full + * weight: an incident that touches three pools is critical for all three, not a + * third as bad for each. Alerts below critical never reach the breaker. + */ +export function groupCriticalAlertsByPool(alerts: readonly SecurityAlert[]): PoolAlertGroup[] { + const byPool = new Map() + + for (const alert of alerts) { + if (alert.severity !== "critical") continue + + for (const poolId of alert.affected_pools) { + if (!poolId) continue + const existing = byPool.get(poolId) + if (existing) { + existing.alertCount += 1 + if (!existing.ruleIds.includes(alert.rule_id)) { + existing.ruleIds.push(alert.rule_id) + } + existing.descriptions.push(alert.description) + } else { + byPool.set(poolId, { + poolId, + ruleIds: [alert.rule_id], + descriptions: [alert.description], + alertCount: 1, + highestSeverity: "critical", + }) + } + } + } + + // Sorted by pool id so a scan's decisions are reported in a stable order. + return [...byPool.values()].sort((a, b) => (a.poolId < b.poolId ? -1 : 1)) +} + +// ── The decision ───────────────────────────────────────────────────────────── + +function buildReason(group: PoolAlertGroup, config: IncidentConfig): string { + const rules = group.ruleIds.join(", ") + const windowMinutes = Math.round(config.thresholdWindowMs / 60_000) + return ( + `Auto-paused: ${group.alertCount} critical alert(s) ` + + `(${rules}) within ${windowMinutes} min, threshold ${config.criticalThreshold}.` + ) +} + +/** + * Decides whether one pool's critical alerts should trip the breaker. + * + * The checks run in a deliberate order, because the answer to "why did nothing + * happen?" has to be a single, honest reason: + * + * 1. Below the threshold: not enough critical alerts to act on. + * 2. Unknown pool: an alert naming a pool that is not in the database. + * 3. Already paused, or not active: nothing left to stop. + * 4. Cooldown: the pool has been auto-paused too recently. This is the gate + * that prevents pause-flap, and it is checked *before* the action is + * produced, so a pool in cooldown is never paused and then reverted. + * + * Dry-run is applied last and only to `executed`. The decision itself, and + * `wouldFire` with it, is identical either way. + */ +export function decideAutoPause( + group: PoolAlertGroup, + pool: PoolState | null, + recentPauses: number, + config: IncidentConfig +): IncidentDecision { + const base = { + poolId: group.poolId, + ruleIds: group.ruleIds, + alertCount: group.alertCount, + severity: group.highestSeverity, + recentPauses, + } + + const skip = (skipReason: SkipReason, reason: string): IncidentDecision => ({ + ...base, + action: "none", + // A pool skipped for cooldown or because it is already paused DID meet the + // thresholds; saying otherwise would hide real signal during a dry run. + wouldFire: skipReason !== "below_threshold", + executed: false, + skipReason, + reason, + }) + + if (group.alertCount < config.criticalThreshold) { + return skip( + "below_threshold", + `No action: ${group.alertCount} critical alert(s), threshold is ${config.criticalThreshold}.` + ) + } + + if (!pool) { + return skip("unknown_pool", `No action: pool ${group.poolId} was not found in the database.`) + } + + if (pool.status === "paused") { + return skip("already_paused", "No action: the pool is already paused.") + } + + if (pool.status !== "active") { + return skip("pool_not_active", `No action: the pool is ${pool.status}.`) + } + + if (recentPauses >= config.maxPausesPerWindow) { + const windowHours = Math.round(config.cooldownWindowMs / 3_600_000) + return skip( + "cooldown", + `No action: ${recentPauses} auto-pause(s) already in the last ${windowHours}h ` + + `(max ${config.maxPausesPerWindow}). Needs admin review.` + ) + } + + return { + ...base, + action: "pause", + wouldFire: true, + // The one place dry-run changes anything. + executed: !config.dryRun, + skipReason: null, + reason: buildReason(group, config), + } +} + +/** Runs the breaker over a whole scan. */ +export function decideIncidentResponse( + groups: readonly PoolAlertGroup[], + pools: ReadonlyMap, + recentPausesByPool: ReadonlyMap, + config: IncidentConfig +): IncidentDecision[] { + return groups.map((group) => + decideAutoPause( + group, + pools.get(group.poolId) ?? null, + recentPausesByPool.get(group.poolId) ?? 0, + config + ) + ) +} + +// ── Reporting ──────────────────────────────────────────────────────────────── + +export interface IncidentSummary { + /** True while the breaker is armed but only logging. */ + dryRun: boolean + /** Pools whose alerts met the thresholds, whether or not action was taken. */ + wouldFire: number + /** Pools actually paused by this scan. */ + paused: number + /** Pools held back by the cooldown. */ + cooldownBlocked: number + decisions: IncidentDecision[] +} + +/** + * The shape the scan endpoints report. It always answers "would this have + * fired", which is what makes a dry-run period useful rather than decorative. + */ +export function summarizeDecisions( + decisions: readonly IncidentDecision[], + config: IncidentConfig +): IncidentSummary { + return { + dryRun: config.dryRun, + wouldFire: decisions.filter((d) => d.wouldFire).length, + paused: decisions.filter((d) => d.executed).length, + cooldownBlocked: decisions.filter((d) => d.skipReason === "cooldown").length, + decisions: [...decisions], + } +} + +// ── On-chain authorization ─────────────────────────────────────────────────── + +/** + * An admin-signed authorization for `pause` on one pool, as stored. + * + * The contract asserts `admin.require_auth()`, and a + * `SorobanAuthorizationEntry` is signed independently of the transaction + * envelope, so the admin can sign one ahead of time and the platform can submit + * it later without ever holding the admin's key. The entry commits to the exact + * invocation, so it can authorise nothing except the pause the admin agreed to. + */ +export interface StoredPauseAuthorization { + id: string + contractAddress: string + adminAddress: string + /** Ledger the signature stops being valid at. */ + expirationLedger: number + usedAt: string | null + revokedAt: string | null +} + +export type AuthorizationRejection = + "used" | "revoked" | "expired" | "expiring_too_soon" | "wrong_contract" | "wrong_admin" + +export interface AuthorizationChoice { + authorization: StoredPauseAuthorization | null + /** Every candidate that was passed over, and why. */ + rejected: Array<{ id: string; reason: AuthorizationRejection }> +} + +/** + * Ledgers of headroom required before an entry is considered usable. + * + * Building, simulating and submitting takes a few seconds, and ledgers close + * about every six. An entry expiring inside this window would very likely be + * rejected by the time it lands, wasting its nonce for nothing. + */ +export const AUTH_LEDGER_SAFETY_MARGIN = 20 + +/** + * Picks the authorization to spend on this pause, if any. + * + * Candidates are rejected for stated reasons rather than silently filtered, so + * an admin whose pool did not auto-pause can be told why: their authorization + * expired, or was already spent, or was signed for an admin address that has + * since changed. + * + * Among usable entries it takes the one expiring soonest. They are perishable + * and single-use, so spending the most perishable first wastes the least. + */ +export function selectPauseAuthorization( + candidates: readonly StoredPauseAuthorization[], + currentLedger: number, + expected: { contractAddress: string; adminAddress: string }, + safetyMargin: number = AUTH_LEDGER_SAFETY_MARGIN +): AuthorizationChoice { + const rejected: AuthorizationChoice["rejected"] = [] + const usable: StoredPauseAuthorization[] = [] + + for (const candidate of candidates) { + const reason = rejectionFor(candidate, currentLedger, expected, safetyMargin) + if (reason) rejected.push({ id: candidate.id, reason }) + else usable.push(candidate) + } + + usable.sort( + (a, b) => a.expirationLedger - b.expirationLedger || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) + ) + + return { authorization: usable[0] ?? null, rejected } +} + +function rejectionFor( + candidate: StoredPauseAuthorization, + currentLedger: number, + expected: { contractAddress: string; adminAddress: string }, + safetyMargin: number +): AuthorizationRejection | null { + if (candidate.usedAt !== null) return "used" + if (candidate.revokedAt !== null) return "revoked" + // Checked before expiry so a mismatch is never reported as a stale entry: + // an authorization for another pool is a different problem entirely. + if (candidate.contractAddress !== expected.contractAddress) return "wrong_contract" + if (candidate.adminAddress.toLowerCase() !== expected.adminAddress.toLowerCase()) { + return "wrong_admin" + } + if (candidate.expirationLedger <= currentLedger) return "expired" + if (candidate.expirationLedger - currentLedger < safetyMargin) return "expiring_too_soon" + return null +} diff --git a/frontend/lib/pause-authorization.ts b/frontend/lib/pause-authorization.ts new file mode 100644 index 0000000..8e10daa --- /dev/null +++ b/frontend/lib/pause-authorization.ts @@ -0,0 +1,121 @@ +/** + * Lets a pool admin pre-authorise the automatic pause. + * + * The security circuit breaker can halt a pool at the platform level on its own, + * but the contract's `pause` asserts `admin.require_auth()`, and the admin is the + * creator's own wallet. Rather than ask anyone to hand over a key, this signs a + * single `SorobanAuthorizationEntry` that authorises exactly one call, + * `pause(admin)` on exactly one contract, and nothing else. + * + * The entry is signed independently of any transaction envelope, so the platform + * can wrap it in a transaction later, pay the fee itself, and submit it the + * moment the breaker trips. What the admin gives up is precisely the ability to + * pause their own pool, and only until the signature expires. + * + * Runs in the browser: it needs the wallet. + */ + +import { + Address, + authorizeInvocation, + rpc, + xdr, + type xdr as XdrNamespace, +} from "@stellar/stellar-sdk" +import type { StellarWalletsKit } from "@creit.tech/stellar-wallets-kit" +import { revokePauseAuthorizationMessage } from "@/lib/wallet-proof" + +/** + * How long a signature stays good for, in ledgers. About 30 days at six seconds + * a ledger. Long enough that re-signing is a rare chore, short enough that a + * forgotten authorization lapses on its own. + */ +export const DEFAULT_VALIDITY_LEDGERS = 432_000 + +export interface SignedPauseAuthorization { + /** Base64 XDR, ready to POST to /api/admin/pause-authorizations. */ + entryXdr: string + expirationLedger: number +} + +/** + * Builds the `pause(admin)` invocation and has the wallet sign it. + * + * The invocation is constructed here rather than taken from a simulation, so no + * transaction has to be built, funded or simulated just to produce a signature. + * `authorizeInvocation` attaches the nonce and expiration the host requires. + */ +export async function signPauseAuthorization(params: { + kit: StellarWalletsKit + rpcUrl: string + networkPassphrase: string + contractAddress: string + adminAddress: string + validityLedgers?: number +}): Promise { + const server = new rpc.Server(params.rpcUrl, { + allowHttp: params.rpcUrl.startsWith("http://"), + }) + + const latest = await server.getLatestLedger() + const expirationLedger = latest.sequence + (params.validityLedgers ?? DEFAULT_VALIDITY_LEDGERS) + + const invocation = new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(params.contractAddress).toScAddress(), + functionName: "pause", + args: [Address.fromString(params.adminAddress).toScVal()], + }) + ), + // No sub-invocations: this authorises the pause and cannot carry anything + // else along with it. The server checks the same thing before storing it. + subInvocations: [], + }) + + const entry = await authorizeInvocation( + async (preimage: XdrNamespace.HashIdPreimage) => { + // SEP-43: the wallet is handed the preimage to sign and returns the + // signature, which is what `authorizeInvocation` splices into the entry. + const { signedAuthEntry } = await params.kit.signAuthEntry(preimage.toXDR("base64"), { + address: params.adminAddress, + networkPassphrase: params.networkPassphrase, + }) + return Buffer.from(signedAuthEntry, "base64") + }, + expirationLedger, + invocation, + params.adminAddress, + params.networkPassphrase + ) + + return { entryXdr: entry.toXDR("base64"), expirationLedger } +} + +// ── Revoking ──────────────────────────────────────────────────────────────── + +/** + * Signs the proof needed to revoke an authorization. + * + * Revoking disarms the automatic on-chain pause, so the endpoint will not take + * an address in a request body as evidence of anything: an attacker preparing to + * drain a pool could otherwise switch off its defence using only public data. + * The wallet signs a short, timestamped message naming the exact authorization, + * and the server checks it against the pool's admin as recorded. + */ +export async function signRevokeProof(params: { + kit: StellarWalletsKit + networkPassphrase: string + adminAddress: string + authorizationId: string +}): Promise<{ signature: string; signedAt: number }> { + const signedAt = Date.now() + const message = revokePauseAuthorizationMessage(params.authorizationId, signedAt) + + const { signedMessage } = await params.kit.signMessage(message, { + address: params.adminAddress, + networkPassphrase: params.networkPassphrase, + }) + + return { signature: signedMessage, signedAt } +} diff --git a/frontend/lib/server/incident-actions.ts b/frontend/lib/server/incident-actions.ts new file mode 100644 index 0000000..4aab707 --- /dev/null +++ b/frontend/lib/server/incident-actions.ts @@ -0,0 +1,435 @@ +/** + * Carries out the circuit breaker's decisions. + * + * `lib/incident-response.ts` decides; this file acts. Splitting them keeps the + * decision logic pure and unit-testable, and keeps every write that can halt a + * live pool in one place where the ordering is explicit. + * + * Server-side only: it uses the service-role Supabase client. Never import from + * a client component. + * + * ## What "auto-pause" actually does, and what it cannot do + * + * The pause has two halves, and only one of them can be automatic: + * + * - **Platform pause (automatic).** The pool's `status` flips to `paused` with + * a reason. The app reads that status, so deposits and payouts stop being + * offered immediately. It is reversible from the admin endpoint. + * - **On-chain pause (automatic when the admin has pre-authorised it).** + * `rotational::pause` asserts `admin.require_auth()` and that the caller is the + * pool's stored admin, so the platform cannot call it on its own keys. But a + * `SorobanAuthorizationEntry` is signed independently of the transaction + * envelope: the admin signs one covering exactly `pause(admin)`, and the + * platform submits it when the breaker trips. See + * `lib/server/pause-onchain.ts`. + * + * When no usable authorization exists the incident stays at + * `onchain_status = 'pending'` and the admin signs the call themselves from the + * review screen. The platform pause has already happened either way, so the pool + * is protected whether or not the contract call goes through. + + * + * `emergency_withdraw` is not touched here, by anything, ever. + */ + +import type { SupabaseClient } from "@supabase/supabase-js" +import type { Database } from "@/lib/supabase" +import type { SecurityAlert } from "@/lib/security-rules" +import { + selectPauseAuthorization, + decideIncidentResponse, + groupCriticalAlertsByPool, + loadIncidentConfig, + summarizeDecisions, + type IncidentConfig, + type IncidentDecision, + type IncidentSummary, + type PoolState, + type StoredPauseAuthorization, +} from "@/lib/incident-response" +import { currentLedger, submitOnChainPause } from "@/lib/server/pause-onchain" + +type AdminClient = SupabaseClient + +export type ScanSource = "cron" | "admin" + +/** The activity type written to `pool_activity` so auto-actions show up in the audit log. */ +export const AUTO_PAUSE_ACTIVITY_TYPE = "security_auto_pause" + +export interface IncidentResponseResult extends IncidentSummary { + /** Incident rows written by this run. */ + incidentIds: string[] +} + +const EMPTY_RESULT = (config: IncidentConfig): IncidentResponseResult => ({ + ...summarizeDecisions([], config), + incidentIds: [], +}) + +// ── Reads ──────────────────────────────────────────────────────────────────── + +async function loadPoolStates( + admin: AdminClient, + poolIds: string[] +): Promise> { + const { data, error } = await admin.from("pools").select("id, name, status").in("id", poolIds) + + if (error) throw error + + return new Map( + (data ?? []).map((p) => [ + p.id, + { id: p.id, name: p.name, status: p.status as PoolState["status"] }, + ]) + ) +} + +/** + * How many times each pool has already been auto-paused inside the cooldown + * window. Only executed pauses count: a dry-run decision must not consume a + * pool's allowance, or arming the breaker later would find it already spent. + */ +async function countRecentAutoPauses( + admin: AdminClient, + poolIds: string[], + since: Date +): Promise> { + const { data, error } = await admin + .from("incidents") + .select("pool_id") + .in("pool_id", poolIds) + .eq("action", "pause") + .eq("executed", true) + .gte("created_at", since.toISOString()) + + if (error) throw error + + const counts = new Map() + for (const row of data ?? []) { + counts.set(row.pool_id, (counts.get(row.pool_id) ?? 0) + 1) + } + return counts +} + +// ── Writes ─────────────────────────────────────────────────────────────────── + +/** + * Writes the incident before anything is paused. + * + * The row starts as "decided but not carried out". If the process dies between + * here and the pause, what is left behind is an incident saying no action was + * taken, which is true and recoverable. The opposite ordering would leave a + * paused pool with no record of why. + */ +async function recordIncident( + admin: AdminClient, + decision: IncidentDecision, + source: ScanSource, + config: IncidentConfig +): Promise { + const { data, error } = await admin + .from("incidents") + .insert({ + pool_id: decision.poolId, + trigger_rule_ids: decision.ruleIds, + severity: decision.severity, + alert_count: decision.alertCount, + reason: decision.reason, + created_by_scan: true, + scan_source: source, + action: decision.action, + executed: false, + dry_run: config.dryRun, + skip_reason: decision.skipReason, + platform_paused: false, + onchain_status: "not_required", + status: "open", + }) + .select("id") + .single() + + if (error) { + console.error("Failed to record incident:", error) + return null + } + return data?.id ?? null +} + +/** Flips the pool to paused, but only if it is still active. */ +async function pausePool(admin: AdminClient, poolId: string, reason: string): Promise { + const { data, error } = await admin + .from("pools") + .update({ + status: "paused", + pause_reason: reason, + paused_at: new Date().toISOString(), + }) + .eq("id", poolId) + // Guards against a race with an admin pausing or completing the pool + // between the decision and this write. + .eq("status", "active") + .select("id") + + if (error) { + console.error("Failed to pause pool:", error) + return false + } + return (data ?? []).length > 0 +} + +/** + * Promotes the incident from "decided" to "carried out", once the pool is + * actually paused. `executed` is what the cooldown counts, so it must reflect + * pauses that really happened and nothing else. + * + * `onchain_status` becomes 'pending': the platform half is done, the contract + * half is waiting for the admin's signature. + */ +async function markIncidentExecuted(admin: AdminClient, incidentId: string): Promise { + const { error } = await admin + .from("incidents") + .update({ + executed: true, + platform_paused: true, + onchain_status: "pending", + // Maintained by hand, matching the rest of the schema, which has no + // updated_at triggers. + updated_at: new Date().toISOString(), + }) + .eq("id", incidentId) + + if (error) console.error("Failed to mark incident executed:", error) +} + +/** + * Tries to carry the pause through to the contract. + * + * Everything here is best-effort by design. The pool is already paused at the + * platform level before this runs, so every failure path downgrades to "an admin + * needs to sign it" rather than undoing anything. + * + * The authorization is marked spent BEFORE submission. Its nonce may reach the + * network even if the response never reaches us, and a consumed nonce can never + * succeed again, so burning it on an uncertain outcome is the honest accounting. + */ +async function attemptOnChainPause( + admin: AdminClient, + poolId: string, + incidentId: string +): Promise<{ status: "confirmed" | "failed" | "pending"; hash?: string; note: string }> { + const [{ data: pool }, { data: candidates }] = await Promise.all([ + admin.from("pools").select("contract_address, creator_address").eq("id", poolId).maybeSingle(), + admin + .from("pause_authorizations") + .select("id, contract_address, admin_address, expiration_ledger, used_at, revoked_at") + .eq("pool_id", poolId) + .is("used_at", null) + .is("revoked_at", null), + ]) + + if (!pool?.contract_address) { + return { status: "pending", note: "the pool has no contract address on record" } + } + + const ledger = await currentLedger() + if (ledger === null) { + return { status: "pending", note: "the Stellar RPC could not be reached" } + } + + const stored: StoredPauseAuthorization[] = (candidates ?? []).map((row) => ({ + id: row.id, + contractAddress: row.contract_address, + adminAddress: row.admin_address, + expirationLedger: row.expiration_ledger, + usedAt: row.used_at, + revokedAt: row.revoked_at, + })) + + const choice = selectPauseAuthorization(stored, ledger, { + contractAddress: pool.contract_address, + adminAddress: pool.creator_address, + }) + + if (!choice.authorization) { + const why = choice.rejected.length + ? "the stored authorizations were unusable (" + + choice.rejected.map((r) => r.reason).join(", ") + + ")" + : "no pause authorization has been signed for this pool" + return { status: "pending", note: why } + } + + const { data: claimed } = await admin + .from("pause_authorizations") + .update({ used_at: new Date().toISOString(), used_by_incident: incidentId }) + .eq("id", choice.authorization.id) + // Two scans racing must never both spend the same entry. + .is("used_at", null) + .select("entry_xdr") + + const entry = (claimed ?? [])[0] + if (!entry) { + return { status: "pending", note: "the authorization was claimed by another run" } + } + + const result = await submitOnChainPause({ + contractAddress: pool.contract_address, + adminAddress: pool.creator_address, + entryXdr: entry.entry_xdr, + }) + + if (result.status === "submitted") { + return { status: "confirmed", hash: result.hash, note: "paused on-chain" } + } + if (result.status === "unavailable") { + return { status: "pending", note: result.reason } + } + return { status: "failed", hash: result.hash, note: result.reason } +} + +/** Records the action where the existing admin audit log will show it. */ +async function writeAuditTrail( + admin: AdminClient, + decision: IncidentDecision, + incidentId: string | null +): Promise { + const { error } = await admin.from("pool_activity").insert({ + pool_id: decision.poolId, + activity_type: AUTO_PAUSE_ACTIVITY_TYPE, + description: incidentId ? `${decision.reason} (incident ${incidentId})` : decision.reason, + }) + + if (error) console.error("Failed to write audit trail:", error) +} + +/** + * Tells the people who can do something about it. + * + * The pool creator is the admin: they hold the key the on-chain pause needs, so + * the notification is both an alert and a call to action. + */ +async function notifyPoolAdmin( + admin: AdminClient, + poolId: string, + decision: IncidentDecision, + config: IncidentConfig, + onchainNote?: string +): Promise { + const { data: pool, error } = await admin + .from("pools") + .select("creator_address, name") + .eq("id", poolId) + .single() + + if (error || !pool) return + + const prefix = config.dryRun ? "[SECURITY DRY-RUN]" : "[SECURITY]" + const action = config.dryRun ? "would have been paused automatically" : "was paused automatically" + + const { error: notifyError } = await admin.from("notifications").insert({ + wallet_address: pool.creator_address, + pool_id: poolId, + activity_type: "security_auto_pause", + message: + `${prefix} "${pool.name}" ${action}. ${decision.reason}` + + (onchainNote ? ` On-chain: ${onchainNote}.` : "") + + " Review it in the admin panel.", + read: false, + }) + + if (notifyError) console.error("Failed to notify pool admin:", notifyError) +} + +// ── Orchestration ──────────────────────────────────────────────────────────── + +/** + * Runs the breaker over a scan's alerts and carries out what it decides. + * + * An incident row is written for every decision that met the thresholds, + * including the ones held back by cooldown or by dry-run, because those are the + * events worth auditing. Decisions below the threshold are not recorded: every + * scan would otherwise write a row for every pool with a single alert, and the + * table would stop being a list of incidents. + * + * Failures are contained per pool. One pool's failed write must not stop the + * breaker from protecting the next one. + */ +export async function runIncidentResponse( + admin: AdminClient, + alerts: readonly SecurityAlert[], + source: ScanSource, + config: IncidentConfig = loadIncidentConfig() +): Promise { + const groups = groupCriticalAlertsByPool(alerts) + if (groups.length === 0) return EMPTY_RESULT(config) + + const poolIds = groups.map((g) => g.poolId) + const since = new Date(Date.now() - config.cooldownWindowMs) + + const [pools, recentPauses] = await Promise.all([ + loadPoolStates(admin, poolIds), + countRecentAutoPauses(admin, poolIds, since), + ]) + + const decisions = decideIncidentResponse(groups, pools, recentPauses, config) + const incidentIds: string[] = [] + /** What happened to the contract call, per pool, for the admin notification. */ + const onchainNotes = new Map() + + for (const decision of decisions) { + if (!decision.wouldFire) continue + + const incidentId = await recordIncident(admin, decision, source, config) + if (incidentId) incidentIds.push(incidentId) + + if (!decision.executed) { + // Dry-run, cooldown or an inactive pool: the record and the notification + // are the whole point, so admins see what the breaker wanted to do. + await notifyPoolAdmin(admin, decision.poolId, decision, config) + continue + } + + const paused = await pausePool(admin, decision.poolId, decision.reason) + if (!paused) { + // Lost the race, or the write failed. The incident stays on record as + // not executed, which is exactly what happened. + continue + } + + if (incidentId) await markIncidentExecuted(admin, incidentId) + await writeAuditTrail(admin, decision, incidentId) + + // Carry it through to the contract when the admin has pre-authorised it. + if (incidentId) { + const onchain = await attemptOnChainPause(admin, decision.poolId, incidentId).catch( + (error) => { + console.error("On-chain pause failed:", error) + return { + status: "pending" as const, + hash: undefined, + note: "the on-chain attempt errored", + } + } + ) + await admin + .from("incidents") + .update({ + onchain_status: onchain.status, + onchain_tx_hash: onchain.hash ?? null, + updated_at: new Date().toISOString(), + }) + .eq("id", incidentId) + onchainNotes.set(decision.poolId, onchain.note) + } + + await notifyPoolAdmin( + admin, + decision.poolId, + decision, + config, + onchainNotes.get(decision.poolId) + ) + } + + return { ...summarizeDecisions(decisions, config), incidentIds } +} diff --git a/frontend/lib/server/pause-onchain.test.ts b/frontend/lib/server/pause-onchain.test.ts new file mode 100644 index 0000000..ca75f5f --- /dev/null +++ b/frontend/lib/server/pause-onchain.test.ts @@ -0,0 +1,128 @@ +// Unit tests for the pause authorization inspector. +// +// A stored authorization is a bearer credential the platform will submit on the +// admin's behalf, so what matters most is what it REFUSES to store. These build +// real entries with the real XDR library and sign them with a throwaway key, so +// the checks run against the same bytes a wallet would produce. No network. +import { test } from "node:test" +import assert from "node:assert" +import { Address, authorizeInvocation, Keypair, Networks, xdr } from "@stellar/stellar-sdk" +import { inspectPauseAuthorization } from "./pause-onchain" + +const PASSPHRASE = Networks.TESTNET +const EXPIRATION = 123_456 + +/** A strkey contract address standing in for a deployed pool. */ +const CONTRACT = Address.contract(Buffer.alloc(32, 7)).toString() + +function invocation( + target: string, + { + fnName = "pause", + withSubInvocation = false, + args, + }: { + fnName?: string + withSubInvocation?: boolean + args?: xdr.ScVal[] + } = {} +) { + return new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(CONTRACT).toScAddress(), + functionName: fnName, + args: args ?? [Address.fromString(target).toScVal()], + }) + ), + subInvocations: withSubInvocation + ? [ + new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(CONTRACT).toScAddress(), + functionName: "emergency_withdraw", + args: [], + }) + ), + subInvocations: [], + }), + ] + : [], + }) +} + +async function sign(signer: Keypair, inv: xdr.SorobanAuthorizedInvocation): Promise { + const entry = await authorizeInvocation(signer, EXPIRATION, inv, signer.publicKey(), PASSPHRASE) + return entry.toXDR("base64") +} + +test("inspector: accepts a properly signed pause authorization", async () => { + const admin = Keypair.random() + const result = inspectPauseAuthorization(await sign(admin, invocation(admin.publicKey()))) + + assert.strictEqual(result.ok, true, result.reason) + assert.strictEqual(result.adminAddress, admin.publicKey()) + assert.strictEqual(result.contractAddress, CONTRACT) + assert.strictEqual(result.expirationLedger, EXPIRATION) +}) + +test("inspector: refuses something that is not an authorization entry", () => { + assert.strictEqual(inspectPauseAuthorization("not-xdr").ok, false) + assert.strictEqual(inspectPauseAuthorization("").ok, false) +}) + +test("inspector: refuses an entry authorising emergency_withdraw", async () => { + // The hard boundary. An admin must never be able to hand the platform a + // credential that moves member funds, by accident or otherwise. + const admin = Keypair.random() + const result = inspectPauseAuthorization( + await sign(admin, invocation(admin.publicKey(), { fnName: "emergency_withdraw" })) + ) + + assert.strictEqual(result.ok, false) + assert.match(result.reason ?? "", /emergency_withdraw/) +}) + +test("inspector: refuses an entry smuggling a second call as a sub-invocation", async () => { + const admin = Keypair.random() + const result = inspectPauseAuthorization( + await sign(admin, invocation(admin.publicKey(), { withSubInvocation: true })) + ) + + assert.strictEqual(result.ok, false) + assert.match(result.reason ?? "", /more than the pause call/) +}) + +test("inspector: refuses an entry that pauses on behalf of another address", async () => { + const admin = Keypair.random() + const someoneElse = Keypair.random() + const result = inspectPauseAuthorization(await sign(admin, invocation(someoneElse.publicKey()))) + + assert.strictEqual(result.ok, false) + assert.match(result.reason ?? "", /different address than its signer/) +}) + +test("inspector: refuses a pause call with the wrong number of arguments", async () => { + const admin = Keypair.random() + const result = inspectPauseAuthorization( + await sign(admin, invocation(admin.publicKey(), { args: [] })) + ) + + assert.strictEqual(result.ok, false) + assert.match(result.reason ?? "", /exactly the admin address/) +}) + +test("inspector: refuses a source-account credential, which delegates nothing", async () => { + // A source-account credential authorises whoever submits the transaction. + // Storing one would be meaningless, and treating it as a delegation would be + // worse than meaningless. + const entry = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + rootInvocation: invocation(Keypair.random().publicKey()), + }) + const result = inspectPauseAuthorization(entry.toXDR("base64")) + + assert.strictEqual(result.ok, false) + assert.match(result.reason ?? "", /not signed by an address/) +}) diff --git a/frontend/lib/server/pause-onchain.ts b/frontend/lib/server/pause-onchain.ts new file mode 100644 index 0000000..20bc611 --- /dev/null +++ b/frontend/lib/server/pause-onchain.ts @@ -0,0 +1,254 @@ +/** + * Submits the on-chain half of an automatic pause. + * + * `rotational::pause` asserts `admin.require_auth()` and that the caller is the + * pool's stored admin, which is the creator's own wallet. The platform holds no + * key that satisfies that, and `SPONSOR_SECRET_KEY` cannot stand in: a fee bump + * pays for a transaction, it authorises nothing inside it. + * + * What makes this possible anyway is that a `SorobanAuthorizationEntry` is + * signed independently of the transaction envelope. The admin signs one entry, + * from their own wallet, covering exactly `pause(admin)` on exactly their pool's + * contract. The platform stores it and, when the breaker trips, wraps it in a + * transaction it pays for and signs the envelope of. The two signatures are + * separate: the admin authorises the call, the platform authorises the fee. + * + * So the platform can pause, and can do nothing else. It never sees the admin's + * key, and the entry it holds commits to one invocation with one nonce. + * + * Server-side only. + */ + +import { + Address, + BASE_FEE, + Keypair, + Networks, + Operation, + TransactionBuilder, + rpc, + xdr, +} from "@stellar/stellar-sdk" +import { getServerRpc } from "@/lib/server/stellar-events" + +/** + * The network the contracts live on. Matches `components/web3-provider.tsx`, + * which is a client component and cannot be imported here. + */ +export const NETWORK_PASSPHRASE = process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET + +/** How long to wait for the ledger to include the transaction. */ +const CONFIRM_TIMEOUT_MS = 30_000 +const CONFIRM_POLL_MS = 2_000 + +export type PauseSubmission = + | { status: "submitted"; hash: string } + | { status: "unavailable"; reason: string } + | { status: "failed"; reason: string; hash?: string } + +/** + * Wraps a pre-signed authorization in a transaction and submits it. + * + * Returns a result rather than throwing: the platform pause has already + * happened by the time this runs, and a failure here must downgrade the + * incident to "an admin needs to sign it", never lose the pause. + */ +export async function submitOnChainPause(params: { + contractAddress: string + adminAddress: string + /** Base64 XDR of the admin-signed SorobanAuthorizationEntry. */ + entryXdr: string +}): Promise { + const secret = process.env.SPONSOR_SECRET_KEY + if (!secret) { + return { status: "unavailable", reason: "SPONSOR_SECRET_KEY is not configured" } + } + + let sponsor: Keypair + let entry: xdr.SorobanAuthorizationEntry + try { + sponsor = Keypair.fromSecret(secret) + entry = xdr.SorobanAuthorizationEntry.fromXDR(params.entryXdr, "base64") + } catch (error) { + return { + status: "failed", + reason: `Could not load the sponsor key or the stored authorization: ${message(error)}`, + } + } + + try { + const server = getServerRpc() + const account = await server.getAccount(sponsor.publicKey()) + + const built = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: params.contractAddress, + function: "pause", + args: [Address.fromString(params.adminAddress).toScVal()], + // The admin's signature travels here, not on the envelope. + auth: [entry], + }) + ) + .setTimeout(60) + .build() + + const simulation = await server.simulateTransaction(built) + if (rpc.Api.isSimulationError(simulation)) { + // The usual causes are an expired entry, a spent nonce, or an admin that + // no longer matches the contract's stored one. + return { status: "failed", reason: `Simulation failed: ${simulation.error}` } + } + + // `assembleTransaction` keeps auth entries the operation already carries and + // only falls back to the simulation's when there are none, so the admin's + // signature survives being given a footprint and a resource fee. + const prepared = rpc.assembleTransaction(built, simulation).build() + prepared.sign(sponsor) + + const sent = await server.sendTransaction(prepared) + if (sent.status === "ERROR") { + return { + status: "failed", + reason: `The network rejected the transaction: ${JSON.stringify(sent.errorResult ?? sent.status)}`, + hash: sent.hash, + } + } + + const confirmed = await waitForTransaction(server, sent.hash) + if (confirmed !== "SUCCESS") { + return { + status: "failed", + reason: `The transaction did not confirm: ${confirmed}`, + hash: sent.hash, + } + } + + return { status: "submitted", hash: sent.hash } + } catch (error) { + return { status: "failed", reason: message(error) } + } +} + +/** + * Polls until the ledger has an answer. + * + * A pending result at the end of the window is reported as such rather than as + * a failure: the transaction may still land, and the incident says an admin + * should check rather than claiming it did not happen. + */ +async function waitForTransaction( + server: rpc.Server, + hash: string +): Promise<"SUCCESS" | "FAILED" | "NOT_FOUND" | "PENDING"> { + const deadline = Date.now() + CONFIRM_TIMEOUT_MS + + while (Date.now() < deadline) { + const result = await server.getTransaction(hash) + if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) return "SUCCESS" + if (result.status === rpc.Api.GetTransactionStatus.FAILED) return "FAILED" + await new Promise((resolve) => setTimeout(resolve, CONFIRM_POLL_MS)) + } + + return "PENDING" +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** The ledger the RPC is currently on, used to judge whether an entry is stale. */ +export async function currentLedger(): Promise { + try { + const server = getServerRpc() + const latest = await server.getLatestLedger() + return latest.sequence + } catch { + return null + } +} + +// ── Inspecting what an admin actually signed ───────────────────────────────── + +export interface AuthorizationInspection { + ok: boolean + reason?: string + adminAddress?: string + contractAddress?: string + expirationLedger?: number +} + +/** + * Reads a submitted authorization and checks it authorises a pause and nothing + * else. + * + * The client says what it signed; this decides whether to believe it. An entry + * is a bearer credential the platform will submit later, so it is checked here + * rather than trusted: it has to be address-credentialed (a source-account + * credential would authorise whoever submits, which is not a delegation), it has + * to invoke `pause` with the signer as its only argument, and it must carry no + * sub-invocations, so it cannot smuggle a second call along with the pause. + */ +export function inspectPauseAuthorization(entryXdr: string): AuthorizationInspection { + let entry: xdr.SorobanAuthorizationEntry + try { + entry = xdr.SorobanAuthorizationEntry.fromXDR(entryXdr, "base64") + } catch { + return { ok: false, reason: "That is not a valid authorization entry." } + } + + try { + const credentials = entry.credentials() + if (credentials.switch().name !== "sorobanCredentialsAddress") { + return { + ok: false, + reason: "The entry is not signed by an address, so it delegates nothing.", + } + } + + const address = credentials.address() + const signer = Address.fromScAddress(address.address()).toString() + const expirationLedger = address.signatureExpirationLedger() + + const invocation = entry.rootInvocation() + if (invocation.subInvocations().length > 0) { + return { ok: false, reason: "The entry authorises more than the pause call." } + } + + const fn = invocation.function() + if (fn.switch().name !== "sorobanAuthorizedFunctionTypeContractFn") { + return { ok: false, reason: "The entry does not authorise a contract call." } + } + + const call = fn.contractFn() + const functionName = call.functionName().toString() + if (functionName !== "pause") { + return { ok: false, reason: `The entry authorises "${functionName}", not "pause".` } + } + + const args = call.args() + if (args.length !== 1) { + return { ok: false, reason: "The pause call must take exactly the admin address." } + } + + const argAddress = Address.fromScVal(args[0]).toString() + if (argAddress !== signer) { + return { + ok: false, + reason: "The entry would pause on behalf of a different address than its signer.", + } + } + + return { + ok: true, + adminAddress: signer, + contractAddress: Address.fromScAddress(call.contractAddress()).toString(), + expirationLedger, + } + } catch (error) { + return { ok: false, reason: `The entry could not be read: ${message(error)}` } + } +} diff --git a/frontend/lib/server/wallet-proof.test.ts b/frontend/lib/server/wallet-proof.test.ts new file mode 100644 index 0000000..15f1c82 --- /dev/null +++ b/frontend/lib/server/wallet-proof.test.ts @@ -0,0 +1,212 @@ +// Unit tests for the wallet-signature proof. +// +// This is what stands between a public pool id and someone disarming a pool's +// automatic pause, so the cases that matter are the forgeries: another key, a +// different authorization, a stale timestamp. +import { test } from "node:test" +import assert from "node:assert" +import { createHash } from "node:crypto" +import { Keypair } from "@stellar/stellar-sdk" +import { checkWalletProof, verifySignedMessage } from "./wallet-proof" +import { + archivePoolMessage, + PROOF_MAX_AGE_MS, + proofIsFresh, + revokePauseAuthorizationMessage, + unarchivePoolMessage, +} from "../wallet-proof" + +/** Signs the way SEP-53 specifies: ed25519 over SHA-256 of the prefixed message. */ +function sign(keypair: Keypair, message: string): string { + const payload = Buffer.concat([ + Buffer.from("Stellar Signed Message:\n", "utf8"), + Buffer.from(message, "utf8"), + ]) + return keypair.sign(createHash("sha256").update(payload).digest()).toString("base64") +} + +test("proof: a signature from the right key is accepted", () => { + const admin = Keypair.random() + const message = revokePauseAuthorizationMessage("auth-1", Date.now()) + + assert.strictEqual(verifySignedMessage(admin.publicKey(), message, sign(admin, message)), true) +}) + +test("proof: a signature from another key is refused", () => { + const admin = Keypair.random() + const attacker = Keypair.random() + const message = revokePauseAuthorizationMessage("auth-1", Date.now()) + + assert.strictEqual( + verifySignedMessage(admin.publicKey(), message, sign(attacker, message)), + false + ) +}) + +test("proof: a signature for a different authorization does not transfer", () => { + // Otherwise revoking one pool's authorization would revoke another's. + const admin = Keypair.random() + const now = Date.now() + const signature = sign(admin, revokePauseAuthorizationMessage("auth-1", now)) + + assert.strictEqual( + verifySignedMessage( + admin.publicKey(), + revokePauseAuthorizationMessage("auth-2", now), + signature + ), + false + ) +}) + +test("proof: garbage signatures and addresses are refused, not thrown on", () => { + const admin = Keypair.random() + const message = revokePauseAuthorizationMessage("auth-1", Date.now()) + + assert.strictEqual(verifySignedMessage(admin.publicKey(), message, "not-base64!!"), false) + assert.strictEqual(verifySignedMessage(admin.publicKey(), message, ""), false) + assert.strictEqual(verifySignedMessage("not-an-address", message, sign(admin, message)), false) +}) + +test("freshness: accepts now, rejects beyond the window on either side", () => { + const now = Date.now() + + assert.strictEqual(proofIsFresh(now, now), true) + assert.strictEqual(proofIsFresh(now - PROOF_MAX_AGE_MS + 1000, now), true) + assert.strictEqual(proofIsFresh(now - PROOF_MAX_AGE_MS - 1000, now), false) + // A clock ahead of the server is just as suspect as one behind it. + assert.strictEqual(proofIsFresh(now + PROOF_MAX_AGE_MS + 1000, now), false) + assert.strictEqual(proofIsFresh(Number.NaN, now), false) +}) + +test("check: a complete, fresh proof passes", () => { + const admin = Keypair.random() + const signedAt = Date.now() + const message = revokePauseAuthorizationMessage("auth-1", signedAt) + + const result = checkWalletProof({ + address: admin.publicKey(), + message, + signature: sign(admin, message), + signedAt, + }) + assert.strictEqual(result.ok, true, result.reason) +}) + +test("check: a missing signature is refused with a usable message", () => { + const admin = Keypair.random() + const signedAt = Date.now() + + const result = checkWalletProof({ + address: admin.publicKey(), + message: revokePauseAuthorizationMessage("auth-1", signedAt), + signature: undefined, + signedAt, + }) + assert.strictEqual(result.ok, false) + assert.match(result.reason ?? "", /signature is required/) +}) + +test("check: a captured proof stops working once it goes stale", () => { + const admin = Keypair.random() + const signedAt = Date.now() - PROOF_MAX_AGE_MS - 60_000 + const message = revokePauseAuthorizationMessage("auth-1", signedAt) + + const result = checkWalletProof({ + address: admin.publicKey(), + message, + signature: sign(admin, message), + signedAt, + }) + assert.strictEqual(result.ok, false) + assert.match(result.reason ?? "", /too old/) +}) + +test("check: a valid signature from the wrong account is refused", () => { + // The endpoint checks against the pool's admin as recorded, so an attacker + // signing with their own key gets nowhere even with a well-formed request. + const attacker = Keypair.random() + const poolAdmin = Keypair.random() + const signedAt = Date.now() + const message = revokePauseAuthorizationMessage("auth-1", signedAt) + + const result = checkWalletProof({ + address: poolAdmin.publicKey(), + message, + signature: sign(attacker, message), + signedAt, + }) + assert.strictEqual(result.ok, false) + assert.match(result.reason ?? "", /does not match/) +}) + +// ── Archiving ─────────────────────────────────────────────────────────────── +// +// A pool id and its creator's address are both public, so the archive and +// unarchive endpoints cannot treat an address in a request body as evidence. +// These pin the two properties the fix depends on. + +test("archive: the pool admin's own signature is accepted", () => { + const admin = Keypair.random() + const message = archivePoolMessage("pool-1", Date.now()) + + assert.strictEqual(verifySignedMessage(admin.publicKey(), message, sign(admin, message)), true) +}) + +test("archive: naming the admin's address does not archive their pool", () => { + // The whole bug this closes: the caller supplies admin_address, so anybody + // can claim to be the creator. Only the signature settles it. + const admin = Keypair.random() + const attacker = Keypair.random() + const message = archivePoolMessage("pool-1", Date.now()) + + assert.strictEqual( + checkWalletProof({ + address: admin.publicKey(), + message, + signature: sign(attacker, message), + signedAt: Date.now(), + }).ok, + false + ) +}) + +test("archive: a proof for one pool does not archive another", () => { + const admin = Keypair.random() + const now = Date.now() + const signature = sign(admin, archivePoolMessage("pool-1", now)) + + assert.strictEqual( + verifySignedMessage(admin.publicKey(), archivePoolMessage("pool-2", now), signature), + false + ) +}) + +test("archive: a proof to archive cannot be replayed to unarchive", () => { + // The two messages differ by more than the pool id for exactly this reason. + const admin = Keypair.random() + const now = Date.now() + const signature = sign(admin, archivePoolMessage("pool-1", now)) + + assert.strictEqual( + verifySignedMessage(admin.publicKey(), unarchivePoolMessage("pool-1", now), signature), + false + ) +}) + +test("archive: a captured proof stops working once it goes stale", () => { + const admin = Keypair.random() + const signedAt = Date.now() - PROOF_MAX_AGE_MS - 1_000 + const message = archivePoolMessage("pool-1", signedAt) + + assert.strictEqual(proofIsFresh(signedAt), false) + assert.strictEqual( + checkWalletProof({ + address: admin.publicKey(), + message, + signature: sign(admin, message), + signedAt, + }).ok, + false + ) +}) diff --git a/frontend/lib/server/wallet-proof.ts b/frontend/lib/server/wallet-proof.ts new file mode 100644 index 0000000..4583466 --- /dev/null +++ b/frontend/lib/server/wallet-proof.ts @@ -0,0 +1,97 @@ +/** + * Checking that a request really comes from the wallet it claims. + * + * Most admin endpoints in this codebase compare a `callerAddress` from the + * request against the pool's `creator_address`. That is a claim, not a proof: + * both values are public, so anyone can send either. + * + * For most of those endpoints that is a pre-existing trade-off. It is not + * acceptable for revoking a pause authorization, because revoking one disarms + * the automatic on-chain pause: an attacker preparing to drain a pool could + * switch off the very thing meant to stop them, using only public data. So that + * one action asks for a signature instead. + * + * SEP-53 is used because it is what wallets implement (`signMessage` across the + * wallet-kit modules) and what `require_auth` already relies on for classic + * accounts: an ed25519 signature over the SHA-256 of a prefixed message. + * + * Server-side only. + */ + +import { createHash } from "node:crypto" +import { Keypair, StrKey } from "@stellar/stellar-sdk" +import { proofIsFresh } from "@/lib/wallet-proof" + +/** SEP-53 framing. Signers prepend exactly this before hashing. */ +const SEP53_PREFIX = "Stellar Signed Message:\n" + +export interface ProofCheck { + ok: boolean + reason?: string +} + +/** + * Verifies a SEP-53 signature over `message` for `address`. + * + * Both framings are accepted: the SHA-256 of the prefixed message, which is what + * SEP-53 specifies, and the prefixed bytes handed straight to ed25519, which + * some signers produce instead. Either way the signature can only come from the + * account's private key, so accepting both costs nothing in strength and saves + * the endpoint from breaking on a wallet that frames it the other way. + */ +export function verifySignedMessage( + address: string, + message: string, + signatureBase64: string +): boolean { + if (!StrKey.isValidEd25519PublicKey(address)) return false + + let signature: Buffer + try { + signature = Buffer.from(signatureBase64, "base64") + } catch { + return false + } + if (signature.length !== 64) return false + + const payload = Buffer.concat([Buffer.from(SEP53_PREFIX, "utf8"), Buffer.from(message, "utf8")]) + const digest = createHash("sha256").update(payload).digest() + + try { + const keypair = Keypair.fromPublicKey(address) + return keypair.verify(digest, signature) || keypair.verify(payload, signature) + } catch { + return false + } +} + +/** + * The whole check an endpoint needs: the proof is fresh, and it was signed by + * the address the action belongs to. + * + * Returns a reason rather than a bare boolean so the caller can tell an admin + * whose clock drifted from one whose wallet signed with the wrong account. + */ +export function checkWalletProof(params: { + address: string + message: string + signature: unknown + signedAt: unknown +}): ProofCheck { + const signature = typeof params.signature === "string" ? params.signature : "" + const signedAt = Number(params.signedAt) + + if (!signature) { + return { ok: false, reason: "A wallet signature is required for this action." } + } + if (!proofIsFresh(signedAt)) { + return { + ok: false, + reason: "That signature is too old or its timestamp is off. Sign again.", + } + } + if (!verifySignedMessage(params.address, params.message, signature)) { + return { ok: false, reason: "The signature does not match this pool's admin." } + } + return { ok: true } +} diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts index fcc75aa..6915bfb 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -43,6 +43,8 @@ export type Database = { contribution_amount: number | null round_duration: number | null frequency: string | null + pause_reason: string | null + paused_at: string | null deadline: string | null minimum_deposit: number | null withdrawal_fee: number | null @@ -71,6 +73,8 @@ export type Database = { contribution_amount?: number | null round_duration?: number | null frequency?: string | null + pause_reason?: string | null + paused_at?: string | null deadline?: string | null minimum_deposit?: number | null withdrawal_fee?: number | null @@ -99,6 +103,8 @@ export type Database = { contribution_amount?: number | null round_duration?: number | null frequency?: string | null + pause_reason?: string | null + paused_at?: string | null deadline?: string | null minimum_deposit?: number | null withdrawal_fee?: number | null @@ -690,6 +696,110 @@ export type Database = { } Relationships: [] } + pause_authorizations: { + Row: { + id: string + pool_id: string + contract_address: string + admin_address: string + entry_xdr: string + expiration_ledger: number + used_at: string | null + used_by_incident: string | null + revoked_at: string | null + created_at: string + } + Insert: { + id?: string + pool_id: string + contract_address: string + admin_address: string + entry_xdr: string + expiration_ledger: number + used_at?: string | null + used_by_incident?: string | null + revoked_at?: string | null + created_at?: string + } + Update: { + used_at?: string | null + used_by_incident?: string | null + revoked_at?: string | null + } + Relationships: [] + } + incidents: { + Row: { + id: string + pool_id: string + trigger_rule_ids: string[] + severity: "info" | "warning" | "critical" + alert_count: number + reason: string + created_by_scan: boolean + scan_source: "cron" | "admin" | "manual" + action: "pause" | "none" + executed: boolean + dry_run: boolean + skip_reason: + | "below_threshold" + | "already_paused" + | "pool_not_active" + | "cooldown" + | "unknown_pool" + | null + platform_paused: boolean + onchain_status: "not_required" | "pending" | "confirmed" | "failed" + onchain_tx_hash: string | null + status: "open" | "resolved" + resolved_by: string | null + resolution_notes: string | null + resolved_at: string | null + created_at: string + updated_at: string + } + Insert: { + id?: string + pool_id: string + trigger_rule_ids?: string[] + severity?: "info" | "warning" | "critical" + alert_count?: number + reason: string + created_by_scan?: boolean + scan_source?: "cron" | "admin" | "manual" + action?: "pause" | "none" + executed?: boolean + dry_run?: boolean + skip_reason?: + | "below_threshold" + | "already_paused" + | "pool_not_active" + | "cooldown" + | "unknown_pool" + | null + platform_paused?: boolean + onchain_status?: "not_required" | "pending" | "confirmed" | "failed" + onchain_tx_hash?: string | null + status?: "open" | "resolved" + resolved_by?: string | null + resolution_notes?: string | null + resolved_at?: string | null + created_at?: string + updated_at?: string + } + Update: { + executed?: boolean + platform_paused?: boolean + onchain_status?: "not_required" | "pending" | "confirmed" | "failed" + onchain_tx_hash?: string | null + status?: "open" | "resolved" + resolved_by?: string | null + resolution_notes?: string | null + resolved_at?: string | null + updated_at?: string + } + Relationships: [] + } bridge_transactions: { Row: { id: string diff --git a/frontend/lib/wallet-proof.ts b/frontend/lib/wallet-proof.ts new file mode 100644 index 0000000..258bff6 --- /dev/null +++ b/frontend/lib/wallet-proof.ts @@ -0,0 +1,65 @@ +/** + * The message a wallet signs to prove it controls an address. + * + * Shared by the browser, which asks the wallet to sign it, and the server, which + * rebuilds it byte for byte and checks the signature. It lives in its own module + * so neither side can drift from the other, and so the server never has to + * import anything that touches a wallet. + */ + +/** + * How far a proof's timestamp may be from the server's clock. + * + * Short enough that a captured proof stops working quickly, wide enough to + * survive an unsynchronised laptop clock and a slow signature. + */ +export const PROOF_MAX_AGE_MS = 5 * 60 * 1000 + +/** + * The exact text signed to revoke a pause authorization. + * + * It names the action and the specific authorization, so a proof captured for + * one revocation cannot be replayed against another, and carries a timestamp so + * it stops being useful within minutes. Replaying it against the *same* + * authorization achieves nothing: revoking an already revoked entry is a no-op. + */ +export function revokePauseAuthorizationMessage(authorizationId: string, signedAt: number): string { + return [ + "JointSave: revoke pause authorization", + `authorization: ${authorizationId}`, + `at: ${signedAt}`, + "Signing this does not move funds.", + ].join("\n") +} + +/** + * The exact text signed to archive a pool, and to bring one back. + * + * Archiving is an admin action that changes what every member sees, so it is + * proved the same way a pause authorization is. The two messages differ by more + * than the pool id so a proof gathered to archive cannot be turned around and + * replayed to unarchive, and each carries a timestamp so it expires in minutes. + */ +export function archivePoolMessage(poolId: string, signedAt: number): string { + return [ + "JointSave: archive pool", + `pool: ${poolId}`, + `at: ${signedAt}`, + "Signing this does not move funds.", + ].join("\n") +} + +export function unarchivePoolMessage(poolId: string, signedAt: number): string { + return [ + "JointSave: unarchive pool", + `pool: ${poolId}`, + `at: ${signedAt}`, + "Signing this does not move funds.", + ].join("\n") +} + +/** True while a proof's timestamp is close enough to now to be accepted. */ +export function proofIsFresh(signedAt: number, now: number = Date.now()): boolean { + if (!Number.isFinite(signedAt)) return false + return Math.abs(now - signedAt) <= PROOF_MAX_AGE_MS +} diff --git a/frontend/package.json b/frontend/package.json index b180912..e5d4c95 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "start": "next start", - "test:unit": "tsx --test lib/archival.test.ts lib/csv-export.test.ts lib/activity-query.test.ts lib/soroban-event-mapping.test.ts lib/analytics.test.ts lib/batch-deposit.test.ts lib/deposit-calendar.test.ts lib/cctp-bridge.test.ts lib/ical-export.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/contract-version.test.ts lib/error-reporting.test.ts lib/pending-transactions.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts app/api/notifications/route.test.ts app/api/user-profile/route.test.ts app/api/notifications/digest-preferences/route.test.ts app/api/notifications/digest/unsubscribe/route.test.ts app/api/cron/send-digests/route.test.ts app/api/portfolio/summary/route.test.ts app/api/templates/route.test.ts", + "test:unit": "tsx --test lib/archival.test.ts lib/csv-export.test.ts lib/activity-query.test.ts lib/soroban-event-mapping.test.ts lib/analytics.test.ts lib/batch-deposit.test.ts lib/deposit-calendar.test.ts lib/cctp-bridge.test.ts lib/ical-export.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/contract-version.test.ts lib/error-reporting.test.ts lib/pending-transactions.test.ts lib/incident-response.test.ts lib/server/pause-onchain.test.ts lib/server/wallet-proof.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts app/api/notifications/route.test.ts app/api/user-profile/route.test.ts app/api/notifications/digest-preferences/route.test.ts app/api/notifications/digest/unsubscribe/route.test.ts app/api/cron/send-digests/route.test.ts app/api/portfolio/summary/route.test.ts app/api/templates/route.test.ts", "test:components": "vitest run", "test:components:coverage": "vitest run --coverage", "test:e2e": "playwright test", diff --git a/supabase/migrations/20260827120000_incident_response.sql b/supabase/migrations/20260827120000_incident_response.sql new file mode 100644 index 0000000..95fc798 --- /dev/null +++ b/supabase/migrations/20260827120000_incident_response.sql @@ -0,0 +1,76 @@ +-- Automated incident response for critical security alerts. +-- +-- The monitoring system in `lib/security-rules.ts` already detects trouble and +-- writes to `security_alerts`. This adds the record of what was DONE about it: +-- every automatic pause, every decision not to act, and how it was resolved. +-- +-- The incident row is written before the pool is paused, so a crash midway +-- leaves an incident with no pause (visible, recoverable) rather than a paused +-- pool nobody can explain. + +CREATE TABLE IF NOT EXISTS public.incidents ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + pool_id UUID NOT NULL, + + -- What tripped the breaker. + trigger_rule_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + severity TEXT NOT NULL DEFAULT 'critical' CHECK (severity IN ('info', 'warning', 'critical')), + alert_count INTEGER NOT NULL DEFAULT 0, + reason TEXT NOT NULL, + + -- Where the decision came from. `created_by_scan` distinguishes the automated + -- path from an incident an admin opened by hand. + created_by_scan BOOLEAN NOT NULL DEFAULT true, + scan_source TEXT NOT NULL DEFAULT 'cron' CHECK (scan_source IN ('cron', 'admin', 'manual')), + + -- What the breaker decided, and whether it was allowed to act. A dry-run + -- incident records `action = 'pause'` with `executed = false`, which is what + -- makes a dry-run period measurable instead of invisible. + action TEXT NOT NULL DEFAULT 'none' CHECK (action IN ('pause', 'none')), + executed BOOLEAN NOT NULL DEFAULT false, + dry_run BOOLEAN NOT NULL DEFAULT true, + skip_reason TEXT CHECK ( + skip_reason IN ('below_threshold', 'already_paused', 'pool_not_active', 'cooldown', 'unknown_pool') + ), + + -- The platform-level pause is immediate and reversible. The on-chain pause + -- needs the pool admin's signature (the contract asserts `admin.require_auth()`), + -- so it is tracked separately and stays 'pending' until an admin signs it. + platform_paused BOOLEAN NOT NULL DEFAULT false, + onchain_status TEXT NOT NULL DEFAULT 'not_required' + CHECK (onchain_status IN ('not_required', 'pending', 'confirmed', 'failed')), + onchain_tx_hash TEXT, + + -- Review and recovery. + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved')), + resolved_by TEXT, + resolution_notes TEXT, + resolved_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The cooldown query is "auto-pauses for this pool since T", so it is the one +-- that has to stay fast as the table grows. +CREATE INDEX IF NOT EXISTS idx_incidents_pool_created + ON public.incidents (pool_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_incidents_status ON public.incidents (status); +CREATE INDEX IF NOT EXISTS idx_incidents_executed ON public.incidents (executed); +CREATE INDEX IF NOT EXISTS idx_incidents_created_at ON public.incidents (created_at DESC); + +ALTER TABLE public.incidents ENABLE ROW LEVEL SECURITY; + +-- Mirrors `security_alerts`: readable, with every write going through the +-- service-role API routes. +CREATE POLICY "incidents_select_public" + ON public.incidents + FOR SELECT + USING (true); + +-- ── Pause context on the pool ─────────────────────────────────────────────── +-- `pools.status` already carries 'paused'; these say why and since when, so a +-- member looking at a halted pool gets an explanation rather than a dead screen. + +ALTER TABLE public.pools ADD COLUMN IF NOT EXISTS pause_reason TEXT; +ALTER TABLE public.pools ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ; diff --git a/supabase/migrations/20260827130000_pause_authorizations.sql b/supabase/migrations/20260827130000_pause_authorizations.sql new file mode 100644 index 0000000..1570920 --- /dev/null +++ b/supabase/migrations/20260827130000_pause_authorizations.sql @@ -0,0 +1,53 @@ +-- Pre-signed authorizations that let the circuit breaker pause a pool on-chain. +-- +-- `rotational::pause` asserts `admin.require_auth()` and that the caller is the +-- pool's stored admin, which is the creator's own wallet. The platform holds no +-- key that satisfies that, and a fee bump authorises nothing. +-- +-- A SorobanAuthorizationEntry, though, is signed independently of the +-- transaction envelope. So the admin signs one entry covering exactly +-- `pause(admin)` on exactly their pool's contract, and the backend keeps it +-- until the breaker trips, then wraps it in a transaction it pays for itself. +-- The platform never holds the admin's key and can never authorise anything +-- other than the call the admin already signed. +-- +-- Entries carry a nonce and an expiration ledger, so each one is single-use and +-- goes stale. That is why this is a table of them rather than a single column. + +CREATE TABLE IF NOT EXISTS public.pause_authorizations ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + pool_id UUID NOT NULL, + + -- Recorded so a stored entry can never be replayed against a different pool + -- or a rotated admin: both are checked before it is used. + contract_address TEXT NOT NULL, + admin_address TEXT NOT NULL, + + -- Base64 XDR of the signed SorobanAuthorizationEntry. + entry_xdr TEXT NOT NULL, + -- The ledger the signature stops being valid at, from the entry itself. + expiration_ledger INTEGER NOT NULL, + + -- Single use. Set when the entry is submitted, whatever the outcome, so a + -- failed submission never silently re-spends a consumed nonce. + used_at TIMESTAMPTZ, + used_by_incident UUID, + + -- An admin withdrawing consent without waiting for expiry. + revoked_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The hot query is "an unused, unrevoked entry for this pool". +CREATE INDEX IF NOT EXISTS idx_pause_auth_pool + ON public.pause_authorizations (pool_id, expiration_ledger DESC); +CREATE INDEX IF NOT EXISTS idx_pause_auth_unused + ON public.pause_authorizations (pool_id) WHERE used_at IS NULL AND revoked_at IS NULL; + +ALTER TABLE public.pause_authorizations ENABLE ROW LEVEL SECURITY; + +-- Deliberately NO select policy, unlike `incidents` and `security_alerts`. +-- A signed entry is a bearer credential: anyone holding it could submit the +-- pause themselves, which would be a griefing vector against the pool's own +-- members. Only the service-role API routes, which bypass RLS, may read it.