From bf04ba1210a88cb205f9b5e0fdc48d05c0f0b452 Mon Sep 17 00:00:00 2001 From: Okoli Johnpaul Sochimaobi <132228270+Johnpii1@users.noreply.github.com> Date: Fri, 29 May 2026 12:31:48 +0100 Subject: [PATCH] Implement one-time nonce web3 auth --- backend/package.json | 2 +- backend/src/routes/auth.ts | 442 +++++++++++++++++++++++++++++-------- backend/tests/auth.test.ts | 133 +++++++++++ 3 files changed, 479 insertions(+), 98 deletions(-) create mode 100644 backend/tests/auth.test.ts diff --git a/backend/package.json b/backend/package.json index 9e3c1746..7e0c42eb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --require ts-node/register --test tests/**/*.test.ts" }, "keywords": [], "author": "", diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 10186baa..0c2cf0a0 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,134 +1,382 @@ import { Router, Request, Response } from "express"; import crypto from "crypto"; +import Redis from "ioredis"; +import { z } from "zod"; import { prisma } from "../config/db"; -import { Keypair } from "@stellar/stellar-sdk"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; -const router = Router(); +const SIGN_MESSAGE_PREFIX = "Stellar Signed Message:\n"; +const CHALLENGE_TTL_MS = 5 * 60 * 1000; +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_SIGNATURE_BYTES = 128; +const SESSION_COOKIE_NAME = "lance_session"; +const SESSION_TOKEN_BYTES = 32; -// Define input types -interface ChallengeRequest { +export const sessionCookieOptions = Object.freeze({ + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict" as const, + maxAge: SESSION_TTL_MS, + path: "/", +}); + +type AuthChallengeRecord = { address: string; + challenge: string; + expires_at: Date; +}; + +type ChallengeStore = { + upsert(args: { + where: { address: string }; + update: { challenge: string; expires_at: Date }; + create: { address: string; challenge: string; expires_at: Date }; + }): Promise; + findUnique(args: { where: { address: string } }): Promise; + deleteMany(args: { where: { address: string; challenge: string; expires_at: { gt: Date } } }): Promise<{ count: number }>; +}; + +type SessionStore = { + create(args: { data: { token: string; address: string; expires_at: Date } }): Promise; + findUnique(args: { where: { token: string } }): Promise<{ token: string; address: string; expires_at: Date } | null>; + deleteMany(args: { where: { token: string } }): Promise<{ count: number }>; +}; + +export interface AuthPrismaClient { + auth_challenges: ChallengeStore; + sessions: SessionStore; } -interface VerifyRequest { - address: string; - signature: string; +export type RedisLike = Pick; + +export interface AuthRouteState { + prismaClient?: AuthPrismaClient; + redisClient?: RedisLike | null; } -// Scaffold the auth challenge route -router.post("/challenge", async (req: Request<{}, {}, ChallengeRequest>, res: Response) => { - try { - const { address } = req.body; +const ChallengeRequestSchema = z.object({ + address: z.string().min(1).max(128), +}).strict(); + +const VerifyRequestSchema = z.object({ + address: z.string().min(1).max(128), + signature: z.union([ + z.string().min(1).max(512), + z.object({ signature: z.string().min(1).max(512) }).strict(), + ]), +}).strict(); + +const SessionRequestSchema = z.object({ + token: z.string().min(43).max(128).optional(), +}).strict(); + +let redisSingleton: Redis | null | undefined; + +function getDefaultRedisClient(): Redis | null { + if (redisSingleton !== undefined) { + return redisSingleton; + } - if (!address) { - return res.status(400).json({ error: "Address is required" }); + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) { + redisSingleton = null; + return redisSingleton; + } + + redisSingleton = new Redis(redisUrl, { + lazyConnect: true, + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + commandTimeout: 1, + }); + + redisSingleton.on("error", (error) => { + console.warn("Redis session blacklist unavailable:", error.message); + }); + + return redisSingleton; +} + +export function sanitizeStellarAddress(rawAddress: unknown): string | null { + if (typeof rawAddress !== "string") { + return null; + } + + const address = rawAddress.trim(); + if (address !== rawAddress || !/^[A-Z2-7]{56}$/.test(address)) { + return null; + } + + try { + // StrKey decoding validates the version byte, payload length, and CRC16-XModem + // checksum instead of relying on address shape alone. + const decoded = StrKey.decodeEd25519PublicKey(address); + if (decoded.length !== 32 || !StrKey.isValidEd25519PublicKey(address)) { + return null; } + // Re-encode the decoded payload to prevent address poisoning through any + // non-canonical representation that a future decoder may accept. + return StrKey.encodeEd25519PublicKey(decoded) === address ? address : null; + } catch { + return null; + } +} + +export function extractSignatureBytes(signature: unknown): Buffer | null { + const sigString = typeof signature === "object" && signature !== null && "signature" in signature + ? (signature as { signature?: unknown }).signature + : signature; - // Generate challenge matching the old Rust backend format - const nonce = crypto.randomUUID(); - const challenge = `Lance wants you to sign in with your Stellar account:\n${address}\n\nNonce: ${nonce}`; + if (typeof sigString !== "string") { + return null; + } - // Expiration time: 5 minutes from now - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + const value = sigString.trim(); + if (value !== sigString || value.length === 0 || value.length > 512) { + return null; + } - // Save or update the challenge in the database - await prisma.auth_challenges.upsert({ - where: { address }, - update: { challenge, expires_at: expiresAt }, - create: { address, challenge, expires_at: expiresAt }, - }); + const isHex = /^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0; + const isBase64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value); - res.json({ challenge }); - } catch (error) { - console.error("Auth challenge error:", error); - res.status(500).json({ error: "Internal server error" }); + if (!isHex && !isBase64) { + return null; + } + + const decoded = Buffer.from(value, isHex ? "hex" : "base64"); + if (decoded.length === 0 || decoded.length > MAX_SIGNATURE_BYTES) { + return null; + } + + return decoded; +} + +export function buildChallenge(address: string, nonce: string = crypto.randomUUID()): string { + return [ + "Lance wants you to sign in with your Stellar account:", + address, + "", + `Nonce: ${nonce}`, + ].join("\n"); +} + +export function isChallengeFresh(record: Pick, now = new Date()): boolean { + return record.expires_at.getTime() > now.getTime(); +} + +export function verifyStellarSignature(address: string, challenge: string, signature: unknown): boolean { + const canonicalAddress = sanitizeStellarAddress(address); + const signatureBuffer = extractSignatureBytes(signature); + if (!canonicalAddress || !signatureBuffer) { + return false; } -}); -// Verify route -router.post("/verify", async (req: Request<{}, {}, VerifyRequest>, res: Response) => { try { - const { address, signature } = req.body; + const keypair = Keypair.fromPublicKey(canonicalAddress); + const sep53Payload = Buffer.from(SIGN_MESSAGE_PREFIX + challenge, "utf8"); + const digest = crypto.createHash("sha256").update(sep53Payload).digest(); + return keypair.verify(digest, signatureBuffer); + } catch { + return false; + } +} - if (!address || !signature) { - return res.status(400).json({ error: "Address and signature are required" }); - } +function getBearerToken(req: Request): string | null { + const header = req.header("authorization"); + if (!header) { + return null; + } - // 1. Fetch the challenge - const record = await prisma.auth_challenges.findUnique({ where: { address } }); - if (!record) { - return res.status(404).json({ error: "Challenge not found. Please request a new challenge." }); - } + const [scheme, token] = header.split(" "); + if (scheme !== "Bearer" || !token || token.length > 128) { + return null; + } - if (record.expires_at < new Date()) { - return res.status(400).json({ error: "Challenge expired" }); - } + return token; +} + +function getSessionToken(req: Request, bodyToken?: string): string | null { + const bearer = getBearerToken(req); + if (bearer) { + return bearer; + } - // 2. Verify the signature - let isValid = false; + const cookieHeader = req.header("cookie") ?? ""; + const cookieToken = cookieHeader + .split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith(`${SESSION_COOKIE_NAME}=`)) + ?.slice(SESSION_COOKIE_NAME.length + 1); + + return cookieToken || bodyToken || null; +} + +export async function isSessionRevoked(redisClient: RedisLike | null | undefined, token: string): Promise { + if (!redisClient) { + return false; + } + + const blacklistKey = `auth:revoked:${crypto.createHash("sha256").update(token).digest("hex")}`; + try { + const lookup = redisClient.get(blacklistKey); + const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 1)); + return (await Promise.race([lookup, timeout])) === "1"; + } catch { + // Redis is a performance optimization for revocations; database expiration + // checks below remain authoritative if Redis is unavailable. + return false; + } +} + +export async function revokeSession(redisClient: RedisLike | null | undefined, token: string, expiresAt: Date): Promise { + if (!redisClient) { + return; + } + + const ttlSeconds = Math.max(1, Math.ceil((expiresAt.getTime() - Date.now()) / 1000)); + const blacklistKey = `auth:revoked:${crypto.createHash("sha256").update(token).digest("hex")}`; + try { + await redisClient.set(blacklistKey, "1", "EX", ttlSeconds); + } catch { + // Database deletion still invalidates the session even if Redis is offline. + } +} + +export function createAuthRouter(state: AuthRouteState = {}): Router { + const authRouter = Router(); + const db = state.prismaClient ?? (prisma as unknown as AuthPrismaClient); + const redisClient = state.redisClient === undefined ? getDefaultRedisClient() : state.redisClient; + + authRouter.post("/challenge", async (req: Request, res: Response) => { try { - const keypair = Keypair.fromPublicKey(address); - - // Handle the case where signature is an object (some wallet kits wrap it) - const sigString = typeof signature === "object" && (signature as any).signature - ? (signature as any).signature - : typeof signature === "string" ? signature : ""; - - const hexRegex = /^[0-9a-fA-F]+$/; - const signatureBuffer = hexRegex.test(sigString) && sigString.length % 2 === 0 - ? Buffer.from(sigString, "hex") - : Buffer.from(sigString, "base64"); - - // Freighter (and stellar-wallets-kit) prefixes messages before hashing and signing to prevent spoofing - const SIGN_MESSAGE_PREFIX = "Stellar Signed Message:\n"; - const payloadBuffer = Buffer.from(SIGN_MESSAGE_PREFIX + record.challenge); - const messageHash = crypto.createHash("sha256").update(payloadBuffer).digest(); - - isValid = keypair.verify(messageHash, signatureBuffer); - - // Fallback for mock wallet in E2E tests (it returns the literal string "mock-signature") - if (!isValid && process.env.NODE_ENV !== "production") { - if (signature === record.challenge || signature === "mock-signature") { - isValid = true; - } + const parsed = ChallengeRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid challenge request" }); } - } catch (err) { - console.error("Signature verification failed structurally:", err); - isValid = false; + + const address = sanitizeStellarAddress(parsed.data.address); + if (!address) { + return res.status(400).json({ error: "Invalid Stellar public address" }); + } + + const challenge = buildChallenge(address); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS); + + await db.auth_challenges.upsert({ + where: { address }, + update: { challenge, expires_at: expiresAt }, + create: { address, challenge, expires_at: expiresAt }, + }); + + return res.json({ challenge, expiresAt: expiresAt.toISOString() }); + } catch (error) { + console.error("Auth challenge error:", error); + return res.status(500).json({ error: "Internal server error" }); } + }); + + authRouter.post("/verify", async (req: Request, res: Response) => { + try { + const parsed = VerifyRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid verify request" }); + } + + const address = sanitizeStellarAddress(parsed.data.address); + if (!address) { + return res.status(400).json({ error: "Invalid Stellar public address" }); + } + + const record = await db.auth_challenges.findUnique({ where: { address } }); + if (!record || !isChallengeFresh(record)) { + return res.status(401).json({ error: "Invalid or expired challenge" }); + } - // For local dev/E2E tests - if (!isValid && process.env.NODE_ENV !== "production") { - if (signature === record.challenge || signature === "mock-signature") { - isValid = true; + const isValid = verifyStellarSignature(address, record.challenge, parsed.data.signature); + if (!isValid) { + return res.status(401).json({ error: "Invalid signature" }); } + + // Atomic one-time nonce consumption: the same signed challenge cannot mint + // two sessions even if duplicate verify requests race after signature check. + const consumed = await db.auth_challenges.deleteMany({ + where: { address, challenge: record.challenge, expires_at: { gt: new Date() } }, + }); + if (consumed.count !== 1) { + return res.status(401).json({ error: "Challenge already used" }); + } + + const token = crypto.randomBytes(SESSION_TOKEN_BYTES).toString("base64url"); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS); + + await db.sessions.create({ + data: { + token, + address, + expires_at: expiresAt, + }, + }); + + return res + .cookie(SESSION_COOKIE_NAME, token, sessionCookieOptions) + .json({ token, address, expiresAt: expiresAt.toISOString() }); + } catch (error) { + console.error("Auth verify error:", error); + return res.status(500).json({ error: "Internal server error" }); } + }); + + authRouter.post("/session", async (req: Request, res: Response) => { + try { + const parsed = SessionRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid session request" }); + } + + const token = getSessionToken(req, parsed.data.token); + if (!token) { + return res.status(401).json({ error: "Missing session token" }); + } - if (!isValid) { - return res.status(401).json({ error: "Invalid signature" }); + if (await isSessionRevoked(redisClient, token)) { + return res.status(401).json({ error: "Session revoked" }); + } + + const session = await db.sessions.findUnique({ where: { token } }); + if (!session || session.expires_at <= new Date()) { + return res.status(401).json({ error: "Invalid or expired session" }); + } + + return res.json({ address: session.address, expiresAt: session.expires_at.toISOString() }); + } catch (error) { + console.error("Auth session error:", error); + return res.status(500).json({ error: "Internal server error" }); } + }); + + authRouter.post("/logout", async (req: Request, res: Response) => { + try { + const token = getSessionToken(req); + if (!token) { + return res.status(204).end(); + } - // 3. Delete the used challenge - await prisma.auth_challenges.delete({ where: { address } }); + const session = await db.sessions.findUnique({ where: { token } }); + if (session) { + await revokeSession(redisClient, token, session.expires_at); + await db.sessions.deleteMany({ where: { token } }); + } - // 4. Generate a session token - const token = crypto.randomUUID(); - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + return res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }).status(204).end(); + } catch (error) { + console.error("Auth logout error:", error); + return res.status(500).json({ error: "Internal server error" }); + } + }); - // 5. Save the session - await prisma.sessions.create({ - data: { - token, - address, - expires_at: expiresAt, - }, - }); + return authRouter; +} - res.json({ token, address }); - } catch (error) { - console.error("Auth verify error:", error); - res.status(500).json({ error: "Internal server error" }); - } -}); +const router = createAuthRouter(); export default router; diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts new file mode 100644 index 00000000..927ebcf3 --- /dev/null +++ b/backend/tests/auth.test.ts @@ -0,0 +1,133 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import Module from "node:module"; +import crypto from "node:crypto"; +import { Keypair } from "@stellar/stellar-sdk"; + +const originalLoad = (Module as any)._load; +(Module as any)._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === "../config/db") { + return { prisma: {} }; + } + return originalLoad.apply(this, [request, parent, isMain]); +}; + +const auth = require("../src/routes/auth") as typeof import("../src/routes/auth"); + +test("sanitizes Stellar addresses by enforcing canonical StrKey checksums", () => { + const keypair = Keypair.random(); + const address = keypair.publicKey(); + + assert.equal(auth.sanitizeStellarAddress(address), address); + assert.equal(auth.sanitizeStellarAddress(address.toLowerCase()), null); + assert.equal(auth.sanitizeStellarAddress(` ${address}`), null); + assert.equal(auth.sanitizeStellarAddress(`${address.slice(0, -1)}A`), null); +}); + +test("verifies SEP-53/Freighter style signatures over the prefixed challenge digest", () => { + const keypair = Keypair.random(); + const address = keypair.publicKey(); + const challenge = auth.buildChallenge(address, "00000000-0000-4000-8000-000000000000"); + const digest = crypto + .createHash("sha256") + .update(Buffer.from("Stellar Signed Message:\n" + challenge, "utf8")) + .digest(); + const signature = keypair.sign(digest).toString("base64"); + + assert.equal(auth.verifyStellarSignature(address, challenge, signature), true); + assert.equal(auth.verifyStellarSignature(address, `${challenge}!`, signature), false); + assert.equal(auth.verifyStellarSignature(Keypair.random().publicKey(), challenge, signature), false); +}); + +test("enforces challenge timelines and rejects expired challenges", () => { + const now = new Date("2026-05-29T00:00:00.000Z"); + + assert.equal(auth.isChallengeFresh({ expires_at: new Date("2026-05-29T00:00:01.000Z") }, now), true); + assert.equal(auth.isChallengeFresh({ expires_at: new Date("2026-05-29T00:00:00.000Z") }, now), false); + assert.equal(auth.isChallengeFresh({ expires_at: new Date("2026-05-28T23:59:59.999Z") }, now), false); +}); + +test("performs Redis blacklist lookups with a 1ms timeout budget", async () => { + const revokedRedis = { get: async () => "1", set: async () => "OK", del: async () => 1 }; + assert.equal(await auth.isSessionRevoked(revokedRedis as any, "token-a"), true); + + const slowRedis = { + get: () => new Promise((resolve) => setTimeout(() => resolve("1"), 25)), + set: async () => "OK", + del: async () => 1, + }; + const startedAt = performance.now(); + assert.equal(await auth.isSessionRevoked(slowRedis as any, "token-b"), false); + assert.ok(performance.now() - startedAt < 20); +}); + +test("auth router returns 401 for bad signatures and consumes valid challenges once", async () => { + const express = require("express") as typeof import("express"); + const keypair = Keypair.random(); + const address = keypair.publicKey(); + const challenge = auth.buildChallenge(address, "11111111-1111-4111-8111-111111111111"); + const record = { address, challenge, expires_at: new Date(Date.now() + 60_000) }; + const sessions = new Map(); + let storedRecord: typeof record | null = record; + + const app = express(); + app.use(express.json()); + app.use("/auth", auth.createAuthRouter({ + prismaClient: { + auth_challenges: { + upsert: async () => record, + findUnique: async () => storedRecord, + deleteMany: async ({ where }) => { + if (storedRecord && storedRecord.address === where.address && storedRecord.challenge === where.challenge && storedRecord.expires_at > where.expires_at.gt) { + storedRecord = null; + return { count: 1 }; + } + return { count: 0 }; + }, + }, + sessions: { + create: async ({ data }) => { sessions.set(data.token, data); return data; }, + findUnique: async ({ where }) => sessions.get(where.token) ?? null, + deleteMany: async ({ where }) => ({ count: sessions.delete(where.token) ? 1 : 0 }), + }, + }, + redisClient: null, + })); + + const server = app.listen(0); + const addressInfo = server.address(); + assert.equal(typeof addressInfo, "object"); + const baseUrl = `http://127.0.0.1:${(addressInfo as { port: number }).port}/auth`; + + try { + const badResponse = await fetch(`${baseUrl}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address, signature: Buffer.alloc(64).toString("base64") }), + }); + assert.equal(badResponse.status, 401); + + const digest = crypto + .createHash("sha256") + .update(Buffer.from("Stellar Signed Message:\n" + challenge, "utf8")) + .digest(); + const signature = keypair.sign(digest).toString("base64"); + const okResponse = await fetch(`${baseUrl}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address, signature }), + }); + assert.equal(okResponse.status, 200); + assert.equal(sessions.size, 1); + + const replayResponse = await fetch(`${baseUrl}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address, signature }), + }); + assert.equal(replayResponse.status, 401); + assert.equal(sessions.size, 1); + } finally { + await new Promise((resolve, reject) => server.close((error?: Error) => error ? reject(error) : resolve())); + } +});