From ddd7c0c03b512ea077099751d6990ebc6ca6a626 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 19:31:30 +0800 Subject: [PATCH] Add generateSecureBase58Token to shared utils with login_ prefix - Create packages/utils/src/generate-base58-token.ts with typed prefix support - Function returns `${TPrefix}${string}` for type-safe prefixed tokens - Add isBase58() validator and parseBase58Token() helper - Add comprehensive tests (13 test cases) - Update login request tokens to use "login_" prefix - Fix login-password.ts to not replace token (cookie/DB mismatch bug) - Migrate all token generation from generateSecureToken (hex) to generateSecureBase58Token (base58) - Remove duplicate token generation from api-server/utils/crypto.ts Co-Authored-By: Claude Opus 4.5 --- .../procedures/auth/create-login-request.ts | 6 +- .../src/procedures/auth/forgot-password.ts | 9 +- .../src/procedures/auth/login-password.ts | 18 +-- .../procedures/auth/resend-verification.ts | 11 +- apps/api-server/src/procedures/auth/signup.ts | 7 +- .../api-server/src/procedures/orgs/invites.ts | 7 +- apps/api-server/src/utils/crypto.ts | 55 +-------- .../utils/src/generate-base58-token.test.ts | 105 ++++++++++++++++++ packages/utils/src/generate-base58-token.ts | 75 +++++++++++++ packages/utils/src/index.ts | 5 + 10 files changed, 218 insertions(+), 80 deletions(-) create mode 100644 packages/utils/src/generate-base58-token.test.ts create mode 100644 packages/utils/src/generate-base58-token.ts diff --git a/apps/api-server/src/procedures/auth/create-login-request.ts b/apps/api-server/src/procedures/auth/create-login-request.ts index 4cc8237..0cc0935 100644 --- a/apps/api-server/src/procedures/auth/create-login-request.ts +++ b/apps/api-server/src/procedures/auth/create-login-request.ts @@ -11,9 +11,9 @@ import { setCookie, } from "../../utils/cookies.js"; import { - generateBase58Token, generateDeviceFingerprint, generateExpiry, + generateSecureBase58Token, } from "../../utils/crypto.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { isDeviceTrusted } from "../../utils/session.js"; @@ -62,7 +62,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( if (!user) { // Generate placeholder token (base58) for anti-enumeration // This prevents attackers from knowing if an email exists based on response - const placeholderToken = generateBase58Token(); + const placeholderToken = generateSecureBase58Token("login_"); // Set placeholder login request token cookie setCookie( @@ -107,7 +107,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( // Create login request with secure token const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST); - const token = generateBase58Token(); + const token = generateSecureBase58Token("login_"); await context.db .insertInto("login_requests") diff --git a/apps/api-server/src/procedures/auth/forgot-password.ts b/apps/api-server/src/procedures/auth/forgot-password.ts index 9f71fc8..55a676b 100644 --- a/apps/api-server/src/procedures/auth/forgot-password.ts +++ b/apps/api-server/src/procedures/auth/forgot-password.ts @@ -7,7 +7,10 @@ */ import { TOKEN_DURATIONS } from "../../utils/cookies.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { + generateExpiry, + generateSecureBase58Token, +} from "../../utils/crypto.js"; import { sendPasswordResetEmail } from "../../utils/email.js"; import { os } from "../base.js"; @@ -33,8 +36,8 @@ export const forgotPassword = os.auth.forgotPassword.handler( .where("user_id", "=", user.id) .execute(); - // Generate secure token (64 hex chars) - const token = generateSecureToken(); + // Generate secure base58 token + const token = generateSecureBase58Token(); // Create password reset record with 1 hour expiry const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); diff --git a/apps/api-server/src/procedures/auth/login-password.ts b/apps/api-server/src/procedures/auth/login-password.ts index dcd2296..456fd97 100644 --- a/apps/api-server/src/procedures/auth/login-password.ts +++ b/apps/api-server/src/procedures/auth/login-password.ts @@ -5,7 +5,6 @@ import { ORPCError } from "@orpc/server"; import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; -import { generateSecureToken } from "../../utils/crypto.js"; import { sendLoginConfirmationEmail } from "../../utils/email.js"; import { verifyPassword } from "../../utils/password.js"; import { isDeviceTrusted } from "../../utils/session.js"; @@ -47,6 +46,7 @@ export const loginPassword = os.auth.loginPassword.handler( "login_requests.id", "login_requests.user_id", "login_requests.email", + "login_requests.token", "login_requests.device_fingerprint", "login_requests.expires_at", "login_requests.completed_at", @@ -106,19 +106,9 @@ export const loginPassword = os.auth.loginPassword.handler( .where("id", "=", result.id) .execute(); } else { - // Device is untrusted - generate confirmation token and send email - const confirmationToken = generateSecureToken(); - - await context.db - .updateTable("login_requests") - .set({ - token: confirmationToken, - }) - .where("id", "=", result.id) - .execute(); - - // Send login confirmation email - await sendLoginConfirmationEmail(result.email, confirmationToken); + // Device is untrusted - send confirmation email with existing token + // The same base58 token is used for both cookie lookup and email confirmation + await sendLoginConfirmationEmail(result.email, result.token); } // Return void (success) diff --git a/apps/api-server/src/procedures/auth/resend-verification.ts b/apps/api-server/src/procedures/auth/resend-verification.ts index ad1b190..5f2eff7 100644 --- a/apps/api-server/src/procedures/auth/resend-verification.ts +++ b/apps/api-server/src/procedures/auth/resend-verification.ts @@ -5,13 +5,16 @@ * Flow: * 1. Check if email is already verified (return early if so) * 2. Delete any existing verification tokens for this user - * 3. Generate new secure token (64 hex chars) + * 3. Generate new secure base58 token * 4. Create new email_verifications record with 24 hour expiry * 5. Send verification email (stubbed) */ import { TOKEN_DURATIONS } from "../../utils/cookies.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { + generateExpiry, + generateSecureBase58Token, +} from "../../utils/crypto.js"; import { sendVerificationEmail } from "../../utils/email.js"; import { authMiddleware, os } from "../base.js"; @@ -30,8 +33,8 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail .where("user_id", "=", context.user.id) .execute(); - // Generate new secure token - const token = generateSecureToken(); + // Generate new secure base58 token + const token = generateSecureBase58Token(); const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); // Create new verification record diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index e821362..33338d5 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -17,7 +17,10 @@ import { setCookie, TOKEN_DURATIONS, } from "../../utils/cookies.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { + generateExpiry, + generateSecureBase58Token, +} from "../../utils/crypto.js"; import { sendVerificationEmail } from "../../utils/email.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { hashPassword, validatePassword } from "../../utils/password.js"; @@ -262,7 +265,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { ); // Generate verification token - const verificationToken = generateSecureToken(); + const verificationToken = generateSecureBase58Token(); const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); // Store verification token (store raw token, not hash - it's already high-entropy) diff --git a/apps/api-server/src/procedures/orgs/invites.ts b/apps/api-server/src/procedures/orgs/invites.ts index 43e4729..4089ad2 100644 --- a/apps/api-server/src/procedures/orgs/invites.ts +++ b/apps/api-server/src/procedures/orgs/invites.ts @@ -4,7 +4,10 @@ import { ORPCError } from "@orpc/server"; import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { + generateExpiry, + generateSecureBase58Token, +} from "../../utils/crypto.js"; import { sendOrgInviteEmail } from "../../utils/email.js"; import { authMiddleware, os } from "../base.js"; import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js"; @@ -88,7 +91,7 @@ export const invitesCreate = os.orgs.invites.create } // Generate invite token and expiry - const token = generateSecureToken(); + const token = generateSecureBase58Token(); const expiresAt = generateExpiry(ORG_INVITE_EXPIRY_DAYS * 24 * 60 * 60); try { diff --git a/apps/api-server/src/utils/crypto.ts b/apps/api-server/src/utils/crypto.ts index 9b3c25f..b6eab81 100644 --- a/apps/api-server/src/utils/crypto.ts +++ b/apps/api-server/src/utils/crypto.ts @@ -1,5 +1,8 @@ import { base58 } from "@scure/base"; +// Re-export generateSecureBase58Token from shared utils +export { generateSecureBase58Token } from "@reviq/utils"; + /** * Token prefix for all RevIQ API tokens */ @@ -62,58 +65,6 @@ export const generateDeviceFingerprint = (): string => { return crypto.randomUUID(); }; -/** - * Generate a secure random token for email verification, password reset, etc. - * Uses 32 bytes (256 bits) of entropy - * Uses Web Crypto API for Cloudflare Workers compatibility - */ -export const generateSecureToken = (): string => { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -}; - -/** - * Base58 alphabet (Bitcoin-style, no 0, O, I, l) - */ -const BASE58_ALPHABET = - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - -/** - * Generate a cryptographically secure base58 token - * Uses 24 bytes (192 bits) of entropy, producing ~33 character output - */ -export const generateBase58Token = (byteLength = 24): string => { - const bytes = new Uint8Array(byteLength); - crypto.getRandomValues(bytes); - - // Convert bytes to base58 - let result = ""; - let num = BigInt(0); - for (const byte of bytes) { - num = num * 256n + BigInt(byte); - } - - while (num > 0n) { - const remainder = Number(num % 58n); - result = BASE58_ALPHABET.charAt(remainder) + result; - num /= 58n; - } - - // Handle leading zeros - for (const byte of bytes) { - if (byte === 0) { - result = BASE58_ALPHABET.charAt(0) + result; - } else { - break; - } - } - - return result; -}; - /** * Generate expiration date */ diff --git a/packages/utils/src/generate-base58-token.test.ts b/packages/utils/src/generate-base58-token.test.ts new file mode 100644 index 0000000..043fbe9 --- /dev/null +++ b/packages/utils/src/generate-base58-token.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "bun:test"; +import { + generateSecureBase58Token, + isBase58, + parseBase58Token, +} from "./generate-base58-token.js"; + +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +describe("isBase58", () => { + it("should return true for valid base58 strings", () => { + expect(isBase58("123456789")).toBe(true); + expect(isBase58("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")).toBe( + true, + ); + expect(isBase58("9JKmn")).toBe(true); + }); + + it("should return false for strings with invalid characters", () => { + // 0, O, I, l are not in base58 alphabet + expect(isBase58("0")).toBe(false); + expect(isBase58("O")).toBe(false); + expect(isBase58("I")).toBe(false); + expect(isBase58("l")).toBe(false); + expect(isBase58("abc0def")).toBe(false); + }); + + it("should return false for empty strings", () => { + expect(isBase58("")).toBe(false); + }); + + it("should return false for strings with special characters", () => { + expect(isBase58("abc+def")).toBe(false); + expect(isBase58("abc/def")).toBe(false); + expect(isBase58("abc=def")).toBe(false); + }); +}); + +describe("generateSecureBase58Token", () => { + it("should generate a valid base58 token", () => { + const token = generateSecureBase58Token(); + expect(isBase58(token)).toBe(true); + }); + + it("should generate tokens of expected length (~33 chars for 24 bytes)", () => { + const token = generateSecureBase58Token(); + // 24 bytes = 192 bits, base58 encoding gives roughly 33 chars + expect(token.length).toBeGreaterThanOrEqual(30); + expect(token.length).toBeLessThanOrEqual(35); + }); + + it("should generate unique tokens", () => { + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + tokens.add(generateSecureBase58Token()); + } + expect(tokens.size).toBe(100); + }); + + it("should only use base58 alphabet characters", () => { + for (let i = 0; i < 50; i++) { + const token = generateSecureBase58Token(); + for (const char of token) { + expect(BASE58_ALPHABET.includes(char)).toBe(true); + } + } + }); + + it("should prepend prefix when provided", () => { + const token = generateSecureBase58Token("lrt_"); + expect(token.startsWith("lrt_")).toBe(true); + // The part after prefix should be valid base58 + expect(isBase58(token.slice(4))).toBe(true); + }); +}); + +describe("parseBase58Token", () => { + it("should parse token with correct prefix", () => { + const token = generateSecureBase58Token("lrt_"); + const parsed = parseBase58Token(token, "lrt_"); + expect(parsed).not.toBeNull(); + if (parsed !== null) { + expect(isBase58(parsed)).toBe(true); + } + }); + + it("should return null for wrong prefix", () => { + const token = generateSecureBase58Token("lrt_"); + const parsed = parseBase58Token(token, "wrong_"); + expect(parsed).toBeNull(); + }); + + it("should return null for missing prefix", () => { + const token = generateSecureBase58Token(); + const parsed = parseBase58Token(token, "lrt_"); + expect(parsed).toBeNull(); + }); + + it("should return null for invalid base58 after prefix", () => { + const invalidToken = "lrt_abc0def"; // 0 is not valid base58 + const parsed = parseBase58Token(invalidToken, "lrt_"); + expect(parsed).toBeNull(); + }); +}); diff --git a/packages/utils/src/generate-base58-token.ts b/packages/utils/src/generate-base58-token.ts new file mode 100644 index 0000000..2622c0b --- /dev/null +++ b/packages/utils/src/generate-base58-token.ts @@ -0,0 +1,75 @@ +/** + * Generate cryptographically secure base58 tokens + * Uses Bitcoin-style base58 alphabet (no 0, O, I, l to avoid confusion) + */ + +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +/** + * Check if a string is valid base58 + */ +export const isBase58 = (str: string): boolean => { + for (const char of str) { + if (!BASE58_ALPHABET.includes(char)) { + return false; + } + } + return str.length > 0; +}; + +/** + * Generate a cryptographically secure base58 token + * Uses 24 bytes (192 bits) of entropy, producing ~33 character output + * + * @param prefix - Optional prefix to prepend (e.g., "login_" for login request tokens) + */ +export function generateSecureBase58Token( + prefix?: TPrefix, +): `${TPrefix}${string}` { + const byteLength = 24; + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + + // Convert bytes to base58 + let result = ""; + let num = BigInt(0); + for (const byte of bytes) { + num = num * 256n + BigInt(byte); + } + + while (num > 0n) { + const remainder = Number(num % 58n); + result = BASE58_ALPHABET.charAt(remainder) + result; + num /= 58n; + } + + // Handle leading zeros + for (const byte of bytes) { + if (byte === 0) { + result = BASE58_ALPHABET.charAt(0) + result; + } else { + break; + } + } + + return `${prefix ?? ""}${result}` as `${TPrefix}${string}`; +} + +/** + * Parse a token with an expected prefix + * Returns the token without prefix if valid, null if invalid + */ +export const parseBase58Token = ( + token: string, + expectedPrefix: string, +): string | null => { + if (!token.startsWith(expectedPrefix)) { + return null; + } + const base58Part = token.slice(expectedPrefix.length); + if (!isBase58(base58Part)) { + return null; + } + return base58Part; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e07c313..1c247d1 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1 +1,6 @@ +export { + generateSecureBase58Token, + isBase58, + parseBase58Token, +} from "./generate-base58-token.js"; export { hashPassword, verifyPassword } from "./hash-password.js";