diff --git a/lib/airdrop/siwe-verify.test.ts b/lib/airdrop/siwe-verify.test.ts new file mode 100644 index 0000000..5cb14c6 --- /dev/null +++ b/lib/airdrop/siwe-verify.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { SiweMessage } from "siwe"; +import { privateKeyToAccount } from "viem/accounts"; + +const TEST_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const account = privateKeyToAccount(TEST_KEY); + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", ""); + vi.unstubAllEnvs(); + vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", ""); +}); + +function buildMessage(overrides: Partial = {}): SiweMessage { + return new SiweMessage({ + domain: "plotlink.xyz", + address: account.address, + statement: "PlotLink Buy-Back Sprint activation", + uri: "https://plotlink.xyz/airdrop", + version: "1", + chainId: 8453, + nonce: "abcd1234", + issuedAt: new Date().toISOString(), + ...overrides, + }); +} + +async function signMessage(msg: SiweMessage): Promise { + const prepared = msg.prepareMessage(); + return account.signMessage({ message: prepared }); +} + +describe("verifySiweRequest", () => { + it("accepts a valid signature within freshness window", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const msg = buildMessage(); + const sig = await signMessage(msg); + const result = await verifySiweRequest(msg.prepareMessage(), sig); + expect(result).toEqual({ ok: true, address: account.address.toLowerCase() }); + }); + + it("rejects an invalid signature", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const msg = buildMessage(); + const badSig = "0x" + "ab".repeat(65); + const result = await verifySiweRequest(msg.prepareMessage(), badSig); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invalid_signature"); + }); + + it("rejects wrong domain", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const msg = buildMessage({ domain: "evil.com" }); + const sig = await signMessage(msg); + const result = await verifySiweRequest(msg.prepareMessage(), sig); + expect(result).toEqual({ ok: false, error: "domain_mismatch" }); + }); + + it("rejects wrong URI", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const msg = buildMessage({ uri: "https://evil.com/airdrop" }); + const sig = await signMessage(msg); + const result = await verifySiweRequest(msg.prepareMessage(), sig); + expect(result).toEqual({ ok: false, error: "uri_mismatch" }); + }); + + it("rejects wrong chain ID", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const msg = buildMessage({ chainId: 1 }); + const sig = await signMessage(msg); + const result = await verifySiweRequest(msg.prepareMessage(), sig); + expect(result).toEqual({ ok: false, error: "chain_id_mismatch" }); + }); + + it("rejects wrong statement", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const msg = buildMessage({ statement: "Wrong statement" }); + const sig = await signMessage(msg); + const result = await verifySiweRequest(msg.prepareMessage(), sig); + expect(result).toEqual({ ok: false, error: "statement_mismatch" }); + }); + + it("rejects expired signature (older than SIGNATURE_FRESHNESS_MIN)", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const old = new Date(Date.now() - 15 * 60_000); + const msg = buildMessage({ issuedAt: old.toISOString() }); + const sig = await signMessage(msg); + const result = await verifySiweRequest(msg.prepareMessage(), sig); + expect(result).toEqual({ ok: false, error: "expired" }); + }); + + it("rejects signature issued in the future", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const future = new Date(Date.now() + 5 * 60_000); + const msg = buildMessage({ issuedAt: future.toISOString() }); + const sig = await signMessage(msg); + const result = await verifySiweRequest(msg.prepareMessage(), sig); + expect(result).toEqual({ ok: false, error: "issued_in_future" }); + }); + + it("rejects message with past expirationTime", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const past = new Date(Date.now() - 60_000); + const msg = buildMessage({ expirationTime: past.toISOString() }); + const sig = await signMessage(msg); + const result = await verifySiweRequest(msg.prepareMessage(), sig); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("expired"); + }); + + it("rejects unparseable message", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const result = await verifySiweRequest("not a siwe message", "0x1234"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invalid_message"); + }); +}); diff --git a/lib/airdrop/siwe-verify.ts b/lib/airdrop/siwe-verify.ts new file mode 100644 index 0000000..bf68496 --- /dev/null +++ b/lib/airdrop/siwe-verify.ts @@ -0,0 +1,63 @@ +import { SiweMessage } from "siwe"; +import { getAirdropConfig } from "./config"; + +export async function verifySiweRequest( + message: string, + signature: string, +): Promise<{ ok: true; address: string } | { ok: false; error: string }> { + const config = getAirdropConfig(); + + let parsed: SiweMessage; + try { + parsed = new SiweMessage(message); + } catch { + return { ok: false, error: "invalid_message" }; + } + + if (parsed.domain !== config.SIWE_DOMAIN) { + return { ok: false, error: "domain_mismatch" }; + } + if (parsed.uri !== config.SIWE_URI) { + return { ok: false, error: "uri_mismatch" }; + } + if (parsed.chainId !== config.SIWE_CHAIN_ID) { + return { ok: false, error: "chain_id_mismatch" }; + } + if (parsed.statement !== config.SIWE_STATEMENT) { + return { ok: false, error: "statement_mismatch" }; + } + + if (parsed.issuedAt) { + const issuedAt = new Date(parsed.issuedAt); + const now = new Date(); + const ageMin = (now.getTime() - issuedAt.getTime()) / 60_000; + if (ageMin > config.SIGNATURE_FRESHNESS_MIN) { + return { ok: false, error: "expired" }; + } + if (ageMin < -1) { + return { ok: false, error: "issued_in_future" }; + } + } + + try { + const result = await parsed.verify( + { signature, domain: config.SIWE_DOMAIN }, + { suppressExceptions: true }, + ); + + if (!result.success) { + const errorType = result.error?.type ?? "unknown"; + if (errorType.toLowerCase().includes("signature")) { + return { ok: false, error: "invalid_signature" }; + } + if (errorType.toLowerCase().includes("expired")) { + return { ok: false, error: "expired" }; + } + return { ok: false, error: errorType }; + } + + return { ok: true, address: result.data.address.toLowerCase() }; + } catch { + return { ok: false, error: "invalid_signature" }; + } +} diff --git a/package-lock.json b/package-lock.json index 1646679..3187aa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.29.1", + "version": "1.30.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.29.1", + "version": "1.30.2", "workspaces": [ "packages/*" ], @@ -29,6 +29,7 @@ "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "siwe": "^2.3.2", "viem": "^2.47.2", "wagmi": "^2.19.5" }, @@ -8717,6 +8718,49 @@ "superstruct": "^2.0.2" } }, + "node_modules/@spruceid/siwe-parser": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@spruceid/siwe-parser/-/siwe-parser-2.1.2.tgz", + "integrity": "sha512-d/r3S1LwJyMaRAKQ0awmo9whfXeE88Qt00vRj91q5uv5ATtWIQEGJ67Yr5eSZw5zp1/fZCXZYuEckt8lSkereQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^1.1.2", + "apg-js": "^4.3.0", + "uri-js": "^4.4.1", + "valid-url": "^1.0.9" + } + }, + "node_modules/@stablelib/binary": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", + "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", + "license": "MIT", + "dependencies": { + "@stablelib/int": "^1.0.1" + } + }, + "node_modules/@stablelib/int": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", + "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", + "license": "MIT" + }, + "node_modules/@stablelib/random": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/random/-/random-1.0.2.tgz", + "integrity": "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } + }, + "node_modules/@stablelib/wipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", + "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.104.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.104.1.tgz", @@ -10850,6 +10894,13 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT", + "peer": true + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -10932,6 +10983,12 @@ "node": ">= 8" } }, + "node_modules/apg-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", + "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", + "license": "BSD-2-Clause" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -13335,6 +13392,114 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT", + "peer": true + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD", + "peer": true + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT", + "peer": true + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/eventemitter2": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", @@ -17797,7 +17962,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -18719,6 +18883,21 @@ "dev": true, "license": "ISC" }, + "node_modules/siwe": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/siwe/-/siwe-2.3.2.tgz", + "integrity": "sha512-aSf+6+Latyttbj5nMu6GF3doMfv2UYj83hhwZgUF20ky6fTS83uVhkQABdIVnEuS8y1bBdk7p6ltb9SmlhTTlA==", + "license": "Apache-2.0", + "dependencies": { + "@spruceid/siwe-parser": "^2.1.2", + "@stablelib/random": "^1.0.1", + "uri-js": "^4.4.1", + "valid-url": "^1.0.9" + }, + "peerDependencies": { + "ethers": "^5.6.8 || ^6.0.8" + } + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -19986,7 +20165,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -20090,6 +20268,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" + }, "node_modules/valtio": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz", diff --git a/package.json b/package.json index ef61efd..782cf70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.30.1", + "version": "1.30.2", "private": true, "workspaces": [ "packages/*" @@ -33,6 +33,7 @@ "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "siwe": "^2.3.2", "viem": "^2.47.2", "wagmi": "^2.19.5" },