Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions apps/dashboard/app/api/verify/challenge/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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" } },
);
});
}
65 changes: 61 additions & 4 deletions apps/dashboard/app/api/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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<ProofRejectionReason, string> = {
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<NextResponse> {
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", [
Expand All @@ -31,6 +57,36 @@ export async function POST(request: Request): Promise<NextResponse> {
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;
Expand All @@ -47,7 +103,8 @@ export async function POST(request: Request): Promise<NextResponse> {

const result: VerificationResult = await client.verifyWallet(
discordUserId,
normalizedWallet
normalizedWallet,
{ proof: { nonce, signature } },
);

return apiResponse(result);
Expand All @@ -63,4 +120,4 @@ export async function POST(request: Request): Promise<NextResponse> {

return apiResponse(mock);
});
}
}
135 changes: 135 additions & 0 deletions apps/dashboard/lib/verification-challenge.ts
Original file line number Diff line number Diff line change
@@ -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<string, VerificationChallenge & { issuedAt: number }>();

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;
}
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
40 changes: 30 additions & 10 deletions apps/dashboard/test/live-verify-mockclient.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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;
Expand Down
Loading
Loading