diff --git a/bun.lock b/bun.lock index 00a9d0d549..fef6e2ab48 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,7 @@ "commander": "^14.0.1", "immer": "^10.1.3", "jimp": "^1.6.0", + "node-machine-id": "^1.1.12", "open": "^10.1.0", "pino": "9.4.0", "posthog-node": "^5.8.0", diff --git a/cli/package.json b/cli/package.json index 09235d9e06..5cb4628c8f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,6 +36,7 @@ "commander": "^14.0.1", "immer": "^10.1.3", "jimp": "^1.6.0", + "node-machine-id": "^1.1.12", "open": "^10.1.0", "pino": "9.4.0", "posthog-node": "^5.8.0", diff --git a/cli/src/components/login-modal-utils.ts b/cli/src/components/login-modal-utils.ts deleted file mode 100644 index 1b83608e3b..0000000000 --- a/cli/src/components/login-modal-utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Utility functions for the login screen component - */ - -/** - * Formats a URL for display by wrapping it at logical breakpoints - */ -export function formatUrl(url: string, maxWidth?: number): string[] { - if (!maxWidth || maxWidth <= 0 || url.length <= maxWidth) { - return [url] - } - - const lines: string[] = [] - let remaining = url - - while (remaining.length > 0) { - if (remaining.length <= maxWidth) { - lines.push(remaining) - break - } - - // Try to break at a logical point (after /, ?, &, =) - let breakPoint = maxWidth - for (let i = maxWidth - 1; i > maxWidth - 20 && i > 0; i--) { - if (['/', '?', '&', '='].includes(remaining[i])) { - breakPoint = i + 1 - break - } - } - - lines.push(remaining.substring(0, breakPoint)) - remaining = remaining.substring(breakPoint) - } - - return lines -} - -/** - * Generates a unique fingerprint ID for CLI authentication - */ -export function generateFingerprintId(): string { - return `codecane-cli-${Math.random().toString(36).substring(2, 15)}` -} - - -/** - * Parses the logo string into individual lines - */ -export function parseLogoLines(logo: string): string[] { - return logo.split('\n').filter((line) => line.length > 0) -} diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index c06944c91d..aa0a9f7b89 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -10,14 +10,11 @@ import { useLoginPolling } from '../hooks/use-login-polling' import { useLogo } from '../hooks/use-logo' import { useSheenAnimation } from '../hooks/use-sheen-animation' import { useTheme } from '../hooks/use-theme' -import { - formatUrl, - generateFingerprintId, - calculateResponsiveLayout, -} from '../login/utils' +import { formatUrl, calculateResponsiveLayout } from '../login/utils' import { useLoginStore } from '../state/login-store' import { IS_FREEBUFF } from '../utils/constants' import { copyTextToClipboard, isRemoteSession } from '../utils/clipboard' +import { getFingerprintId } from '../utils/fingerprint' import { logger } from '../utils/logger' import { getLogoBlockColor, getLogoAccentColor } from '../utils/theme-system' @@ -40,6 +37,7 @@ export const LoginModal = ({ loginUrl, loading, error, + fingerprintId, fingerprintHash, expiresAt, isWaitingForEnter, @@ -49,6 +47,7 @@ export const LoginModal = ({ setLoginUrl, setLoading, setError, + setFingerprintId, setFingerprintHash, setExpiresAt, setIsWaitingForEnter, @@ -59,9 +58,6 @@ export const LoginModal = ({ setHasClickedLink, } = useLoginStore() - // Generate fingerprint ID (only once on mount) - const [fingerprintId] = useState(() => generateFingerprintId()) - // Track hover state for copy button const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false) @@ -111,17 +107,22 @@ export const LoginModal = ({ setLoading(true) setError(null) - fetchLoginUrlMutation.mutate(fingerprintId, { + // Near-instant after the prefetch in initializeApp; falls back to the + // sync legacy fingerprint if hardware hashing fails. + const id = await getFingerprintId() + setFingerprintId(id) + + fetchLoginUrlMutation.mutate(id, { onSettled: () => { setLoading(false) }, }) }, [ - fingerprintId, loading, hasOpenedBrowser, setLoading, setError, + setFingerprintId, fetchLoginUrlMutation, ]) diff --git a/cli/src/hooks/use-login-polling.ts b/cli/src/hooks/use-login-polling.ts index 0cc76c9953..2aa409eaca 100644 --- a/cli/src/hooks/use-login-polling.ts +++ b/cli/src/hooks/use-login-polling.ts @@ -8,7 +8,7 @@ import type { User } from '../utils/auth' interface UseLoginPollingParams { loginUrl: string | null - fingerprintId: string + fingerprintId: string | null fingerprintHash: string | null expiresAt: string | null isWaitingForEnter: boolean @@ -49,7 +49,10 @@ export function useLoginPolling({ }, [onError]) useEffect(() => { - if (!loginUrl || !fingerprintHash || !expiresAt || !isWaitingForEnter) { + // fingerprintHash only becomes non-null after the login-URL mutation + // succeeds, and that path always sets fingerprintId first — so gating + // on fingerprintHash implicitly gates on fingerprintId. + if (!loginUrl || !fingerprintId || !fingerprintHash || !expiresAt || !isWaitingForEnter) { return } @@ -67,7 +70,7 @@ export function useLoginPolling({ }, { baseUrl: LOGIN_WEBSITE_URL, - fingerprintId, + fingerprintId: fingerprintId!, fingerprintHash, expiresAt, shouldContinue: () => active, diff --git a/cli/src/init/init-app.ts b/cli/src/init/init-app.ts index 1b8ae41efa..a0f2b0794e 100644 --- a/cli/src/init/init-app.ts +++ b/cli/src/init/init-app.ts @@ -13,6 +13,7 @@ import { setProjectRoot } from '../project-files' import { initTimestampFormatter } from '../utils/helpers' import { enableManualThemeRefresh } from '../utils/theme-system' import { initAnalytics } from '../utils/analytics' +import { getFingerprintId } from '../utils/fingerprint' import { initializeDirenv } from './init-direnv' export async function initializeApp(params: { cwd?: string }): Promise { @@ -38,6 +39,10 @@ export async function initializeApp(params: { cwd?: string }): Promise { enableManualThemeRefresh() initTimestampFormatter() + // Compute the hardware-based fingerprint in the background so it's ready + // by the time the user finishes reading the login prompt. + void getFingerprintId() + // Refresh Claude OAuth credentials in the background if they exist // This ensures the subscription status is up-to-date on startup if (CLAUDE_OAUTH_ENABLED) { diff --git a/cli/src/login/plain-login.ts b/cli/src/login/plain-login.ts index ea29f19b03..9f2803b644 100644 --- a/cli/src/login/plain-login.ts +++ b/cli/src/login/plain-login.ts @@ -2,9 +2,9 @@ import { cyan, green, red, yellow, bold } from 'picocolors' import { LOGIN_WEBSITE_URL } from './constants' import { generateLoginUrl, pollLoginStatus } from './login-flow' -import { generateFingerprintId } from './utils' import { saveUserCredentials } from '../utils/auth' import { IS_FREEBUFF } from '../utils/constants' +import { getFingerprintId } from '../utils/fingerprint' import { logger } from '../utils/logger' import type { User } from '../utils/auth' @@ -18,7 +18,7 @@ import type { User } from '../utils/auth' * clipboard and browser integration don't work. */ export async function runPlainLogin(): Promise { - const fingerprintId = generateFingerprintId() + const fingerprintId = await getFingerprintId() console.log() console.log(bold(IS_FREEBUFF ? 'Freebuff Login' : 'Codebuff Login')) diff --git a/cli/src/login/utils.ts b/cli/src/login/utils.ts index 354f6a920b..2063dd2c77 100644 --- a/cli/src/login/utils.ts +++ b/cli/src/login/utils.ts @@ -54,13 +54,6 @@ export function formatUrl(url: string, maxWidth?: number): string[] { return lines } -/** - * Generates a unique fingerprint ID for CLI authentication - */ -export function generateFingerprintId(): string { - return `codebuff-cli-${Math.random().toString(36).substring(2, 15)}` -} - /** * Determines the color for a character based on its position relative to the sheen * Block characters use blockColor, shadow/border characters animate to accent green diff --git a/cli/src/state/login-store.ts b/cli/src/state/login-store.ts index 64ce7dba45..915dde05c3 100644 --- a/cli/src/state/login-store.ts +++ b/cli/src/state/login-store.ts @@ -5,6 +5,7 @@ export type LoginStoreState = { loginUrl: string | null loading: boolean error: string | null + fingerprintId: string | null fingerprintHash: string | null expiresAt: string | null isWaitingForEnter: boolean @@ -23,6 +24,9 @@ type LoginStoreActions = { setError: ( value: string | null | ((prev: string | null) => string | null), ) => void + setFingerprintId: ( + value: string | null | ((prev: string | null) => string | null), + ) => void setFingerprintHash: ( value: string | null | ((prev: string | null) => string | null), ) => void @@ -46,6 +50,7 @@ const initialState: LoginStoreState = { loginUrl: null, loading: false, error: null, + fingerprintId: null, fingerprintHash: null, expiresAt: null, isWaitingForEnter: false, @@ -76,6 +81,12 @@ export const useLoginStore = create()( state.error = typeof value === 'function' ? value(state.error) : value }), + setFingerprintId: (value) => + set((state) => { + state.fingerprintId = + typeof value === 'function' ? value(state.fingerprintId) : value + }), + setFingerprintHash: (value) => set((state) => { state.fingerprintHash = @@ -125,6 +136,7 @@ export const useLoginStore = create()( state.loginUrl = initialState.loginUrl state.loading = initialState.loading state.error = initialState.error + state.fingerprintId = initialState.fingerprintId state.fingerprintHash = initialState.fingerprintHash state.expiresAt = initialState.expiresAt state.isWaitingForEnter = initialState.isWaitingForEnter diff --git a/cli/src/utils/fingerprint.ts b/cli/src/utils/fingerprint.ts index dc74dcac2a..22e974fdda 100644 --- a/cli/src/utils/fingerprint.ts +++ b/cli/src/utils/fingerprint.ts @@ -21,20 +21,16 @@ let machineIdModule: typeof import('node-machine-id') | null = null let systeminformationModule: typeof import('systeminformation') | null = null async function getMachineId(): Promise { - try { - if (!machineIdModule) { - machineIdModule = await import('node-machine-id') - } - const id = await machineIdModule.machineId() - // Validate that we got a real machine ID, not an empty or placeholder value - if (!id || id === 'unknown' || id.length < 8) { - throw new Error('Invalid machine ID returned') - } - return id - } catch (error) { - // Re-throw to signal that enhanced fingerprinting should fall back to legacy - throw error + if (!machineIdModule) { + machineIdModule = await import('node-machine-id') } + const id = await machineIdModule.machineId() + // Validate that we got a real machine ID, not an empty or placeholder value. + // Throwing here triggers the legacy fallback in calculateFingerprint(). + if (!id || id === 'unknown' || id.length < 8) { + throw new Error('Invalid machine ID returned') + } + return id } async function getSystemInfo(): Promise<{ @@ -141,6 +137,25 @@ function calculateLegacyFingerprint(): string { return `codebuff-cli-${randomSuffix}` } +/** + * Cached fingerprint promise. Populated on first call and reused for the + * process lifetime so every auth step in a session ships the same fingerprint + * to the server. + */ +let cachedFingerprintPromise: Promise | null = null + +/** + * Returns the process-wide CLI fingerprint, computing it on first call. + * Safe to call from multiple places — the first caller wins and the rest + * await the same promise. + */ +export function getFingerprintId(): Promise { + if (!cachedFingerprintPromise) { + cachedFingerprintPromise = calculateFingerprint() + } + return cachedFingerprintPromise +} + /** * Main fingerprint function. * Tries enhanced fingerprinting first, falls back to legacy if it fails. diff --git a/freebuff/web/src/app/onboard/page.tsx b/freebuff/web/src/app/onboard/page.tsx index 4906290a21..2299b77ac0 100644 --- a/freebuff/web/src/app/onboard/page.tsx +++ b/freebuff/web/src/app/onboard/page.tsx @@ -130,6 +130,9 @@ const Onboard = async ({ searchParams }: PageProps) => { ) } + // Log fingerprint collisions as a signal for async abuse review, but don't + // block login — shared dev machines, Docker images with baked-in machine-ids, + // and CI runners can legitimately produce the same fingerprint across users. const { hasConflict, existingUserId } = await checkFingerprintConflict( fingerprintId, user.id, @@ -139,13 +142,6 @@ const Onboard = async ({ searchParams }: PageProps) => { { fingerprintId, existingUserId, attemptedUserId: user.id }, 'Fingerprint ownership conflict', ) - return ( - - ) } const sessionToken = await getSessionTokenFromCookies() diff --git a/web/src/app/onboard/page.tsx b/web/src/app/onboard/page.tsx index 9f38619b39..f39d22a208 100644 --- a/web/src/app/onboard/page.tsx +++ b/web/src/app/onboard/page.tsx @@ -94,6 +94,9 @@ const Onboard = async ({ searchParams }: PageProps) => { ) } + // Log fingerprint collisions as a signal for async abuse review, but don't + // block login — shared dev machines, Docker images with baked-in machine-ids, + // and CI runners can legitimately produce the same fingerprint across users. const { hasConflict, existingUserId } = await checkFingerprintConflict( fingerprintId, user.id, @@ -103,18 +106,6 @@ const Onboard = async ({ searchParams }: PageProps) => { { fingerprintId, existingUserId, attemptedUserId: user.id }, 'Fingerprint ownership conflict', ) - return ( - - Please try generating a new login code. If the problem persists, - contact {env.NEXT_PUBLIC_SUPPORT_EMAIL} for assistance. -

- } - /> - ) } const sessionToken = await getSessionTokenFromCookies()