Skip to content

Commit 41ef22b

Browse files
ThomasK33claude
andauthored
fix(lockfile): generate auth token from a CSPRNG and restrict lock-file permissions (#259)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 6de9b80 commit 41ef22b

7 files changed

Lines changed: 314 additions & 51 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ The `fixtures/` directory contains test Neovim configurations for verifying plug
8282

8383
The WebSocket server implements secure authentication using:
8484

85-
- **UUID v4 Tokens**: Generated per session with enhanced entropy
85+
- **128-bit Tokens**: 32-char lowercase hex from the OS CSPRNG, generated per session
8686
- **Header-based Auth**: Uses `x-claude-code-ide-authorization` header
8787
- **Lock File Discovery**: Tokens stored in `~/.claude/ide/[port].lock` for Claude CLI
8888
- **MCP Compliance**: Follows official Claude Code IDE authentication protocol
@@ -145,7 +145,7 @@ opts = {
145145

146146
-**WebSocket Server**: RFC 6455 compliant with MCP message format
147147
-**Tool Registration**: JSON Schema-based tool definitions
148-
-**Authentication**: UUID v4 token-based secure handshake
148+
-**Authentication**: 128-bit token-based secure handshake (32-char lowercase hex from the OS CSPRNG)
149149
-**Message Format**: JSON-RPC 2.0 with MCP content structure
150150
-**Error Handling**: Comprehensive JSON-RPC error responses
151151

@@ -212,7 +212,7 @@ mise run test # Recommended for complete validation
212212

213213
## Authentication Testing
214214

215-
The plugin implements authentication using UUID v4 tokens that are generated for each server session and stored in lock files. This ensures secure connections between Claude CLI and the Neovim WebSocket server.
215+
The plugin implements authentication using 128-bit tokens (32-char lowercase hex) from the OS CSPRNG that are generated for each server session and stored in lock files. This ensures secure connections between Claude CLI and the Neovim WebSocket server.
216216

217217
### Testing Authentication Features
218218

@@ -340,7 +340,7 @@ Log levels for authentication events:
340340
### Security Considerations
341341

342342
- WebSocket server only accepts local connections (127.0.0.1) for security
343-
- Authentication tokens are UUID v4 with enhanced entropy
343+
- Authentication tokens are 128-bit tokens (32-char lowercase hex) from the OS CSPRNG
344344
- Lock files created at `~/.claude/ide/[port].lock` for Claude CLI discovery
345345
- All authentication events are logged for security auditing
346346

PROTOCOL.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ The IDE writes a discovery file to `~/.claude/ide/[port].lock`:
2424
"workspaceFolders": ["/path/to/project"], // Open folders
2525
"ideName": "VS Code", // or "Neovim", "IntelliJ", etc.
2626
"transport": "ws", // WebSocket transport
27-
"authToken": "550e8400-e29b-41d4-a716-446655440000" // Random UUID for authentication
27+
"authToken": "a3f1c2d4e5f60718293a4b5c6d7e8f90" // 32-char lowercase hex token (128 bits) from the OS CSPRNG
2828
}
2929
```
3030

@@ -44,7 +44,7 @@ Claude reads the lock files, finds the matching port from the environment, and c
4444
When Claude connects to the IDE's WebSocket server, it must authenticate using the token from the lock file. The authentication happens via a custom WebSocket header:
4545

4646
```
47-
x-claude-code-ide-authorization: 550e8400-e29b-41d4-a716-446655440000
47+
x-claude-code-ide-authorization: a3f1c2d4e5f60718293a4b5c6d7e8f90
4848
```
4949

5050
The IDE validates this header against the `authToken` value from the lock file. If the token doesn't match, the connection is rejected.
@@ -514,7 +514,12 @@ local server = create_websocket_server("127.0.0.1", random_port)
514514

515515
```lua
516516
-- ~/.claude/ide/[port].lock
517-
local auth_token = generate_uuid() -- Generate random UUID
517+
-- Generate a 128-bit token (32-char lowercase hex) from the OS CSPRNG.
518+
-- Never use math.random for this; a weak token is worse than a startup error.
519+
local bytes = vim.loop.random(16) -- 16 secure random bytes
520+
local auth_token = bytes:gsub(".", function(c)
521+
return string.format("%02x", string.byte(c))
522+
end)
518523
local lock_data = {
519524
pid = vim.fn.getpid(),
520525
workspaceFolders = { vim.fn.getcwd() },

lua/claudecode/lockfile.lua

Lines changed: 89 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,45 +19,54 @@ end
1919

2020
M.lock_dir = get_lock_dir()
2121

22-
-- Track if random seed has been initialized
23-
local random_initialized = false
24-
25-
---Generate a random UUID for authentication
26-
---@return string uuid A randomly generated UUID string
27-
local function generate_auth_token()
28-
-- Initialize random seed only once
29-
if not random_initialized then
30-
local seed = os.time() + vim.fn.getpid()
31-
-- Add more entropy if available
32-
if vim.loop and vim.loop.hrtime then
33-
seed = seed + (vim.loop.hrtime() % 1000000)
22+
---Read n random bytes from a cryptographically secure source.
23+
---Tries libuv's OS CSPRNG first, then falls back to /dev/urandom.
24+
---Never falls back to math.random: a weak token is worse than a startup error.
25+
---@param n number The number of random bytes to read
26+
---@return string bytes A string of exactly n random bytes
27+
local function get_random_bytes(n)
28+
-- Prefer libuv's uv_random (OS CSPRNG). Use vim.loop.random (available on
29+
-- Neovim 0.8+) rather than vim.uv.random (only aliased on 0.10+).
30+
if vim.loop and vim.loop.random then
31+
local ok, bytes = pcall(vim.loop.random, n)
32+
if ok and type(bytes) == "string" and #bytes == n then
33+
return bytes
3434
end
35-
math.randomseed(seed)
35+
end
3636

37-
-- Call math.random a few times to "warm up" the generator
38-
for _ = 1, 10 do
39-
math.random()
37+
-- Fallback: read directly from the kernel CSPRNG.
38+
local file = io.open("/dev/urandom", "rb")
39+
if file then
40+
local bytes = file:read(n)
41+
file:close()
42+
if type(bytes) == "string" and #bytes == n then
43+
return bytes
4044
end
41-
random_initialized = true
4245
end
4346

44-
-- Generate UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
45-
local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
46-
local uuid = template:gsub("[xy]", function(c)
47-
local v = (c == "x") and math.random(0, 15) or math.random(8, 11)
48-
return string.format("%x", v)
47+
error("Failed to obtain " .. n .. " bytes of secure random data (no vim.loop.random or readable /dev/urandom)")
48+
end
49+
50+
---Generate a cryptographically secure authentication token.
51+
---@return string token A 32-character lowercase hex string (128 bits of entropy)
52+
local function generate_auth_token()
53+
local bytes = get_random_bytes(16)
54+
55+
-- Hex-encode the random bytes into a 32-character lowercase string.
56+
local token = bytes:gsub(".", function(c)
57+
return string.format("%02x", string.byte(c))
4958
end)
5059

51-
-- Validate generated UUID format
52-
if not uuid:match("^[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+$") then
53-
error("Generated invalid UUID format: " .. uuid)
60+
-- Sanity-check the generated token shape.
61+
if not token:match("^[0-9a-f]+$") then
62+
error("Generated invalid auth token format")
5463
end
5564

56-
if #uuid ~= 36 then
57-
error("Generated UUID has invalid length: " .. #uuid .. " (expected 36)")
65+
if #token < 16 then
66+
error("Generated auth token too short: " .. #token .. " (expected at least 16)")
5867
end
5968

60-
return uuid
69+
return token
6170
end
6271

6372
---Generate a new authentication token
@@ -82,13 +91,18 @@ function M.create(port, auth_token)
8291
end
8392

8493
local ok, err = pcall(function()
85-
return vim.fn.mkdir(M.lock_dir, "p")
94+
return vim.fn.mkdir(M.lock_dir, "p", tonumber("700", 8))
8695
end)
8796

8897
if not ok then
8998
return false, "Failed to create lock directory: " .. (err or "unknown error")
9099
end
91100

101+
-- mkdir's mode argument only applies to newly created directories; a lock dir
102+
-- left over from an earlier version may still be 0755. Tighten it to 0700.
103+
-- pcall-wrapped because fs_chmod is unsupported on some non-POSIX platforms.
104+
pcall(vim.loop.fs_chmod, M.lock_dir, tonumber("700", 8))
105+
92106
local lock_path = M.lock_dir .. "/" .. port .. ".lock"
93107

94108
local workspace_folders = M.get_workspace_folders()
@@ -130,23 +144,60 @@ function M.create(port, auth_token)
130144
return false, "Failed to encode lock file content: " .. (json_err or "unknown error")
131145
end
132146

133-
local file = io.open(lock_path, "w")
134-
if not file then
135-
return false, "Failed to create lock file: " .. lock_path
136-
end
147+
-- Write atomically with restrictive (0600) permissions: write to a temp file
148+
-- in the same directory, then rename into place. Using "wx" (O_CREAT|O_EXCL)
149+
-- refuses to follow an existing file or symlink at the temp path.
150+
-- Include hrtime() so the temp path stays unique even after a crash where the
151+
-- PID is reused for the same port (otherwise "wx" would fail EEXIST).
152+
-- string.format keeps hrtime() (a double on LuaJIT) as a plain integer rather
153+
-- than scientific notation (e.g. 1.75e+15), which would corrupt the path.
154+
local tmp_path = lock_path .. ".tmp." .. vim.fn.getpid() .. "." .. string.format("%d", vim.loop.hrtime())
137155

138156
local write_ok, write_err = pcall(function()
139-
file:write(json)
140-
file:close()
157+
local fd = vim.loop.fs_open(tmp_path, "wx", tonumber("600", 8))
158+
if not fd then
159+
error("could not open temp file: " .. tmp_path)
160+
end
161+
162+
local close_and_raise = function(message)
163+
pcall(vim.loop.fs_close, fd)
164+
error(message)
165+
end
166+
167+
-- fs_write may write fewer bytes than requested (quota/disk-full/odd FS),
168+
-- so loop on the returned count and an offset until all bytes are written.
169+
-- Otherwise a truncated lock file could be renamed into place.
170+
local pos = 0
171+
while pos < #json do
172+
local ok_write, written = pcall(vim.loop.fs_write, fd, json:sub(pos + 1), pos)
173+
if not ok_write then
174+
close_and_raise("could not write temp file: " .. tostring(written))
175+
end
176+
if type(written) ~= "number" or written <= 0 then
177+
close_and_raise("could not write temp file: short write at offset " .. pos .. "/" .. #json)
178+
end
179+
pos = pos + written
180+
end
181+
182+
-- fs_close returns (nil, err) on libuv failure without raising, so check
183+
-- both the pcall status and the libuv result before renaming.
184+
local ok_close, close_result = pcall(vim.loop.fs_close, fd)
185+
if not ok_close or not close_result then
186+
error("could not close temp file: " .. tmp_path)
187+
end
141188
end)
142189

143190
if not write_ok then
144-
pcall(function()
145-
file:close()
146-
end)
191+
pcall(vim.loop.fs_unlink, tmp_path)
147192
return false, "Failed to write lock file: " .. (write_err or "unknown error")
148193
end
149194

195+
local rename_ok, rename_err = os.rename(tmp_path, lock_path)
196+
if not rename_ok then
197+
pcall(vim.loop.fs_unlink, tmp_path)
198+
return false, "Failed to write lock file: " .. (rename_err or "rename failed")
199+
end
200+
150201
return true, lock_path, auth_token
151202
end
152203

lua/claudecode/server/handshake.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function M.validate_upgrade_request(request, expected_auth_token)
6060
return false, "Authentication token too short (min 10 characters)"
6161
end
6262

63-
if auth_header ~= expected_auth_token then
63+
if not utils.constant_time_compare(auth_header, expected_auth_token) then
6464
return false, "Invalid authentication token"
6565
end
6666
end

lua/claudecode/server/utils.lua

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,14 +407,52 @@ function M.apply_mask(data, mask)
407407
return table.concat(result)
408408
end
409409

410+
local rng_seeded = false
411+
410412
---Shuffle an array in place using Fisher-Yates algorithm
411413
---@param tbl table The array to shuffle
412414
function M.shuffle_array(tbl)
413-
math.randomseed(os.time())
415+
-- Seed the PRNG once per process so port selection order varies across editor
416+
-- starts. Seeding lazily on first use (rather than on every call, as a prior
417+
-- version did with os.time()) avoids identical orderings within the same
418+
-- second while still giving each process a distinct sequence.
419+
if not rng_seeded then
420+
math.randomseed(os.time())
421+
rng_seeded = true
422+
end
414423
for i = #tbl, 2, -1 do
415424
local j = math.random(i)
416425
tbl[i], tbl[j] = tbl[j], tbl[i]
417426
end
418427
end
419428

429+
---Compare two strings in constant time relative to their length.
430+
---Returns false immediately on a length mismatch; otherwise every byte is
431+
---examined so total work does not depend on the matching-prefix length.
432+
---@param a string First string
433+
---@param b string Second string
434+
---@return boolean equal True if the strings are byte-for-byte equal
435+
function M.constant_time_compare(a, b)
436+
if type(a) ~= "string" or type(b) ~= "string" then
437+
return false
438+
end
439+
440+
if #a ~= #b then
441+
return false
442+
end
443+
444+
-- Accumulate a value that is non-zero iff any byte differs, doing identical,
445+
-- branchless work per byte (subtract + multiply + add). This keeps the timing
446+
-- independent of the matching-prefix length without the bit module or the
447+
-- file-local arithmetic-emulated bor/bxor, whose loop counts depend on operand
448+
-- magnitude and would themselves reintroduce prefix-length timing leakage.
449+
local diff = 0
450+
for i = 1, #a do
451+
local d = a:byte(i) - b:byte(i)
452+
diff = diff + d * d
453+
end
454+
455+
return diff == 0
456+
end
457+
420458
return M

0 commit comments

Comments
 (0)