Skip to content

Commit 9e40387

Browse files
jahoomaclaude
andcommitted
Wire hardware-based CLI fingerprint into login flow
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 <noreply@anthropic.com>
1 parent 6bb2c6c commit 9e40387

10 files changed

Lines changed: 65 additions & 85 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"commander": "^14.0.1",
3737
"immer": "^10.1.3",
3838
"jimp": "^1.6.0",
39+
"node-machine-id": "^1.1.12",
3940
"open": "^10.1.0",
4041
"pino": "9.4.0",
4142
"posthog-node": "^5.8.0",

cli/src/components/login-modal-utils.ts

Lines changed: 0 additions & 51 deletions
This file was deleted.

cli/src/components/login-modal.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,11 @@ import { useLoginPolling } from '../hooks/use-login-polling'
1010
import { useLogo } from '../hooks/use-logo'
1111
import { useSheenAnimation } from '../hooks/use-sheen-animation'
1212
import { useTheme } from '../hooks/use-theme'
13-
import {
14-
formatUrl,
15-
generateFingerprintId,
16-
calculateResponsiveLayout,
17-
} from '../login/utils'
13+
import { formatUrl, calculateResponsiveLayout } from '../login/utils'
1814
import { useLoginStore } from '../state/login-store'
1915
import { IS_FREEBUFF } from '../utils/constants'
2016
import { copyTextToClipboard, isRemoteSession } from '../utils/clipboard'
17+
import { getFingerprintId } from '../utils/fingerprint'
2118
import { logger } from '../utils/logger'
2219
import { getLogoBlockColor, getLogoAccentColor } from '../utils/theme-system'
2320

@@ -40,6 +37,7 @@ export const LoginModal = ({
4037
loginUrl,
4138
loading,
4239
error,
40+
fingerprintId,
4341
fingerprintHash,
4442
expiresAt,
4543
isWaitingForEnter,
@@ -49,6 +47,7 @@ export const LoginModal = ({
4947
setLoginUrl,
5048
setLoading,
5149
setError,
50+
setFingerprintId,
5251
setFingerprintHash,
5352
setExpiresAt,
5453
setIsWaitingForEnter,
@@ -59,9 +58,6 @@ export const LoginModal = ({
5958
setHasClickedLink,
6059
} = useLoginStore()
6160

62-
// Generate fingerprint ID (only once on mount)
63-
const [fingerprintId] = useState(() => generateFingerprintId())
64-
6561
// Track hover state for copy button
6662
const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false)
6763

@@ -111,17 +107,22 @@ export const LoginModal = ({
111107
setLoading(true)
112108
setError(null)
113109

114-
fetchLoginUrlMutation.mutate(fingerprintId, {
110+
// Near-instant after the prefetch in initializeApp; falls back to the
111+
// sync legacy fingerprint if hardware hashing fails.
112+
const id = await getFingerprintId()
113+
setFingerprintId(id)
114+
115+
fetchLoginUrlMutation.mutate(id, {
115116
onSettled: () => {
116117
setLoading(false)
117118
},
118119
})
119120
}, [
120-
fingerprintId,
121121
loading,
122122
hasOpenedBrowser,
123123
setLoading,
124124
setError,
125+
setFingerprintId,
125126
fetchLoginUrlMutation,
126127
])
127128

cli/src/hooks/use-login-polling.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { User } from '../utils/auth'
88

99
interface UseLoginPollingParams {
1010
loginUrl: string | null
11-
fingerprintId: string
11+
fingerprintId: string | null
1212
fingerprintHash: string | null
1313
expiresAt: string | null
1414
isWaitingForEnter: boolean
@@ -49,6 +49,9 @@ export function useLoginPolling({
4949
}, [onError])
5050

5151
useEffect(() => {
52+
// fingerprintHash only becomes non-null after the login-URL mutation
53+
// succeeds, and that path always sets fingerprintId first — so gating
54+
// on fingerprintHash implicitly gates on fingerprintId.
5255
if (!loginUrl || !fingerprintHash || !expiresAt || !isWaitingForEnter) {
5356
return
5457
}
@@ -67,7 +70,7 @@ export function useLoginPolling({
6770
},
6871
{
6972
baseUrl: LOGIN_WEBSITE_URL,
70-
fingerprintId,
73+
fingerprintId: fingerprintId!,
7174
fingerprintHash,
7275
expiresAt,
7376
shouldContinue: () => active,

cli/src/init/init-app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { setProjectRoot } from '../project-files'
1313
import { initTimestampFormatter } from '../utils/helpers'
1414
import { enableManualThemeRefresh } from '../utils/theme-system'
1515
import { initAnalytics } from '../utils/analytics'
16+
import { getFingerprintId } from '../utils/fingerprint'
1617
import { initializeDirenv } from './init-direnv'
1718

1819
export async function initializeApp(params: { cwd?: string }): Promise<void> {
@@ -38,6 +39,10 @@ export async function initializeApp(params: { cwd?: string }): Promise<void> {
3839
enableManualThemeRefresh()
3940
initTimestampFormatter()
4041

42+
// Compute the hardware-based fingerprint in the background so it's ready
43+
// by the time the user finishes reading the login prompt.
44+
void getFingerprintId()
45+
4146
// Refresh Claude OAuth credentials in the background if they exist
4247
// This ensures the subscription status is up-to-date on startup
4348
if (CLAUDE_OAUTH_ENABLED) {

cli/src/login/plain-login.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { cyan, green, red, yellow, bold } from 'picocolors'
22

33
import { LOGIN_WEBSITE_URL } from './constants'
44
import { generateLoginUrl, pollLoginStatus } from './login-flow'
5-
import { generateFingerprintId } from './utils'
65
import { saveUserCredentials } from '../utils/auth'
76
import { IS_FREEBUFF } from '../utils/constants'
7+
import { getFingerprintId } from '../utils/fingerprint'
88
import { logger } from '../utils/logger'
99

1010
import type { User } from '../utils/auth'
@@ -18,7 +18,7 @@ import type { User } from '../utils/auth'
1818
* clipboard and browser integration don't work.
1919
*/
2020
export async function runPlainLogin(): Promise<void> {
21-
const fingerprintId = generateFingerprintId()
21+
const fingerprintId = await getFingerprintId()
2222

2323
console.log()
2424
console.log(bold(IS_FREEBUFF ? 'Freebuff Login' : 'Codebuff Login'))

cli/src/login/utils.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,6 @@ export function formatUrl(url: string, maxWidth?: number): string[] {
5454
return lines
5555
}
5656

57-
/**
58-
* Generates a unique fingerprint ID for CLI authentication
59-
*/
60-
export function generateFingerprintId(): string {
61-
return `codebuff-cli-${Math.random().toString(36).substring(2, 15)}`
62-
}
63-
6457
/**
6558
* Determines the color for a character based on its position relative to the sheen
6659
* Block characters use blockColor, shadow/border characters animate to accent green

cli/src/state/login-store.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type LoginStoreState = {
55
loginUrl: string | null
66
loading: boolean
77
error: string | null
8+
fingerprintId: string | null
89
fingerprintHash: string | null
910
expiresAt: string | null
1011
isWaitingForEnter: boolean
@@ -23,6 +24,9 @@ type LoginStoreActions = {
2324
setError: (
2425
value: string | null | ((prev: string | null) => string | null),
2526
) => void
27+
setFingerprintId: (
28+
value: string | null | ((prev: string | null) => string | null),
29+
) => void
2630
setFingerprintHash: (
2731
value: string | null | ((prev: string | null) => string | null),
2832
) => void
@@ -46,6 +50,7 @@ const initialState: LoginStoreState = {
4650
loginUrl: null,
4751
loading: false,
4852
error: null,
53+
fingerprintId: null,
4954
fingerprintHash: null,
5055
expiresAt: null,
5156
isWaitingForEnter: false,
@@ -76,6 +81,12 @@ export const useLoginStore = create<LoginStore>()(
7681
state.error = typeof value === 'function' ? value(state.error) : value
7782
}),
7883

84+
setFingerprintId: (value) =>
85+
set((state) => {
86+
state.fingerprintId =
87+
typeof value === 'function' ? value(state.fingerprintId) : value
88+
}),
89+
7990
setFingerprintHash: (value) =>
8091
set((state) => {
8192
state.fingerprintHash =
@@ -125,6 +136,7 @@ export const useLoginStore = create<LoginStore>()(
125136
state.loginUrl = initialState.loginUrl
126137
state.loading = initialState.loading
127138
state.error = initialState.error
139+
state.fingerprintId = initialState.fingerprintId
128140
state.fingerprintHash = initialState.fingerprintHash
129141
state.expiresAt = initialState.expiresAt
130142
state.isWaitingForEnter = initialState.isWaitingForEnter

cli/src/utils/fingerprint.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,16 @@ let machineIdModule: typeof import('node-machine-id') | null = null
2121
let systeminformationModule: typeof import('systeminformation') | null = null
2222

2323
async function getMachineId(): Promise<string> {
24-
try {
25-
if (!machineIdModule) {
26-
machineIdModule = await import('node-machine-id')
27-
}
28-
const id = await machineIdModule.machineId()
29-
// Validate that we got a real machine ID, not an empty or placeholder value
30-
if (!id || id === 'unknown' || id.length < 8) {
31-
throw new Error('Invalid machine ID returned')
32-
}
33-
return id
34-
} catch (error) {
35-
// Re-throw to signal that enhanced fingerprinting should fall back to legacy
36-
throw error
24+
if (!machineIdModule) {
25+
machineIdModule = await import('node-machine-id')
3726
}
27+
const id = await machineIdModule.machineId()
28+
// Validate that we got a real machine ID, not an empty or placeholder value.
29+
// Throwing here triggers the legacy fallback in calculateFingerprint().
30+
if (!id || id === 'unknown' || id.length < 8) {
31+
throw new Error('Invalid machine ID returned')
32+
}
33+
return id
3834
}
3935

4036
async function getSystemInfo(): Promise<{
@@ -141,6 +137,25 @@ function calculateLegacyFingerprint(): string {
141137
return `codebuff-cli-${randomSuffix}`
142138
}
143139

140+
/**
141+
* Cached fingerprint promise. Populated on first call and reused for the
142+
* process lifetime so every auth step in a session ships the same fingerprint
143+
* to the server.
144+
*/
145+
let cachedFingerprintPromise: Promise<string> | null = null
146+
147+
/**
148+
* Returns the process-wide CLI fingerprint, computing it on first call.
149+
* Safe to call from multiple places — the first caller wins and the rest
150+
* await the same promise.
151+
*/
152+
export function getFingerprintId(): Promise<string> {
153+
if (!cachedFingerprintPromise) {
154+
cachedFingerprintPromise = calculateFingerprint()
155+
}
156+
return cachedFingerprintPromise
157+
}
158+
144159
/**
145160
* Main fingerprint function.
146161
* Tries enhanced fingerprinting first, falls back to legacy if it fails.

0 commit comments

Comments
 (0)