Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 0 additions & 51 deletions cli/src/components/login-modal-utils.ts

This file was deleted.

21 changes: 11 additions & 10 deletions cli/src/components/login-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -40,6 +37,7 @@ export const LoginModal = ({
loginUrl,
loading,
error,
fingerprintId,
fingerprintHash,
expiresAt,
isWaitingForEnter,
Expand All @@ -49,6 +47,7 @@ export const LoginModal = ({
setLoginUrl,
setLoading,
setError,
setFingerprintId,
setFingerprintHash,
setExpiresAt,
setIsWaitingForEnter,
Expand All @@ -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)

Expand Down Expand Up @@ -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,
])

Expand Down
9 changes: 6 additions & 3 deletions cli/src/hooks/use-login-polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -67,7 +70,7 @@ export function useLoginPolling({
},
{
baseUrl: LOGIN_WEBSITE_URL,
fingerprintId,
fingerprintId: fingerprintId!,
fingerprintHash,
expiresAt,
shouldContinue: () => active,
Expand Down
5 changes: 5 additions & 0 deletions cli/src/init/init-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -38,6 +39,10 @@ export async function initializeApp(params: { cwd?: string }): Promise<void> {
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) {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/login/plain-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -18,7 +18,7 @@ import type { User } from '../utils/auth'
* clipboard and browser integration don't work.
*/
export async function runPlainLogin(): Promise<void> {
const fingerprintId = generateFingerprintId()
const fingerprintId = await getFingerprintId()

console.log()
console.log(bold(IS_FREEBUFF ? 'Freebuff Login' : 'Codebuff Login'))
Expand Down
7 changes: 0 additions & 7 deletions cli/src/login/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions cli/src/state/login-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -46,6 +50,7 @@ const initialState: LoginStoreState = {
loginUrl: null,
loading: false,
error: null,
fingerprintId: null,
fingerprintHash: null,
expiresAt: null,
isWaitingForEnter: false,
Expand Down Expand Up @@ -76,6 +81,12 @@ export const useLoginStore = create<LoginStore>()(
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 =
Expand Down Expand Up @@ -125,6 +136,7 @@ export const useLoginStore = create<LoginStore>()(
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
Expand Down
41 changes: 28 additions & 13 deletions cli/src/utils/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,16 @@ let machineIdModule: typeof import('node-machine-id') | null = null
let systeminformationModule: typeof import('systeminformation') | null = null

async function getMachineId(): Promise<string> {
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<{
Expand Down Expand Up @@ -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<string> | 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<string> {
if (!cachedFingerprintPromise) {
cachedFingerprintPromise = calculateFingerprint()
}
return cachedFingerprintPromise
}

/**
* Main fingerprint function.
* Tries enhanced fingerprinting first, falls back to legacy if it fails.
Expand Down
10 changes: 3 additions & 7 deletions freebuff/web/src/app/onboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -139,13 +142,6 @@ const Onboard = async ({ searchParams }: PageProps) => {
{ fingerprintId, existingUserId, attemptedUserId: user.id },
'Fingerprint ownership conflict',
)
return (
<StatusCard
title="Unable to complete login"
description="Something went wrong during the login process."
message={`Please try generating a new login code. If the problem persists, contact ${env.NEXT_PUBLIC_SUPPORT_EMAIL} for assistance.`}
/>
)
}

const sessionToken = await getSessionTokenFromCookies()
Expand Down
15 changes: 3 additions & 12 deletions web/src/app/onboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -103,18 +106,6 @@ const Onboard = async ({ searchParams }: PageProps) => {
{ fingerprintId, existingUserId, attemptedUserId: user.id },
'Fingerprint ownership conflict',
)
return (
<CardWithBeams
title="Unable to complete login"
description="Something went wrong during the login process."
content={
<p>
Please try generating a new login code. If the problem persists,
contact {env.NEXT_PUBLIC_SUPPORT_EMAIL} for assistance.
</p>
}
/>
)
}

const sessionToken = await getSessionTokenFromCookies()
Expand Down
Loading