diff --git a/backend/musicstrk_collections/bruno/verify-wallet.bru b/backend/musicstrk_collections/bruno/verify-wallet.bru new file mode 100644 index 00000000..87cb933b --- /dev/null +++ b/backend/musicstrk_collections/bruno/verify-wallet.bru @@ -0,0 +1,27 @@ +meta { + name: verify-wallet + type: http + seq: 3 +} + +post { + url: {{DEV_SERVER}}/auth/verify-wallet + body: json + auth: none +} + +body:json { + { + "walletAddress": "0x1234567890abcdef1234567890abcdef12345678", + "message": "Sign this message to authenticate with MusicStrk", + "signature": ["0x123456789abcdef", "0xfedcba987654321"] + } +} + +tests { + test("should return 200 for valid verification", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().error).to.equal(false); + expect(res.getBody().data.verified).to.equal(true); + }); +} diff --git a/backend/src/routes/v1/auth.ts b/backend/src/routes/v1/auth.ts index 5aa273b8..c69a74b6 100644 --- a/backend/src/routes/v1/auth.ts +++ b/backend/src/routes/v1/auth.ts @@ -1,7 +1,7 @@ import { Router, Request } from "express"; import { BigNumberish, constants, ec, Provider } from "starknet"; -import UserModel, { createUser, findUserByaddress } from "models/UserModel"; +import { createUser, findUserByaddress } from "models/UserModel"; import { AUTHENTICATION_SNIP12_MESSAGE } from "constants/index"; import { createJWT } from "utilities/jwt"; import { Role } from "types"; @@ -121,7 +121,239 @@ AuthRoutes.post( msg: error.message, }); } +}) + +type ReqBody_VerifyWallet = { + walletAddress: string + message: string + signature: string[] +} + +/** + * POST /api/v1/auth/verify-wallet + * Verify wallet ownership through message signature + * Reusable for performer registration and voting authentication + */ +AuthRoutes.post("/verify-wallet", async (req: Request<{}, {}, ReqBody_VerifyWallet>, res: any) => { + const startTime = Date.now() + const { walletAddress, message, signature } = req.body + + console.log("[/verify-wallet | Request]:", { + walletAddress, + messageLength: message?.length, + signatureLength: signature?.length, + timestamp: new Date().toISOString(), + }) + + // Input validation + if (!walletAddress || !message || !signature) { + console.log("[/verify-wallet | Validation Error]: Missing required fields") + return res.status(400).json({ + error: true, + msg: "Missing required fields: walletAddress, message, and signature are required", + code: "MISSING_FIELDS", + }) + } + + // Validate wallet address format + if (!walletAddress.startsWith("0x") || walletAddress.length < 10) { + console.log("[/verify-wallet | Validation Error]: Invalid wallet address format") + return res.status(400).json({ + error: true, + msg: "Invalid wallet address format", + code: "INVALID_WALLET_FORMAT", + }) + } + + // Validate signature array + if (!Array.isArray(signature) || signature.length === 0) { + console.log("[/verify-wallet | Validation Error]: Invalid signature format") + return res.status(400).json({ + error: true, + msg: "Signature must be a non-empty array", + code: "INVALID_SIGNATURE_FORMAT", + }) + } + + try { + // Normalize wallet address + const normalizedWalletAddress = walletAddress.toLowerCase() + + console.log("[/verify-wallet | Processing]:", { + normalizedWalletAddress, + signatureElements: signature.length, + chainId: await provider.getChainId(), + }) + + // Create typed data structure for the message + const typedData = { + domain: { + name: "MusicStrk Wallet Verification", + chainId: + process.env.NODE_ENV === "production" + ? "0x534e5f4d41494e" // SN_MAIN + : "0x534e5f5345504f4c4941", // SN_SEPOLIA + version: "1.0.0", + revision: "1", + }, + message: { + content: message, + }, + primaryType: "VerificationMessage", + types: { + VerificationMessage: [ + { + name: "content", + type: "shortstring", + }, + ], + StarknetDomain: [ + { + name: "name", + type: "shortstring", + }, + { + name: "chainId", + type: "shortstring", + }, + { + name: "version", + type: "shortstring", + }, + ], + }, + } + + // Handle different signature formats (Argent vs other wallets) + let rIndex: number, sIndex: number + + if (signature.length === 2) { + // Standard signature format [r, s] + rIndex = 0 + sIndex = 1 + } else if (signature.length === 3) { + // Argent signature format [signer_type, r, s] + rIndex = 1 + sIndex = 2 + } else if (signature.length === 5) { + // Multi-sig Argent format [signer_type, signer_1, r, s, signer_2] + rIndex = 2 + sIndex = 3 + } else { + throw new Error(`Unsupported signature format: expected 2, 3, or 5 elements, got ${signature.length}`) + } + + // Create normalized signature object + const normalizedSignature = new ec.starkCurve.Signature(BigInt(signature[rIndex]), BigInt(signature[sIndex])) + + console.log("[/verify-wallet | Signature Analysis]:", { + originalLength: signature.length, + rIndex, + sIndex, + r: signature[rIndex], + s: signature[sIndex], + normalizedR: normalizedSignature.r.toString(16), + normalizedS: normalizedSignature.s.toString(16), + }) + + // Verify the signature against the typed data + const isSignatureValid = await provider.verifyMessageInStarknet( + typedData, + normalizedSignature, + normalizedWalletAddress, + ) + + const processingTime = Date.now() - startTime + + console.log("[/verify-wallet | Verification Result]:", { + isValid: isSignatureValid, + walletAddress: normalizedWalletAddress, + processingTimeMs: processingTime, + timestamp: new Date().toISOString(), + }) + + if (!isSignatureValid) { + return res.status(401).json({ + error: true, + msg: "Signature verification failed: signature does not match the provided wallet address", + code: "SIGNATURE_MISMATCH", + details: { + walletAddress: normalizedWalletAddress, + verified: false, + }, + }) + } + + // Successful verification + return res.status(200).json({ + error: false, + msg: "Wallet verification successful", + data: { + walletAddress: normalizedWalletAddress, + verified: true, + timestamp: new Date().toISOString(), + processingTimeMs: processingTime, + }, + }) + } catch (error: any) { + const processingTime = Date.now() - startTime + + console.error("[/verify-wallet | Error]:", { + error: error.message, + stack: error.stack, + walletAddress, + processingTimeMs: processingTime, + timestamp: new Date().toISOString(), + }) + + // Handle specific error types + if (error.message.toLowerCase().includes("contract not found")) { + return res.status(400).json({ + error: true, + msg: "Wallet contract not found or not deployed on this network", + code: "CONTRACT_NOT_FOUND", + details: { + walletAddress, + network: process.env.NODE_ENV === "production" ? "mainnet" : "sepolia", + }, + }) + } + + if (error.message.toLowerCase().includes("invalid signature")) { + return res.status(400).json({ + error: true, + msg: "Invalid signature format or corrupted signature data", + code: "INVALID_SIGNATURE", + details: { + signatureLength: signature?.length, + walletAddress, + }, + }) + } + + if (error.message.toLowerCase().includes("network") || error.message.toLowerCase().includes("rpc")) { + return res.status(503).json({ + error: true, + msg: "Network connectivity issue: unable to verify signature", + code: "NETWORK_ERROR", + details: { + retryable: true, + }, + }) + } + + // Generic server error + return res.status(500).json({ + error: true, + msg: "Internal server error during wallet verification", + code: "INTERNAL_ERROR", + details: { + timestamp: new Date().toISOString(), + processingTimeMs: processingTime, + }, + }) } -); +}) + +export default AuthRoutes -export default AuthRoutes; diff --git a/backend/src/types.ts b/backend/src/types.ts index cdb66f76..85ec1a97 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -70,3 +70,23 @@ export interface VotePayload { score?: number personalityScale: PersonalityScale } + +export interface WalletVerificationPayload { + walletAddress: string + message: string + signature: string[] +} + +export interface WalletVerificationResponse { + error: boolean + msg: string + code?: string + data?: { + walletAddress: string + verified: boolean + timestamp: string + processingTimeMs: number + } + details?: any +} + diff --git a/backend/tests/wallet-verification.test.js b/backend/tests/wallet-verification.test.js new file mode 100644 index 00000000..0e1043f0 --- /dev/null +++ b/backend/tests/wallet-verification.test.js @@ -0,0 +1,388 @@ +const request = require("supertest") +const express = require("express") +const { json } = require("express") +const { Account, Provider, ec, hash, typedData, constants } = require("starknet") + +// Create a real provider for testing +const provider = new Provider({ + sequencer: { + baseUrl: process.env.NODE_ENV === "production" + ? "https://alpha-mainnet.starknet.io" + : "https://alpha4.starknet.io" + } +}) + +// Test account setup - you should use a real test account +const TEST_PRIVATE_KEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" +const TEST_ACCOUNT_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678" + +describe("Wallet Verification API", () => { + let app + + beforeAll(() => { + // Setup express app with the auth routes + const AuthRoutes = require("../src/routes/v1/auth").default + app = express() + app.use(json()) + app.use("/api/v1/auth", AuthRoutes) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + /** + * Helper function to create a real signature for testing + */ + const createRealSignature = async (walletAddress, message, privateKey) => { + try { + // Create the same typed data structure as in the API + const typedDataStructure = { + domain: { + name: "MusicStrk Wallet Verification", + chainId: process.env.NODE_ENV === "production" + ? "0x534e5f4d41494e" // SN_MAIN + : "0x534e5f5345504f4c4941", // SN_SEPOLIA + version: "1.0.0", + revision: "1", + }, + message: { + content: message, + }, + primaryType: "VerificationMessage", + types: { + VerificationMessage: [ + { + name: "content", + type: "shortstring", + }, + ], + StarknetDomain: [ + { + name: "name", + type: "shortstring", + }, + { + name: "chainId", + type: "shortstring", + }, + { + name: "version", + type: "shortstring", + }, + ], + }, + } + + // Get the message hash + const messageHash = typedData.getMessageHash(typedDataStructure, walletAddress) + + // Sign the message hash + const keyPair = ec.starkCurve.getStarkKey(privateKey) + const signature = ec.starkCurve.sign(messageHash, privateKey) + + return [signature.r.toString(), signature.s.toString()] + } catch (error) { + console.error("Error creating signature:", error) + throw error + } + } + + test("should verify wallet successfully with real signature", async () => { + const message = "Sign this message to authenticate with MusicStrk" + + // Generate a real signature + const realSignature = await createRealSignature( + TEST_ACCOUNT_ADDRESS, + message, + TEST_PRIVATE_KEY + ) + + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: message, + signature: realSignature, + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(200) + expect(response.body.error).toBe(false) + expect(response.body.data.verified).toBe(true) + expect(response.body.data.walletAddress).toBe(payload.walletAddress.toLowerCase()) + expect(response.body.data).toHaveProperty("timestamp") + expect(response.body.data).toHaveProperty("processingTimeMs") + }) + + test("should reject signature from different private key", async () => { + const message = "Sign this message to authenticate with MusicStrk" + const wrongPrivateKey = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + + // Generate signature with wrong private key + const wrongSignature = await createRealSignature( + TEST_ACCOUNT_ADDRESS, + message, + wrongPrivateKey + ) + + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: message, + signature: wrongSignature, + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(401) + expect(response.body.error).toBe(true) + expect(response.body.code).toBe("SIGNATURE_MISMATCH") + expect(response.body.details.verified).toBe(false) + }) + + test("should reject signature for different message", async () => { + const originalMessage = "Sign this message to authenticate with MusicStrk" + const differentMessage = "Different message content" + + // Sign the original message but send different message in request + const signature = await createRealSignature( + TEST_ACCOUNT_ADDRESS, + originalMessage, + TEST_PRIVATE_KEY + ) + + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: differentMessage, // Different message + signature: signature, + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(401) + expect(response.body.error).toBe(true) + expect(response.body.code).toBe("SIGNATURE_MISMATCH") + }) + + test("should handle Argent wallet signature format", async () => { + const message = "Sign this message to authenticate with MusicStrk" + + // Generate base signature + const baseSignature = await createRealSignature( + TEST_ACCOUNT_ADDRESS, + message, + TEST_PRIVATE_KEY + ) + + // Format as Argent signature: [signer_type, r, s] + const argentSignature = ["0x0", baseSignature[0], baseSignature[1]] + + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: message, + signature: argentSignature, + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(200) + expect(response.body.data.verified).toBe(true) + }) + + test("should handle multi-sig Argent signature format", async () => { + const message = "Sign this message to authenticate with MusicStrk" + + // Generate base signature + const baseSignature = await createRealSignature( + TEST_ACCOUNT_ADDRESS, + message, + TEST_PRIVATE_KEY + ) + + // Format as multi-sig Argent signature: [signer_type, signer_1, r, s, signer_2] + const multiSigSignature = [ + "0x0", + "0x1111111111111111", + baseSignature[0], + baseSignature[1], + "0x2222222222222222" + ] + + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: message, + signature: multiSigSignature, + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(200) + expect(response.body.data.verified).toBe(true) + }) + + test("should reject missing required fields", async () => { + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + // Missing message and signature + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(400) + expect(response.body.error).toBe(true) + expect(response.body.code).toBe("MISSING_FIELDS") + }) + + test("should reject invalid wallet address format", async () => { + const payload = { + walletAddress: "invalid-address", + message: "Sign this message to authenticate", + signature: ["0x123", "0x456"], + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(400) + expect(response.body.error).toBe(true) + expect(response.body.code).toBe("INVALID_WALLET_FORMAT") + }) + + test("should reject invalid signature format", async () => { + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: "Sign this message to authenticate", + signature: "not-an-array", + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(400) + expect(response.body.error).toBe(true) + expect(response.body.code).toBe("INVALID_SIGNATURE_FORMAT") + }) + + test("should reject empty signature array", async () => { + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: "Sign this message to authenticate", + signature: [], + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(400) + expect(response.body.error).toBe(true) + expect(response.body.code).toBe("INVALID_SIGNATURE_FORMAT") + }) + + test("should reject unsupported signature array length", async () => { + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: "Sign this message to authenticate", + signature: ["0x123"], // Only 1 element - unsupported + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(500) + expect(response.body.error).toBe(true) + expect(response.body.code).toBe("INTERNAL_ERROR") + }) + + test("should handle malformed signature values", async () => { + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: "Sign this message to authenticate", + signature: ["invalid-hex", "also-invalid"], + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + expect(response.status).toBe(400) + expect(response.body.error).toBe(true) + expect(response.body.code).toBe("INVALID_SIGNATURE") + }) + + // Integration test with environment variables + test("should use correct chain ID based on environment", async () => { + const originalEnv = process.env.NODE_ENV + + // Test with production environment + process.env.NODE_ENV = "production" + + const message = "Sign this message to authenticate with MusicStrk" + const signature = await createRealSignature( + TEST_ACCOUNT_ADDRESS, + message, + TEST_PRIVATE_KEY + ) + + const payload = { + walletAddress: TEST_ACCOUNT_ADDRESS, + message: message, + signature: signature, + } + + const response = await request(app) + .post("/api/v1/auth/verify-wallet") + .send(payload) + .set("Accept", "application/json") + + // Restore original environment + process.env.NODE_ENV = originalEnv + + // Should work with production chain ID + expect(response.status).toBe(200) + expect(response.body.data.verified).toBe(true) + }) +}) + +// Additional helper for creating test accounts if needed +const createTestAccount = () => { + const privateKey = ec.starkCurve.utils.randomPrivateKey() + const publicKey = ec.starkCurve.getStarkKey(privateKey) + const address = hash.calculateContractAddressFromHash( + publicKey, + hash.getSelectorFromName("initialize"), + [publicKey], + 0 + ) + + return { + privateKey: `0x${privateKey.toString(16)}`, + publicKey: `0x${publicKey.toString(16)}`, + address: `0x${address.toString(16)}`, + } +} \ No newline at end of file