From cf9b9aed1fbe4fea0d0c921a15a4c1690e822b94 Mon Sep 17 00:00:00 2001 From: vreabernardo Date: Fri, 24 Jul 2026 02:18:09 +0000 Subject: [PATCH] feat(dashboard): require signed challenge for wallet verification Live-mode /api/verify accepted any claimed address, so anyone could link a stranger's public wallet and inherit its roles. The route now requires a signature over a single-use, expiring challenge scoped to the (discordUserId, wallet) pair, issued by POST /api/verify/challenge and verified with viem before the lookup is forwarded to core. The proof (nonce + signature) rides along on the core call via a new optional IntegrationClient.verifyWallet option. The bot's /verify command hands the user the challenge message to sign instead of doing a lookup-style verify. Mock mode is unchanged. Closes #173 --- .../app/api/verify/challenge/route.ts | 59 +++++ apps/dashboard/app/api/verify/route.ts | 65 +++++- apps/dashboard/lib/verification-challenge.ts | 135 ++++++++++++ apps/dashboard/package.json | 1 + .../test/live-verify-mockclient.test.ts | 40 +++- apps/dashboard/test/live-verify.test.ts | 50 ++++- apps/dashboard/test/verify-challenge.test.ts | 205 ++++++++++++++++++ apps/discord-bot/src/bot.ts | 62 +++++- apps/discord-bot/src/config.ts | 1 + apps/discord-bot/src/index.ts | 13 ++ packages/integration-client/src/client.ts | 18 +- packages/integration-client/src/types.ts | 11 + 12 files changed, 618 insertions(+), 42 deletions(-) create mode 100644 apps/dashboard/app/api/verify/challenge/route.ts create mode 100644 apps/dashboard/lib/verification-challenge.ts create mode 100644 apps/dashboard/test/verify-challenge.test.ts diff --git a/apps/dashboard/app/api/verify/challenge/route.ts b/apps/dashboard/app/api/verify/challenge/route.ts new file mode 100644 index 0000000..a3803e5 --- /dev/null +++ b/apps/dashboard/app/api/verify/challenge/route.ts @@ -0,0 +1,59 @@ +/** + * POST /api/verify/challenge + * + * Issues a single-use, expiring verification challenge for a + * (discordUserId, wallet) pair (issue #173). The wallet signs the returned + * message; POST /api/verify then requires { nonce, signature } and only + * proceeds when the signature is valid for that exact challenge. + * + * Request body: { discordUserId: string, wallet: string } + * Response: { nonce, message, expiresAt, expiresIn } + */ +import { NextResponse } from "next/server"; +import { + apiResponse, + apiValidationError, + handleApiError, +} from "@/lib/api-helpers"; +import { isValidChecksumAddress } from "@/lib/address"; +import { + CHALLENGE_TTL_MS, + getVerificationChallengeStore, +} from "@/lib/verification-challenge"; + +// Challenges are per-request and must never be cached. +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleApiError(async () => { + const body = await request.json(); + const { discordUserId, wallet } = body; + + if (!discordUserId || !wallet) { + return apiValidationError("Missing verification fields", [ + ...(!discordUserId + ? [{ field: "discordUserId", message: "discordUserId is required" }] + : []), + ...(!wallet ? [{ field: "wallet", message: "wallet is required" }] : []), + ]); + } + + if (!isValidChecksumAddress(wallet)) { + return apiValidationError("Invalid wallet", [ + { field: "wallet", message: "wallet must be a checksummed Ethereum address" }, + ]); + } + + const challenge = getVerificationChallengeStore().issue(discordUserId, wallet); + + return apiResponse( + { + nonce: challenge.nonce, + message: challenge.message, + expiresAt: challenge.expiresAt, + expiresIn: Math.floor(CHALLENGE_TTL_MS / 1000), + }, + { headers: { "Cache-Control": "no-store" } }, + ); + }); +} diff --git a/apps/dashboard/app/api/verify/route.ts b/apps/dashboard/app/api/verify/route.ts index 19b29db..367d816 100644 --- a/apps/dashboard/app/api/verify/route.ts +++ b/apps/dashboard/app/api/verify/route.ts @@ -1,18 +1,44 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { apiResponse, +apiError, apiValidationError, handleApiError, } from "@/lib/api-helpers"; import { validateLiveModeEnv, getApiMode } from "@/lib/env"; import { IntegrationClient, type VerificationResult } from "@guildpass/integration-client"; import { isValidChecksumAddress, normaliseAddress } from "@/lib/address"; +import { getVerificationChallengeStore } from "@/lib/verification-challenge"; +import { verifyMessage } from "viem"; + +type ProofRejectionReason = + | "missing_nonce" + | "challenge_invalid" + | "signature_invalid"; + +function rejectProof( + reason: ProofRejectionReason, + discordUserId: string, + wallet: string, +): NextResponse { + // Never log the signature itself; it is bearer material until consumed. + console.warn( + JSON.stringify({ event: "verify_proof_rejected", reason, discordUserId, wallet }), + ); + const messages: Record = { + missing_nonce: + "Wallet verification requires signing a challenge. Request one from POST /api/verify/challenge and resubmit with { nonce, signature }.", + challenge_invalid: "Challenge is unknown, expired, already used, or was issued for a different discordUserId/wallet pair", + signature_invalid: "Signature does not recover to the claimed wallet for this challenge", + }; + return apiError(messages[reason], 401); +} export async function POST(request: Request): Promise { return handleApiError(async () => { const mode = getApiMode(); const body = await request.json(); - const { discordUserId, wallet } = body; + const { discordUserId, wallet, nonce, signature } = body; if (!discordUserId || !wallet) { return apiValidationError("Missing verification fields", [ @@ -31,6 +57,36 @@ export async function POST(request: Request): Promise { const normalizedWallet = normaliseAddress(wallet); if (mode === "live") { + // Proof-of-control gate: the requester must sign a single-use challenge + // scoped to this (discordUserId, wallet) pair, or anyone could claim a + // stranger's public address. + if (typeof nonce !== "string" || typeof signature !== "string") { + return rejectProof("missing_nonce", discordUserId, wallet); + } + + const message = getVerificationChallengeStore().consume( + discordUserId, + wallet, + nonce, + ); + if (!message) { + return rejectProof("challenge_invalid", discordUserId, wallet); + } + + let signatureValid: boolean; + try { + signatureValid = await verifyMessage({ + address: wallet as `0x${string}`, + message, + signature: signature as `0x${string}`, + }); + } catch { + signatureValid = false; + } + if (!signatureValid) { + return rejectProof("signature_invalid", discordUserId, wallet); + } + // Allow injecting a test client via globalThis for unit tests const testClient = (globalThis as any).__TEST_INTEGRATION_CLIENT; let client; @@ -47,7 +103,8 @@ export async function POST(request: Request): Promise { const result: VerificationResult = await client.verifyWallet( discordUserId, - normalizedWallet + normalizedWallet, + { proof: { nonce, signature } }, ); return apiResponse(result); @@ -63,4 +120,4 @@ export async function POST(request: Request): Promise { return apiResponse(mock); }); -} \ No newline at end of file +} diff --git a/apps/dashboard/lib/verification-challenge.ts b/apps/dashboard/lib/verification-challenge.ts new file mode 100644 index 0000000..e3e12fc --- /dev/null +++ b/apps/dashboard/lib/verification-challenge.ts @@ -0,0 +1,135 @@ +/** + * lib/verification-challenge.ts + * + * Challenge-response store for wallet proof-of-control (issue #173). A + * challenge is handed out by POST /api/verify/challenge and consumed exactly + * once by POST /api/verify, which requires a wallet signature over the + * challenge message before it forwards the lookup to core. + * + * Unlike the SIWE nonce store (lib/auth/nonce-store.ts), challenges are + * scoped to a specific (discordUserId, wallet) pair: a nonce issued for one + * pair is invalid for any other, which is what blocks cross-context replay. + * + * In-memory, matching the nonce-store/session-store pattern. A multi-instance + * deployment should back this with a shared store; the interface below is the + * seam for that swap. + */ + +/** Default challenge lifetime: 5 minutes (same bound as the SIWE nonce store). */ +export const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +const CHALLENGE_RANDOM_BYTES = 16; + +export interface VerificationChallenge { + nonce: string; + /** The exact EIP-191 message the wallet must sign. */ + message: string; + expiresAt: number; +} + +export interface IVerificationChallengeStore { + /** Issue a fresh challenge for a (discordUserId, wallet) pair. Replaces any prior one. */ + issue(discordUserId: string, wallet: string, now?: number): VerificationChallenge; + /** + * Consume a challenge for the pair. Returns the message to verify the + * signature against, or null when the nonce is unknown, expired, already + * used, or was issued for a different pair. Every consume attempt is + * single-use: the record is removed even on failure so a presented nonce + * can never be retried. + */ + consume(discordUserId: string, wallet: string, nonce: string, now?: number): string | null; + /** Number of currently stored challenges (for tests/introspection). */ + size(): number; +} + +/** + * Build the message the wallet signs. The pair binding lives IN the signed + * text, so a signature produced for one (discordUserId, wallet) pair cannot + * be replayed for another even if the nonce somehow collided. + */ +export function buildChallengeMessage( + discordUserId: string, + wallet: string, + nonce: string, + expiresAt: number, +): string { + return [ + "GuildPass wallet verification", + "", + "Sign this message to prove you control this wallet and link it to your Discord account.", + "", + `Discord user: ${discordUserId}`, + `Wallet: ${wallet}`, + `Nonce: ${nonce}`, + `Expires: ${new Date(expiresAt).toISOString()}`, + ].join("\n"); +} + +function generateNonce(): string { + const bytes = new Uint8Array(CHALLENGE_RANDOM_BYTES); + crypto.getRandomValues(bytes); + const encoded = Array.from(bytes) + .map((b) => b.toString(36).padStart(2, "0")) + .join(""); + return encoded.slice(0, 16); +} + +function scopeKey(discordUserId: string, wallet: string): string { + return `${discordUserId}:${wallet.toLowerCase()}`; +} + +export function createVerificationChallengeStore(): IVerificationChallengeStore { + const records = new Map(); + + function prune(now: number): void { + for (const [key, rec] of records) { + if (rec.expiresAt <= now) records.delete(key); + } + } + + return { + issue(discordUserId: string, wallet: string, now: number = Date.now()): VerificationChallenge { + prune(now); + const key = scopeKey(discordUserId, wallet); + let nonce = generateNonce(); + while ([...records.values()].some((r) => r.nonce === nonce)) nonce = generateNonce(); + const expiresAt = now + CHALLENGE_TTL_MS; + const challenge: VerificationChallenge = { + nonce, + message: buildChallengeMessage(discordUserId, wallet, nonce, expiresAt), + expiresAt, + }; + records.set(key, { ...challenge, issuedAt: now }); + return challenge; + }, + + consume(discordUserId: string, wallet: string, nonce: string, now: number = Date.now()): string | null { + const key = scopeKey(discordUserId, wallet); + const rec = records.get(key); + if (!rec || rec.nonce !== nonce) return null; // unknown, or issued for a different pair + records.delete(key); // single-use, consumed on first presentation + if (rec.expiresAt <= now) return null; + return rec.message; + }, + + size(): number { + return records.size; + }, + }; +} + +let _store: IVerificationChallengeStore | null = null; + +export function getVerificationChallengeStore(): IVerificationChallengeStore { + // Test hook: an injected store takes precedence over the singleton, + // mirroring the __TEST_INTEGRATION_CLIENT pattern in the verify route. + const injected = (globalThis as any).__TEST_VERIFICATION_CHALLENGE_STORE; + if (injected) return injected as IVerificationChallengeStore; + if (!_store) _store = createVerificationChallengeStore(); + return _store; +} + +/** Reset the singleton (tests only). */ +export function resetVerificationChallengeStore(): void { + _store = null; +} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 6271503..9d384d7 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -26,6 +26,7 @@ "next": "14.2.21", "react": "18.3.1", "react-dom": "18.3.1", + "viem": "^2.13.0", "@guildpass/integration-client": "workspace:*", "@guildpass/webhook-utils": "workspace:*", "@guildpass/metrics": "workspace:*", diff --git a/apps/dashboard/test/live-verify-mockclient.test.ts b/apps/dashboard/test/live-verify-mockclient.test.ts index 9744966..09b61b4 100644 --- a/apps/dashboard/test/live-verify-mockclient.test.ts +++ b/apps/dashboard/test/live-verify-mockclient.test.ts @@ -1,5 +1,10 @@ import { test } from "node:test"; import assert from "node:assert"; +import { privateKeyToAccount } from "viem/accounts"; + +// Hardhat/Anvil dev account #0 (public test key) +const TEST_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const TEST_WALLET = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; test("POST /api/verify uses injected IntegrationClient in live mode via mock client", async () => { const previousMode = process.env.DASHBOARD_API_MODE; @@ -12,6 +17,9 @@ test("POST /api/verify uses injected IntegrationClient in live mode via mock cli process.env.GUILD_PASS_CORE_URL = "http://127.0.0.1:1"; try { + const { resetVerificationChallengeStore } = await import("../lib/verification-challenge.js"); + resetVerificationChallengeStore(); + (globalThis as any).__TEST_INTEGRATION_CLIENT = { verifyWallet: async (discordUserId: string, wallet: string) => ({ userId: discordUserId, @@ -21,22 +29,34 @@ test("POST /api/verify uses injected IntegrationClient in live mode via mock cli }), }; - const { POST } = await import("../app/api/verify/route.js"); + const { POST: challengePOST } = await import("../app/api/verify/challenge/route.js"); + const challengeRes = await challengePOST( + new Request("http://localhost/api/verify/challenge", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ discordUserId: "u_inj", wallet: TEST_WALLET }), + }) as any, + ); + const challengeBody = await challengeRes.json(); + const { nonce, message } = challengeBody.data; - const payload = { discordUserId: "u_inj", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }; - const req = new Request("http://localhost/api/verify", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload), - }); + const account = privateKeyToAccount(TEST_KEY); + const signature = await account.signMessage({ message }); - const res = await POST(req as any); + const { POST } = await import("../app/api/verify/route.js"); + const res = await POST( + new Request("http://localhost/api/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ discordUserId: "u_inj", wallet: TEST_WALLET, nonce, signature }), + }) as any, + ); const body = await res.json(); assert.strictEqual(body.ok, true); const data = body.data; - assert.strictEqual(data.userId, payload.discordUserId); - assert.strictEqual(data.wallet, payload.wallet); + assert.strictEqual(data.userId, "u_inj"); + assert.strictEqual(data.wallet, TEST_WALLET); assert.strictEqual(data.verified, true); } finally { delete (globalThis as any).__TEST_INTEGRATION_CLIENT; diff --git a/apps/dashboard/test/live-verify.test.ts b/apps/dashboard/test/live-verify.test.ts index 5c9730b..02040d5 100644 --- a/apps/dashboard/test/live-verify.test.ts +++ b/apps/dashboard/test/live-verify.test.ts @@ -1,6 +1,11 @@ import { test } from "node:test"; import assert from "node:assert"; import http from "node:http"; +import { privateKeyToAccount } from "viem/accounts"; + +// Hardhat/Anvil dev account #0 (public test key) +const TEST_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const TEST_WALLET = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; test("POST /api/verify forwards to core API in live mode", async () => { const previousMode = process.env.DASHBOARD_API_MODE; @@ -11,7 +16,7 @@ test("POST /api/verify forwards to core API in live mode", async () => { process.env.GUILD_PASS_CORE_API_KEY = "test-core-api-key"; process.env.WEBHOOK_SECRET = "test-webhook-secret"; - // Note: this test requires a mock HTTP server; see live-verify-mockclient for injected version + let coreBody: any = null; const server = http.createServer((req, res) => { if (!req.url) return res.end(); const url = new URL(req.url, `http://localhost`); @@ -21,6 +26,7 @@ test("POST /api/verify forwards to core API in live mode", async () => { req.on("data", (chunk) => (body += chunk)); req.on("end", () => { const parsed = JSON.parse(body || "{}"); + coreBody = parsed; const result = { userId: parsed.discordUserId, wallet: parsed.wallet, @@ -45,23 +51,45 @@ test("POST /api/verify forwards to core API in live mode", async () => { process.env.GUILD_PASS_CORE_URL = `http://127.0.0.1:${port}`; try { - const { POST } = await import("../app/api/verify/route.js"); + const { resetVerificationChallengeStore } = await import("../lib/verification-challenge.js"); + resetVerificationChallengeStore(); + + // 1. Request a challenge for the pair + const { POST: challengePOST } = await import("../app/api/verify/challenge/route.js"); + const challengeRes = await challengePOST( + new Request("http://localhost/api/verify/challenge", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ discordUserId: "u_live", wallet: TEST_WALLET }), + }) as any, + ); + const challengeBody = await challengeRes.json(); + assert.strictEqual(challengeBody.ok, true); + const { nonce, message } = challengeBody.data; - const payload = { discordUserId: "u_live", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }; - const req = new Request("http://localhost/api/verify", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload), - }); + // 2. Sign the challenge with the claimed wallet + const account = privateKeyToAccount(TEST_KEY); + const signature = await account.signMessage({ message }); - const res = await POST(req as any); + // 3. Submit proof + const { POST } = await import("../app/api/verify/route.js"); + const res = await POST( + new Request("http://localhost/api/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ discordUserId: "u_live", wallet: TEST_WALLET, nonce, signature }), + }) as any, + ); const body = await res.json(); assert.strictEqual(body.ok, true); const data = body.data; - assert.strictEqual(data.userId, payload.discordUserId); - assert.strictEqual(data.wallet, payload.wallet); + assert.strictEqual(data.userId, "u_live"); + assert.strictEqual(data.wallet, TEST_WALLET); assert.strictEqual(data.verified, true); + + // core received the proof of control alongside the lookup + assert.deepStrictEqual(coreBody.proof, { nonce, signature }); } finally { if (server.listening) { server.close(); diff --git a/apps/dashboard/test/verify-challenge.test.ts b/apps/dashboard/test/verify-challenge.test.ts new file mode 100644 index 0000000..6fb5001 --- /dev/null +++ b/apps/dashboard/test/verify-challenge.test.ts @@ -0,0 +1,205 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { privateKeyToAccount } from "viem/accounts"; +import { + CHALLENGE_TTL_MS, + createVerificationChallengeStore, + resetVerificationChallengeStore, +} from "../lib/verification-challenge.js"; + +// Hardhat/Anvil dev accounts #0 and #1 (public, used across the ecosystem for tests) +const ALICE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const BOB_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +const ALICE = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +const savedEnv: Record = {}; + +function setLiveEnv() { + for (const k of ["DASHBOARD_API_MODE", "GUILD_PASS_CORE_URL", "GUILD_PASS_CORE_API_KEY", "WEBHOOK_SECRET"]) { + savedEnv[k] = process.env[k]; + } + process.env.DASHBOARD_API_MODE = "live"; + process.env.GUILD_PASS_CORE_URL = "http://127.0.0.1:1"; + process.env.GUILD_PASS_CORE_API_KEY = "test-core-api-key"; + process.env.WEBHOOK_SECRET = "test-webhook-secret"; +} + +function restoreEnv() { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +} + +function jsonRequest(url: string, payload: unknown): Request { + return new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +async function issueChallenge(discordUserId: string, wallet: string) { + const { POST: challengePOST } = await import("../app/api/verify/challenge/route.js"); + const res = await challengePOST(jsonRequest("http://localhost/api/verify/challenge", { discordUserId, wallet }) as any); + const body = await res.json(); + return { res, body }; +} + +async function signChallenge(privateKey: `0x${string}`, message: string) { + const account = privateKeyToAccount(privateKey); + return account.signMessage({ message }); +} + +beforeEach(() => { + resetVerificationChallengeStore(); + delete (globalThis as any).__TEST_VERIFICATION_CHALLENGE_STORE; + setLiveEnv(); +}); + +afterEach(() => { + delete (globalThis as any).__TEST_INTEGRATION_CLIENT; + delete (globalThis as any).__TEST_VERIFICATION_CHALLENGE_STORE; + resetVerificationChallengeStore(); + restoreEnv(); +}); + +test("POST /api/verify/challenge issues a challenge scoped to the pair", async () => { + const { res, body } = await issueChallenge("user_1", ALICE); + assert.strictEqual(res.status, 200); + assert.strictEqual(body.ok, true); + assert.ok(typeof body.data.nonce === "string" && body.data.nonce.length >= 8); + assert.ok(body.data.message.includes("Discord user: user_1")); + assert.ok(body.data.message.includes(`Wallet: ${ALICE}`)); + assert.ok(body.data.message.includes(`Nonce: ${body.data.nonce}`)); + assert.strictEqual(body.data.expiresIn, Math.floor(CHALLENGE_TTL_MS / 1000)); +}); + +test("POST /api/verify/challenge rejects missing fields and bad checksum", async () => { + const missing = await issueChallenge("", ""); + assert.strictEqual(missing.res.status, 400); + + const badWallet = await issueChallenge("user_1", ALICE.toLowerCase()); + assert.strictEqual(badWallet.res.status, 400); +}); + +test("POST /api/verify without a signature is rejected in live mode", async () => { + const { POST } = await import("../app/api/verify/route.js"); + const res = await POST(jsonRequest("http://localhost/api/verify", { discordUserId: "user_1", wallet: ALICE }) as any); + assert.strictEqual(res.status, 401); + const body = await res.json(); + assert.strictEqual(body.ok, false); +}); + +test("valid signature over the issued challenge verifies", async () => { + let calledWith: { discordUserId?: string; wallet?: string; options?: any } = {}; + (globalThis as any).__TEST_INTEGRATION_CLIENT = { + verifyWallet: async (discordUserId: string, wallet: string, options?: any) => { + calledWith = { discordUserId, wallet, options }; + return { userId: discordUserId, wallet, verified: true, message: "ok" }; + }, + }; + + const { body: challengeBody } = await issueChallenge("user_1", ALICE); + const { nonce, message } = challengeBody.data; + const signature = await signChallenge(ALICE_KEY, message); + + const { POST } = await import("../app/api/verify/route.js"); + const res = await POST( + jsonRequest("http://localhost/api/verify", { discordUserId: "user_1", wallet: ALICE, nonce, signature }) as any, + ); + const body = await res.json(); + assert.strictEqual(res.status, 200); + assert.strictEqual(body.ok, true); + assert.strictEqual(body.data.verified, true); + assert.strictEqual(calledWith.discordUserId, "user_1"); + // proof is forwarded to core with the verification request + assert.deepStrictEqual(calledWith.options?.proof, { nonce, signature }); +}); + +test("signature from a different wallet is rejected", async () => { + (globalThis as any).__TEST_INTEGRATION_CLIENT = { + verifyWallet: async () => { + throw new Error("must not be called"); + }, + }; + + const { body: challengeBody } = await issueChallenge("user_1", ALICE); + const { nonce, message } = challengeBody.data; + const signature = await signChallenge(BOB_KEY, message); // wrong signer + + const { POST } = await import("../app/api/verify/route.js"); + const res = await POST( + jsonRequest("http://localhost/api/verify", { discordUserId: "user_1", wallet: ALICE, nonce, signature }) as any, + ); + assert.strictEqual(res.status, 401); +}); + +test("a consumed nonce cannot be replayed", async () => { + (globalThis as any).__TEST_INTEGRATION_CLIENT = { + verifyWallet: async (discordUserId: string, wallet: string) => ({ + userId: discordUserId, wallet, verified: true, + }), + }; + + const { body: challengeBody } = await issueChallenge("user_1", ALICE); + const { nonce, message } = challengeBody.data; + const signature = await signChallenge(ALICE_KEY, message); + + const { POST } = await import("../app/api/verify/route.js"); + const first = await POST( + jsonRequest("http://localhost/api/verify", { discordUserId: "user_1", wallet: ALICE, nonce, signature }) as any, + ); + assert.strictEqual(first.status, 200); + + const second = await POST( + jsonRequest("http://localhost/api/verify", { discordUserId: "user_1", wallet: ALICE, nonce, signature }) as any, + ); + assert.strictEqual(second.status, 401); +}); + +test("a challenge issued for a different discord user is rejected (cross-context)", async () => { + (globalThis as any).__TEST_INTEGRATION_CLIENT = { + verifyWallet: async () => { + throw new Error("must not be called"); + }, + }; + + const { body: challengeBody } = await issueChallenge("user_1", ALICE); + const { nonce, message } = challengeBody.data; + const signature = await signChallenge(ALICE_KEY, message); + + const { POST } = await import("../app/api/verify/route.js"); + // Same wallet, same nonce, valid signature — but a different Discord user. + const res = await POST( + jsonRequest("http://localhost/api/verify", { discordUserId: "user_2", wallet: ALICE, nonce, signature }) as any, + ); + assert.strictEqual(res.status, 401); +}); + +test("challenge store enforces single-use, scope, and expiry", () => { + const store = createVerificationChallengeStore(); + const t0 = 1_000_000; + + const c = store.issue("u1", ALICE, t0); + assert.strictEqual(store.size(), 1); + + // wrong pair can't consume it + assert.strictEqual(store.consume("u2", ALICE, c.nonce, t0), null); + assert.strictEqual(store.size(), 1, "failed cross-pair consume must not burn the challenge"); + + // correct pair consumes it once + assert.ok(store.consume("u1", ALICE, c.nonce, t0 + 1)); + assert.strictEqual(store.consume("u1", ALICE, c.nonce, t0 + 2), null, "replay must fail"); + + // expired challenge is rejected + const c2 = store.issue("u1", ALICE, t0); + assert.strictEqual(store.consume("u1", ALICE, c2.nonce, t0 + CHALLENGE_TTL_MS + 1), null); + + // re-issuing for the same pair replaces the old challenge + const c3 = store.issue("u1", ALICE, t0); + const c4 = store.issue("u1", ALICE, t0); + assert.notStrictEqual(c3.nonce, c4.nonce); + assert.strictEqual(store.consume("u1", ALICE, c3.nonce, t0), null, "superseded nonce must fail"); + assert.ok(store.consume("u1", ALICE, c4.nonce, t0)); +}); diff --git a/apps/discord-bot/src/bot.ts b/apps/discord-bot/src/bot.ts index 292abde..c34785f 100644 --- a/apps/discord-bot/src/bot.ts +++ b/apps/discord-bot/src/bot.ts @@ -18,12 +18,24 @@ export interface GuildStats { // A minimal interface capturing the IntegrationClient methods used by the bot. // This decouples the bot from the concrete class, making testing and DI simple. +export interface VerificationChallenge { + nonce: string; + message: string; + expiresAt: number; + expiresIn: number; +} + export interface BotIntegrationClient { verifyWallet( discordUserId: string, wallet: string, options?: { signal?: AbortSignal }, ): Promise; + issueVerificationChallenge( + discordUserId: string, + wallet: string, + options?: { signal?: AbortSignal }, + ): Promise; getMembershipByDiscordUser( discordUserId: string, options?: { signal?: AbortSignal }, @@ -124,20 +136,24 @@ export function createClient(options: BotOptions = {}): Client { const wallet = interaction.options.getString("wallet", true); await interaction.deferReply({ ephemeral: true }); + // Proof-of-control flow (issue #173): pasting an address here never + // proved ownership — addresses are public — so verification now + // requires signing a challenge with the wallet. A Discord slash + // command can't produce that signature, so we hand the user the + // message to sign instead of doing a lookup-style verify. try { - const result = await integration.verifyWallet( + const result = await integration.issueVerificationChallenge( interaction.user.id, wallet, ); - if (result.verified) { - await interaction.editReply( - `✅ Wallet verified: \`${wallet}\``, - ); - } else { - await interaction.editReply( - `❌ Verification failed: ${result.message ?? "unknown reason"}`, - ); - } + const signHere = config.dashboardUrl + ? `Sign it with your wallet at ${config.dashboardUrl} (the verification form requests the challenge for you).` + : "Sign it with your wallet and submit the signature through the GuildPass dashboard verification form."; + await interaction.editReply( + `To prove you control \`${wallet}\`, sign this exact message with that wallet:\n` + + `\`\`\`\n${result.message}\n\`\`\`\n` + + `${signHere} The challenge expires in ${Math.floor(result.expiresIn / 60)} minutes and works once.`, + ); } catch (err) { console.error("[bot] /verify error:", err); await interaction.editReply( @@ -302,6 +318,32 @@ function createStubIntegrationClient(): BotIntegrationClient { message: "Stub integration — no GuildPass API connected.", }; }, + async issueVerificationChallenge( + discordUserId: string, + wallet: string, + options?: { signal?: AbortSignal }, + ): Promise { + // Challenges are issued by the dashboard, not core. With no integration + // client wired in, call the dashboard endpoint directly when configured. + if (!config.dashboardUrl) { + throw new Error("GUILD_PASS_DASHBOARD_URL is not configured"); + } + const res = await fetch(`${config.dashboardUrl}/api/verify/challenge`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ discordUserId, wallet }), + signal: options?.signal ?? null, + }); + const body = (await res.json()) as + | { ok: true; data: VerificationChallenge } + | { ok: false; error?: string }; + if (!res.ok || !body.ok) { + throw new Error( + `dashboard challenge failed: ${res.ok && !body.ok ? body.error : res.status}`, + ); + } + return body.data; + }, async getMembershipByDiscordUser( _discordUserId: string, _options?: { signal?: AbortSignal }, diff --git a/apps/discord-bot/src/config.ts b/apps/discord-bot/src/config.ts index bb7e553..3414ffa 100644 --- a/apps/discord-bot/src/config.ts +++ b/apps/discord-bot/src/config.ts @@ -7,6 +7,7 @@ export const config = { guildId: process.env.DISCORD_GUILD_ID ?? "", coreBaseUrl: process.env.GUILD_PASS_CORE_URL ?? "", coreApiKey: process.env.GUILD_PASS_CORE_API_KEY ?? "", + dashboardUrl: process.env.GUILD_PASS_DASHBOARD_URL ?? "", roles: { admin: process.env.DISCORD_ROLE_ADMIN ?? "", member: process.env.DISCORD_ROLE_MEMBER ?? "", diff --git a/apps/discord-bot/src/index.ts b/apps/discord-bot/src/index.ts index b9e41a9..7b7c8ba 100644 --- a/apps/discord-bot/src/index.ts +++ b/apps/discord-bot/src/index.ts @@ -64,6 +64,19 @@ if (isMockMode) { }; } + async issueVerificationChallenge( + discordUserId: string, + wallet: string, + ) { + console.log("[mock] issueVerificationChallenge called", discordUserId, wallet); + return { + nonce: "mocknonce1234567", + message: `GuildPass wallet verification\n\nDiscord user: ${discordUserId}\nWallet: ${wallet}\nNonce: mocknonce1234567`, + expiresAt: Date.now() + 5 * 60 * 1000, + expiresIn: 300, + }; + } + async getMembershipByDiscordUser( discordUserId: string, ): Promise { diff --git a/packages/integration-client/src/client.ts b/packages/integration-client/src/client.ts index e93295c..03e68b3 100644 --- a/packages/integration-client/src/client.ts +++ b/packages/integration-client/src/client.ts @@ -1,4 +1,4 @@ -import type { IntegrationClientOptions, Membership, VerificationResult } from "./types.js"; // IC: 71 +import type { IntegrationClientOptions, Membership, VerificationProof, VerificationResult } from "./types.js"; // IC: 71 import { HttpClient } from "./http/httpClient.js"; import { ContractClient } from "./contracts/contractClient.js"; import type { HttpRequestOptions } from "./http/http.types.js"; @@ -107,21 +107,25 @@ export class IntegrationClient { /** * Verify that a Discord user controls a given wallet and return the result. * - * POSTs `{ discordUserId, wallet }` to `/v1/verify`. + * POSTs `{ discordUserId, wallet }` to `/v1/verify`. When `options.proof` + * is given, the `{ nonce, signature }` evidence is included in the body so + * core can record that control was proven, not just claimed (issue #173). * * @param discordUserId - The Discord user id claiming ownership of the wallet. * @param wallet - The wallet address to verify against the user. - * @param options - Per-request {@link HttpRequestOptions} (timeout/retry/headers). + * @param options - Per-request {@link HttpRequestOptions} (timeout/retry/headers), + * plus optional `proof` ({@link VerificationProof}). * @returns The {@link VerificationResult} (`{ userId, wallet, verified, message? }`). * Throws `Error("core:")` on any non-OK response. */ - async verifyWallet(discordUserId: string, wallet: string, options: HttpRequestOptions = {}): Promise { + async verifyWallet(discordUserId: string, wallet: string, options: HttpRequestOptions & { proof?: VerificationProof } = {}): Promise { const url = `${this.baseUrl}/v1/verify`; // IC: 91 + const { proof, ...requestOptions } = options; const res = await this.httpClient.request(url, { - ...options, + ...requestOptions, method: "POST", - headers: { ...headers(this.apiKey), ...options.headers }, - body: JSON.stringify({ discordUserId, wallet }) + headers: { ...headers(this.apiKey), ...requestOptions.headers }, + body: JSON.stringify({ discordUserId, wallet, ...(proof ? { proof } : {}) }) }); // IC: 92 if (!res.ok) throw new Error(`core:${res.status}`); // IC: 93 const data = await res.json(); // IC: 94 diff --git a/packages/integration-client/src/types.ts b/packages/integration-client/src/types.ts index 782ae26..39b3a29 100644 --- a/packages/integration-client/src/types.ts +++ b/packages/integration-client/src/types.ts @@ -21,6 +21,17 @@ export type VerificationResult = { message?: string; // IC: 112 }; // IC: 113 +/** + * Proof-of-control evidence accompanying a wallet verification request + * (issue #173): the single-use challenge nonce issued by the verifying + * service and the wallet's signature over that challenge. Forwarded to core + * so the verification record carries the evidence it was authorised with. + */ +export type VerificationProof = { + nonce: string; + signature: string; +}; + /** * Structured audit activity event model */