From 0c131eed6111d7bb3169263c6435b196035a5951 Mon Sep 17 00:00:00 2001 From: Diego Vega <212783706+diegoveme@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:40:41 -0600 Subject: [PATCH 1/5] feat(security): automate incident response for critical pool alerts The security scan already detected critical alerts and persisted them, but nothing acted on them. This adds the circuit breaker: enough critical alerts against one pool trips it, the pool is paused so no further money moves, and an incident is recorded for an admin to review. The decision logic is a pure module with no database, clock or network, so the cases that matter most are exercised in tests: below threshold, during cooldown, already paused, unknown pool, and dry-run. Execution against Supabase lives separately, writing the incident before the pause so a crash midway leaves a record with no action rather than a paused pool nobody can explain. Cooldown is a gate checked before the action, not a warning after it: with the defaults a pool is auto-paused at most once a day, and if it trips again it stays paused for an admin instead of flapping. Only pauses that actually happened count towards it, so a dry-run period does not consume a pool's allowance. Dry-run is the default and the intended rollout path. Both scan endpoints report whether an action would have fired regardless of dry-run state, so the flag produces real data on threshold accuracy before anyone arms it. One constraint worth stating plainly: the on-chain half of the pause cannot be automatic. `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 it, since `SPONSOR_SECRET_KEY` only pays fees. So the platform pause is immediate and automatic, and the contract call is prepared for the admin to sign, tracked as `onchain_status`. Automating it would mean adding a guardian role to a deployed funds-holding contract, which is the maintainers' call, not this layer's. No contract was changed. `emergency_withdraw` stays manual and admin-only. The breaker's action type has exactly two values, and a test asserts that set has not grown. Closes #254 --- docs/INCIDENT_RESPONSE.md | 112 ++++++ frontend/.env.example | 24 ++ .../app/api/admin/incidents/[id]/route.ts | 179 +++++++++ frontend/app/api/admin/incidents/route.ts | 83 +++++ frontend/app/api/admin/security/scan/route.ts | 15 +- frontend/app/api/cron/security-scan/route.ts | 14 + frontend/lib/incident-response.test.ts | 322 ++++++++++++++++ frontend/lib/incident-response.ts | 343 ++++++++++++++++++ frontend/lib/server/incident-actions.ts | 308 ++++++++++++++++ frontend/lib/supabase.ts | 78 ++++ frontend/package.json | 2 +- .../20260827120000_incident_response.sql | 76 ++++ 12 files changed, 1554 insertions(+), 2 deletions(-) create mode 100644 frontend/app/api/admin/incidents/[id]/route.ts create mode 100644 frontend/app/api/admin/incidents/route.ts create mode 100644 frontend/lib/incident-response.test.ts create mode 100644 frontend/lib/incident-response.ts create mode 100644 frontend/lib/server/incident-actions.ts create mode 100644 supabase/migrations/20260827120000_incident_response.sql diff --git a/docs/INCIDENT_RESPONSE.md b/docs/INCIDENT_RESPONSE.md index 62a89e1..91a3109 100644 --- a/docs/INCIDENT_RESPONSE.md +++ b/docs/INCIDENT_RESPONSE.md @@ -130,6 +130,118 @@ 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 | No | `rotational::pause` is called by the pool admin, signed with their own wallet. | + +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: `SPONSOR_SECRET_KEY` only pays network fees, and a fee bump +authorises nothing. 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. + +Making the on-chain half automatic would mean adding a platform guardian role to +a deployed, funds-holding contract, redeploying, and migrating existing pools. It +is a real option, but it is a security decision for the maintainers rather than +something this layer should assume. + +### 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/` | +| Schema | `supabase/migrations/20260827120000_incident_response.sql` | + ## Review and Post-Incident After resolving any CRITICAL or WARNING alert: diff --git a/frontend/.env.example b/frontend/.env.example index 66c90b5..57a2db5 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -73,3 +73,27 @@ 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 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/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/lib/incident-response.test.ts b/frontend/lib/incident-response.test.ts new file mode 100644 index 0000000..54db201 --- /dev/null +++ b/frontend/lib/incident-response.test.ts @@ -0,0 +1,322 @@ +// 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 { + DEFAULT_INCIDENT_CONFIG, + decideAutoPause, + decideIncidentResponse, + groupCriticalAlertsByPool, + loadIncidentConfig, + summarizeDecisions, + type IncidentAction, + type IncidentConfig, + type PoolAlertGroup, + type PoolState, +} 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}`) + } +}) diff --git a/frontend/lib/incident-response.ts b/frontend/lib/incident-response.ts new file mode 100644 index 0000000..a46aa51 --- /dev/null +++ b/frontend/lib/incident-response.ts @@ -0,0 +1,343 @@ +/** + * 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], + } +} diff --git a/frontend/lib/server/incident-actions.ts b/frontend/lib/server/incident-actions.ts new file mode 100644 index 0000000..173d6a1 --- /dev/null +++ b/frontend/lib/server/incident-actions.ts @@ -0,0 +1,308 @@ +/** + * 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 (never automatic).** `rotational::pause` asserts + * `admin.require_auth()` and that the caller equals the pool's stored admin, + * which is the creator's own wallet. The platform holds no key that satisfies + * that (`SPONSOR_SECRET_KEY` only pays fees; a fee bump authorises nothing). + * So the incident is marked `onchain_status = 'pending'` and the admin signs + * the real contract call from the review screen. + * + * Automating the on-chain half would mean adding a platform guardian role to a + * deployed, funds-holding contract. That is a security decision for the + * maintainers, not something to slip into this layer; see + * `docs/INCIDENT_RESPONSE.md`. + * + * `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 { + decideIncidentResponse, + groupCriticalAlertsByPool, + loadIncidentConfig, + summarizeDecisions, + type IncidentConfig, + type IncidentDecision, + type IncidentSummary, + type PoolState, +} from "@/lib/incident-response" + +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) +} + +/** 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 +): 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} 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[] = [] + + 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) + await notifyPoolAdmin(admin, decision.poolId, decision, config) + } + + return { ...summarizeDecisions(decisions, config), incidentIds } +} diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts index d9232e0..80b10dc 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -42,6 +42,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 @@ -66,6 +68,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 @@ -90,6 +94,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 @@ -649,6 +655,78 @@ export type Database = { } 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: [] + } } // supabase-js v2 requires these keys on the schema type; without them the // client can't match GenericSchema and every table degrades to `never`. diff --git a/frontend/package.json b/frontend/package.json index 53b3a35..e14a78e 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/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/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/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/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 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; From 4dde868d69d7154d9f62a511880ddbd69c743ab0 Mon Sep 17 00:00:00 2001 From: Diego Vega <212783706+diegoveme@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:37:56 -0600 Subject: [PATCH 2/5] docs(security): correct how the on-chain pause can be automated The previous wording said automating the contract call would require adding a guardian role to the contract. That is wrong, and worth fixing before anyone designs around it. The gap is key custody, not the contract. A SorobanAuthorizationEntry is signed independently of the transaction envelope, so an admin can pre-sign one covering pause(admin) and the backend can submit it later, paying the fee itself. @stellar/stellar-sdk already exports authorizeEntry and the wallet modules in @creit.tech/stellar-wallets-kit implement signAuthEntry, so both halves are available in this repo today. require_auth on a classic G address also honours Stellar multisig at the medium threshold, which is a second route. Still not implemented here: entries are single-use and expire, so it needs a signing flow, storage and expiry handling, and a submission path of its own. onchain_status is the hook it plugs into. --- docs/INCIDENT_RESPONSE.md | 31 +++++++++++++++++++------ frontend/lib/server/incident-actions.ts | 12 ++++++---- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/INCIDENT_RESPONSE.md b/docs/INCIDENT_RESPONSE.md index 91a3109..014f30b 100644 --- a/docs/INCIDENT_RESPONSE.md +++ b/docs/INCIDENT_RESPONSE.md @@ -152,15 +152,32 @@ The pause has two halves, and only the first can be automatic. 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: `SPONSOR_SECRET_KEY` only pays network fees, and a fee bump -authorises nothing. So an executed incident is recorded with -`onchain_status = 'pending'` and the admin signs the contract call from the +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. -Making the on-chain half automatic would mean adding a platform guardian role to -a deployed, funds-holding contract, redeploying, and migrating existing pools. It -is a real option, but it is a security decision for the maintainers rather than -something this layer should assume. +That is a key-custody gap rather than a contract limitation, and there are two +documented ways to close it with no contract change: + +1. **Pre-signed authorization entries.** A `SorobanAuthorizationEntry` is signed + independently of the transaction envelope, so the authorizer and the submitter + can be different parties. An admin pre-signs an entry covering `pause(admin)` + from their own wallet, and the backend submits it when the breaker trips. + `@stellar/stellar-sdk` exports `authorizeEntry` and the wallet modules in + `@creit.tech/stellar-wallets-kit` implement `signAuthEntry`, so both halves are + already available here. Entries carry a nonce and a `signatureExpirationLedger`, + so they are single-use and expire and have to be re-issued periodically. +2. **Account multisig.** `require_auth` for a classic `G` address uses Stellar + multisig at the medium threshold, not only the master key, so an admin who adds + a platform signer with enough weight lets the backend authorise `pause` + directly. Operationally simpler, but a much wider grant, since that weight + applies to the account in general. + +The first is the safer default, because it limits the platform to exactly the +call the admin signed. Neither is implemented yet: each needs a signing flow, +storage and expiry handling, and a submission path, which belong in their own +change. ### emergency_withdraw is never automatic diff --git a/frontend/lib/server/incident-actions.ts b/frontend/lib/server/incident-actions.ts index 173d6a1..8bc0a47 100644 --- a/frontend/lib/server/incident-actions.ts +++ b/frontend/lib/server/incident-actions.ts @@ -22,10 +22,14 @@ * So the incident is marked `onchain_status = 'pending'` and the admin signs * the real contract call from the review screen. * - * Automating the on-chain half would mean adding a platform guardian role to a - * deployed, funds-holding contract. That is a security decision for the - * maintainers, not something to slip into this layer; see - * `docs/INCIDENT_RESPONSE.md`. + * The contract call can be automated later without changing the contract: a + * `SorobanAuthorizationEntry` is signed independently of the transaction + * envelope, so an admin can pre-sign one covering `pause(admin)` and the backend + * can submit it when the breaker trips (`authorizeEntry` in + * `@stellar/stellar-sdk`, `signAuthEntry` in the wallet kit). That needs a + * signing flow and an entry lifecycle of its own, since entries are single-use + * and expire, so it is a follow-up. `onchain_status` is the hook it plugs into. + * See `docs/INCIDENT_RESPONSE.md`. * * `emergency_withdraw` is not touched here, by anything, ever. */ From 75a4e0769023fce1ac383baea810a1aa1c2cf9a9 Mon Sep 17 00:00:00 2001 From: Diego Vega <212783706+diegoveme@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:59:37 -0600 Subject: [PATCH 3/5] feat(security): pause the pool on-chain automatically, via a pre-signed authorization Completes the circuit breaker. It now carries the pause through to the contract instead of stopping at the platform level and asking an admin to finish the job. The obstacle was never the contract. `rotational::pause` asserts `admin.require_auth()` and the admin is the creator's own wallet, so the platform cannot call it on its own keys, and `SPONSOR_SECRET_KEY` cannot stand in because a fee bump authorises nothing inside a transaction. That is key custody, not a contract limitation. A SorobanAuthorizationEntry is signed independently of the transaction envelope, so the party that authorises a call and the party that submits it can differ. 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. Two signatures, two jobs: the admin authorises the call, the platform authorises the fee. No contract change, no shared key, and the credential the platform holds can do exactly one thing. The entry is validated on arrival rather than trusted. It 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 matched against the pool's contract and admin, and refused if it expires too soon to be useful. Tests build real entries with the XDR library and assert each refusal, including an entry authorising emergency_withdraw and one hiding it in a sub-invocation. Entries are single-use and expire, so selection is its own tested decision: spent, revoked, expired, expiring inside a safety margin, or signed for another contract or a rotated admin are each rejected for their own reason, and the entry expiring soonest is spent first. An authorization is marked used before submission, since its nonce may reach the network even when the response does not. Without a usable authorization nothing is lost: the platform pause still happens immediately and the incident stays at onchain_status 'pending' for the admin to sign. The stored XDR is never returned by the API and the table has no read policy outside the service role, because whoever holds it can pause the pool. --- docs/INCIDENT_RESPONSE.md | 88 ++++-- frontend/.env.example | 9 + .../api/admin/pause-authorizations/route.ts | 227 ++++++++++++++++ frontend/lib/incident-response.test.ts | 142 ++++++++++ frontend/lib/incident-response.ts | 91 +++++++ frontend/lib/pause-authorization.ts | 92 +++++++ frontend/lib/server/incident-actions.ts | 157 +++++++++-- frontend/lib/server/pause-onchain.test.ts | 128 +++++++++ frontend/lib/server/pause-onchain.ts | 254 ++++++++++++++++++ frontend/lib/supabase.ts | 32 +++ frontend/package.json | 2 +- .../20260827130000_pause_authorizations.sql | 53 ++++ 12 files changed, 1235 insertions(+), 40 deletions(-) create mode 100644 frontend/app/api/admin/pause-authorizations/route.ts create mode 100644 frontend/lib/pause-authorization.ts create mode 100644 frontend/lib/server/pause-onchain.test.ts create mode 100644 frontend/lib/server/pause-onchain.ts create mode 100644 supabase/migrations/20260827130000_pause_authorizations.sql diff --git a/docs/INCIDENT_RESPONSE.md b/docs/INCIDENT_RESPONSE.md index 014f30b..449c6d6 100644 --- a/docs/INCIDENT_RESPONSE.md +++ b/docs/INCIDENT_RESPONSE.md @@ -148,7 +148,7 @@ 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 | No | `rotational::pause` is called by the pool admin, signed with their own wallet. | +| 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 @@ -157,27 +157,67 @@ 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 there are two -documented ways to close it with no contract change: - -1. **Pre-signed authorization entries.** A `SorobanAuthorizationEntry` is signed - independently of the transaction envelope, so the authorizer and the submitter - can be different parties. An admin pre-signs an entry covering `pause(admin)` - from their own wallet, and the backend submits it when the breaker trips. - `@stellar/stellar-sdk` exports `authorizeEntry` and the wallet modules in - `@creit.tech/stellar-wallets-kit` implement `signAuthEntry`, so both halves are - already available here. Entries carry a nonce and a `signatureExpirationLedger`, - so they are single-use and expire and have to be re-issued periodically. -2. **Account multisig.** `require_auth` for a classic `G` address uses Stellar - multisig at the medium threshold, not only the master key, so an admin who adds - a platform signer with enough weight lets the backend authorise `pause` - directly. Operationally simpler, but a much wider grant, since that weight - applies to the account in general. - -The first is the safer default, because it limits the platform to exactly the -call the admin signed. Neither is implemented yet: each needs a signing flow, -storage and expiry handling, and a submission path, which belong in their own -change. +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 { admin_address, action: "revoke", id } +``` + +`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. + +### 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 @@ -257,7 +297,11 @@ contract stays paused until they sign `unpause` themselves. | 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 diff --git a/frontend/.env.example b/frontend/.env.example index 57a2db5..fd31427 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -97,3 +97,12 @@ INCIDENT_AUTO_PAUSE_ENABLED=false # 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/pause-authorizations/route.ts b/frontend/app/api/admin/pause-authorizations/route.ts new file mode 100644 index 0000000..db514d3 --- /dev/null +++ b/frontend/app/api/admin/pause-authorizations/route.ts @@ -0,0 +1,227 @@ +/** + * 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 { admin_address, action: "revoke", id } + * ``` + * + * 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. + */ + +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" + +/** + * 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 + } + 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() : "" + if (!adminAddress) { + return NextResponse.json({ error: "admin_address is required" }, { status: 400 }) + } + + 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 || pool.creator_address?.toLowerCase() !== adminAddress.toLowerCase()) { + return NextResponse.json({ error: "Forbidden" }, { 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 }) + return NextResponse.json({ revoked: true }) + } + + // ── Register ────────────────────────────────────────────────────────────── + 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/lib/incident-response.test.ts b/frontend/lib/incident-response.test.ts index 54db201..d0f9bd1 100644 --- a/frontend/lib/incident-response.test.ts +++ b/frontend/lib/incident-response.test.ts @@ -6,16 +6,19 @@ 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" @@ -320,3 +323,142 @@ test("boundary: pausing is the only action the breaker can ever return", () => { 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 index a46aa51..d1bfdd4 100644 --- a/frontend/lib/incident-response.ts +++ b/frontend/lib/incident-response.ts @@ -341,3 +341,94 @@ export function summarizeDecisions( 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..22a90e5 --- /dev/null +++ b/frontend/lib/pause-authorization.ts @@ -0,0 +1,92 @@ +/** + * 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" + +/** + * 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 } +} diff --git a/frontend/lib/server/incident-actions.ts b/frontend/lib/server/incident-actions.ts index 8bc0a47..4aab707 100644 --- a/frontend/lib/server/incident-actions.ts +++ b/frontend/lib/server/incident-actions.ts @@ -15,21 +15,19 @@ * - **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 (never automatic).** `rotational::pause` asserts - * `admin.require_auth()` and that the caller equals the pool's stored admin, - * which is the creator's own wallet. The platform holds no key that satisfies - * that (`SPONSOR_SECRET_KEY` only pays fees; a fee bump authorises nothing). - * So the incident is marked `onchain_status = 'pending'` and the admin signs - * the real contract call from the review screen. + * - **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`. * - * The contract call can be automated later without changing the contract: a - * `SorobanAuthorizationEntry` is signed independently of the transaction - * envelope, so an admin can pre-sign one covering `pause(admin)` and the backend - * can submit it when the breaker trips (`authorizeEntry` in - * `@stellar/stellar-sdk`, `signAuthEntry` in the wallet kit). That needs a - * signing flow and an entry lifecycle of its own, since entries are single-use - * and expire, so it is a follow-up. `onchain_status` is the hook it plugs into. - * See `docs/INCIDENT_RESPONSE.md`. + * 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. */ @@ -38,6 +36,7 @@ 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, @@ -46,7 +45,9 @@ import { type IncidentDecision, type IncidentSummary, type PoolState, + type StoredPauseAuthorization, } from "@/lib/incident-response" +import { currentLedger, submitOnChainPause } from "@/lib/server/pause-onchain" type AdminClient = SupabaseClient @@ -200,6 +201,92 @@ async function markIncidentExecuted(admin: AdminClient, incidentId: string): Pro 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, @@ -225,7 +312,8 @@ async function notifyPoolAdmin( admin: AdminClient, poolId: string, decision: IncidentDecision, - config: IncidentConfig + config: IncidentConfig, + onchainNote?: string ): Promise { const { data: pool, error } = await admin .from("pools") @@ -242,7 +330,10 @@ async function notifyPoolAdmin( wallet_address: pool.creator_address, pool_id: poolId, activity_type: "security_auto_pause", - message: `${prefix} "${pool.name}" ${action}. ${decision.reason} Review it in the admin panel.`, + message: + `${prefix} "${pool.name}" ${action}. ${decision.reason}` + + (onchainNote ? ` On-chain: ${onchainNote}.` : "") + + " Review it in the admin panel.", read: false, }) @@ -282,6 +373,8 @@ export async function runIncidentResponse( 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 @@ -305,7 +398,37 @@ export async function runIncidentResponse( if (incidentId) await markIncidentExecuted(admin, incidentId) await writeAuditTrail(admin, decision, incidentId) - await notifyPoolAdmin(admin, decision.poolId, decision, config) + + // 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/supabase.ts b/frontend/lib/supabase.ts index 80b10dc..3782625 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -655,6 +655,38 @@ 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 diff --git a/frontend/package.json b/frontend/package.json index e14a78e..780d017 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/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/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 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/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/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 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/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. From 5079bead024c9cf3600015b16a1d3ddced083a8b Mon Sep 17 00:00:00 2001 From: Diego Vega <212783706+diegoveme@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:07:13 -0600 Subject: [PATCH 4/5] fix(security): require a wallet signature to revoke a pause authorization Revoking took the admin address from the request body and compared it to the pool's creator_address. Both are public, so anyone who could read a pool could disarm its automatic on-chain pause. That is the worst place in this feature for a spoofable check: an attacker preparing to drain a pool could switch off the defence first, using only data the app already publishes. Registering never had this problem and still does not need a session: an entry that was not signed by the pool's real admin is refused by the inspector no matter who posts it, and the contract would reject it anyway. Revoking has no such self-validation, so it now asks for proof instead of a claim. The wallet signs a short message naming the exact authorization and the moment it was signed. The server rebuilds that message and verifies it under SEP-53 against the pool's admin as recorded, never against an address from the request, so a spoofed admin_address buys nothing. A captured proof goes stale in five minutes and does not transfer to another authorization; replaying it against the same one is a no-op, since revoking a revoked entry changes nothing. No challenge table was needed: the signed message carries its own timestamp and names its target, and the action is idempotent. The revocation is also written to pool_activity now, so disarming a pool is as auditable as arming it. --- docs/INCIDENT_RESPONSE.md | 18 ++- .../api/admin/pause-authorizations/route.ts | 44 +++++- frontend/lib/pause-authorization.ts | 29 ++++ frontend/lib/server/wallet-proof.test.ts | 135 ++++++++++++++++++ frontend/lib/server/wallet-proof.ts | 97 +++++++++++++ frontend/lib/wallet-proof.ts | 39 +++++ frontend/package.json | 2 +- 7 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 frontend/lib/server/wallet-proof.test.ts create mode 100644 frontend/lib/server/wallet-proof.ts create mode 100644 frontend/lib/wallet-proof.ts diff --git a/docs/INCIDENT_RESPONSE.md b/docs/INCIDENT_RESPONSE.md index 449c6d6..8630d4d 100644 --- a/docs/INCIDENT_RESPONSE.md +++ b/docs/INCIDENT_RESPONSE.md @@ -194,7 +194,7 @@ general rather than to one call. ``` GET /api/admin/pause-authorizations?poolId=&callerAddress=
POST /api/admin/pause-authorizations { admin_address, pool_id, entry_xdr } -POST /api/admin/pause-authorizations { admin_address, action: "revoke", id } +POST /api/admin/pause-authorizations { action: "revoke", id, signature, signed_at } ``` `lib/pause-authorization.ts` builds and signs the entry in the browser through @@ -208,6 +208,22 @@ 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 diff --git a/frontend/app/api/admin/pause-authorizations/route.ts b/frontend/app/api/admin/pause-authorizations/route.ts index db514d3..2b38f57 100644 --- a/frontend/app/api/admin/pause-authorizations/route.ts +++ b/frontend/app/api/admin/pause-authorizations/route.ts @@ -4,7 +4,7 @@ * ``` * GET /api/admin/pause-authorizations?poolId=&callerAddress=
* POST /api/admin/pause-authorizations { admin_address, pool_id, entry_xdr } - * POST /api/admin/pause-authorizations { admin_address, action: "revoke", id } + * POST /api/admin/pause-authorizations { action: "revoke", id, signature, signed_at } * ``` * * The pool admin signs a `SorobanAuthorizationEntry` covering exactly @@ -19,6 +19,13 @@ * 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" @@ -26,6 +33,8 @@ 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 @@ -99,6 +108,8 @@ export async function POST(req: NextRequest) { entry_xdr?: string action?: string id?: string + signature?: string + signed_at?: number } try { body = await req.json() @@ -107,10 +118,6 @@ export async function POST(req: NextRequest) { } const adminAddress = typeof body.admin_address === "string" ? body.admin_address.trim() : "" - if (!adminAddress) { - return NextResponse.json({ error: "admin_address is required" }, { status: 400 }) - } - const admin = getAdminClient() // ── Revoke ──────────────────────────────────────────────────────────────── @@ -133,10 +140,23 @@ export async function POST(req: NextRequest) { .select("creator_address") .eq("id", existing.pool_id) .maybeSingle() - if (!pool || pool.creator_address?.toLowerCase() !== adminAddress.toLowerCase()) { + 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() }) @@ -144,10 +164,22 @@ export async function POST(req: NextRequest) { .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) { diff --git a/frontend/lib/pause-authorization.ts b/frontend/lib/pause-authorization.ts index 22a90e5..8e10daa 100644 --- a/frontend/lib/pause-authorization.ts +++ b/frontend/lib/pause-authorization.ts @@ -23,6 +23,7 @@ import { 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 @@ -90,3 +91,31 @@ export async function signPauseAuthorization(params: { 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/wallet-proof.test.ts b/frontend/lib/server/wallet-proof.test.ts new file mode 100644 index 0000000..bb88e15 --- /dev/null +++ b/frontend/lib/server/wallet-proof.test.ts @@ -0,0 +1,135 @@ +// 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 { PROOF_MAX_AGE_MS, proofIsFresh, revokePauseAuthorizationMessage } 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/) +}) 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/wallet-proof.ts b/frontend/lib/wallet-proof.ts new file mode 100644 index 0000000..2728ccb --- /dev/null +++ b/frontend/lib/wallet-proof.ts @@ -0,0 +1,39 @@ +/** + * 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") +} + +/** 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 780d017..3f3fc2f 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/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/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 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/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/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", From ea40081190642b6347c2cecca0369875fdd80036 Mon Sep 17 00:00:00 2001 From: Diego Vega <212783706+diegoveme@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:40:52 -0600 Subject: [PATCH 5/5] fix(api): require a wallet signature to archive and unarchive a pool Both endpoints decided who the caller was from an admin_address field in the request body and compared it to the pool's creator. A pool id and a creator address are both public, so that check could be satisfied by anyone willing to type the right address, and archiving a pool takes it out of Explore and out of every member's active list. The endpoints now verify a wallet signature against the creator address as the database records it, the same proof this branch already requires before revoking a pause authorization. The body's admin_address is no longer what authorises anything. The messages name the action and the pool and carry a timestamp, so a proof gathered for one pool cannot be used on another, a proof to archive cannot be replayed to unarchive, and a captured one stops working within minutes. Five tests cover exactly those cases. The archive banner signs before it calls, so the admin flow keeps working. The daily sweep in /api/cron/archive-pools is unaffected: it writes through the admin client and never touches these routes. --- frontend/app/api/pools/[id]/archive/route.ts | 23 +++++- .../app/api/pools/[id]/unarchive/route.ts | 17 +++- .../components/group/archived-pool-banner.tsx | 35 +++++++- frontend/lib/archive-proof.ts | 53 +++++++++++++ frontend/lib/server/wallet-proof.test.ts | 79 ++++++++++++++++++- frontend/lib/wallet-proof.ts | 26 ++++++ 6 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 frontend/lib/archive-proof.ts 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/server/wallet-proof.test.ts b/frontend/lib/server/wallet-proof.test.ts index bb88e15..15f1c82 100644 --- a/frontend/lib/server/wallet-proof.test.ts +++ b/frontend/lib/server/wallet-proof.test.ts @@ -8,7 +8,13 @@ import assert from "node:assert" import { createHash } from "node:crypto" import { Keypair } from "@stellar/stellar-sdk" import { checkWalletProof, verifySignedMessage } from "./wallet-proof" -import { PROOF_MAX_AGE_MS, proofIsFresh, revokePauseAuthorizationMessage } 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 { @@ -133,3 +139,74 @@ test("check: a valid signature from the wrong account is refused", () => { 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/wallet-proof.ts b/frontend/lib/wallet-proof.ts index 2728ccb..258bff6 100644 --- a/frontend/lib/wallet-proof.ts +++ b/frontend/lib/wallet-proof.ts @@ -32,6 +32,32 @@ export function revokePauseAuthorizationMessage(authorizationId: string, signedA ].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