From 9e4038706c87cbd3731824b273ad0bb7425af142 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 21 Apr 2026 15:00:53 -0700 Subject: [PATCH 1/3] Wire hardware-based CLI fingerprint into login flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enhanced hardware fingerprint (machine-id + CPU + MAC + hostname, SHA-256) in cli/src/utils/fingerprint.ts existed but was never imported, so every login path used Math.random() and every session.fingerprint_id was unique — killing any multi-account clustering signal (maxFpShare/maxSigShare stuck at 1). Cache the promise once per process and pull the id into login-store so both TUI login and plain-text login ship the same hardware hash. Pre-fetch during initializeApp so it's resolved by the time the user hits Enter. Dropped two dead duplicates of generateFingerprintId and the unused login-modal-utils.ts. Co-Authored-By: Claude Opus 4.7 --- bun.lock | 1 + cli/package.json | 1 + cli/src/components/login-modal-utils.ts | 51 ------------------------- cli/src/components/login-modal.tsx | 21 +++++----- cli/src/hooks/use-login-polling.ts | 7 +++- cli/src/init/init-app.ts | 5 +++ cli/src/login/plain-login.ts | 4 +- cli/src/login/utils.ts | 7 ---- cli/src/state/login-store.ts | 12 ++++++ cli/src/utils/fingerprint.ts | 41 +++++++++++++------- 10 files changed, 65 insertions(+), 85 deletions(-) delete mode 100644 cli/src/components/login-modal-utils.ts 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..b1887716b7 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,6 +49,9 @@ export function useLoginPolling({ }, [onError]) useEffect(() => { + // 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 || !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. From 6797baca287cae44a49dedb107cc44c2ea15a936 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 21 Apr 2026 16:17:33 -0700 Subject: [PATCH 2/3] Update cli/src/hooks/use-login-polling.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- cli/src/hooks/use-login-polling.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/hooks/use-login-polling.ts b/cli/src/hooks/use-login-polling.ts index b1887716b7..2aa409eaca 100644 --- a/cli/src/hooks/use-login-polling.ts +++ b/cli/src/hooks/use-login-polling.ts @@ -52,7 +52,7 @@ export function useLoginPolling({ // 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 || !fingerprintHash || !expiresAt || !isWaitingForEnter) { + if (!loginUrl || !fingerprintId || !fingerprintHash || !expiresAt || !isWaitingForEnter) { return } From 609645bfb797186582f430d8efd652fbf519405d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 21 Apr 2026 16:24:42 -0700 Subject: [PATCH 3/3] Soften fingerprint conflict to log-only, never block login With the new hardware-based fingerprint, legit users on shared dev machines, Docker images with baked-in /etc/machine-id, CI runners, and corporate golden images can all produce the same fingerprint. Hard-blocking in that case would lock out coworkers behind whichever user logged in first. Keep the "Fingerprint ownership conflict" warn log as input for async abuse review, but always proceed to createCliSession so the signal never gates login on its own. Co-Authored-By: Claude Opus 4.7 --- freebuff/web/src/app/onboard/page.tsx | 10 +++------- web/src/app/onboard/page.tsx | 15 +++------------ 2 files changed, 6 insertions(+), 19 deletions(-) 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()