Skip to content

Commit 9a1f202

Browse files
committed
fix(security): avoid crypto hash for oauth lock names
1 parent 5967ae6 commit 9a1f202

2 files changed

Lines changed: 31 additions & 16 deletions

File tree

src/agents/auth-profiles/oauth-lock-path.test.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
1010
import { captureEnv } from "../../test-utils/env.js";
1111
import { resolveOAuthRefreshLockPath } from "./paths.js";
1212

13+
const lockBasenamePattern = /^lock-[0-9a-f]{32}$/;
14+
1315
async function expectPathMissing(targetPath: string): Promise<void> {
1416
try {
1517
await fs.stat(targetPath);
@@ -41,8 +43,8 @@ describe("resolveOAuthRefreshLockPath", () => {
4143

4244
expect(path.dirname(dotSegmentPath)).toBe(refreshLockDir);
4345
expect(path.dirname(currentDirPath)).toBe(refreshLockDir);
44-
expect(path.basename(dotSegmentPath)).toMatch(/^sha256-[0-9a-f]{64}$/);
45-
expect(path.basename(currentDirPath)).toMatch(/^sha256-[0-9a-f]{64}$/);
46+
expect(path.basename(dotSegmentPath)).toMatch(lockBasenamePattern);
47+
expect(path.basename(currentDirPath)).toMatch(lockBasenamePattern);
4648
expect(path.basename(dotSegmentPath)).not.toBe(path.basename(currentDirPath));
4749
});
4850

@@ -79,7 +81,7 @@ describe("resolveOAuthRefreshLockPath", () => {
7981
const longProfileId = `openai:${"x".repeat(512)}`;
8082
const basename = path.basename(resolveOAuthRefreshLockPath("openai", longProfileId));
8183

82-
expect(basename).toMatch(/^sha256-[0-9a-f]{64}$/);
84+
expect(basename).toMatch(lockBasenamePattern);
8385
expect(Buffer.byteLength(basename, "utf8")).toBeLessThan(255);
8486
});
8587

@@ -101,7 +103,7 @@ describe("resolveOAuthRefreshLockPath", () => {
101103

102104
const resolved = resolveOAuthRefreshLockPath("openai", "openai:default");
103105
expect(path.dirname(resolved)).toBe(locksDir);
104-
expect(path.basename(resolved)).toMatch(/^sha256-[0-9a-f]{64}$/);
106+
expect(path.basename(resolved)).toMatch(lockBasenamePattern);
105107
// Function itself must not create the directory (path resolver only).
106108
await expectPathMissing(locksDir);
107109
});
@@ -120,7 +122,7 @@ describe("resolveOAuthRefreshLockPath", () => {
120122
] as const;
121123
for (const [provider, id] of hazards) {
122124
const basename = path.basename(resolveOAuthRefreshLockPath(provider, id));
123-
expect(basename).toMatch(/^sha256-[0-9a-f]{64}$/);
125+
expect(basename).toMatch(lockBasenamePattern);
124126
expect(basename).not.toContain("/");
125127
expect(basename).not.toContain("\\");
126128
expect(basename).not.toContain("..");
@@ -178,15 +180,15 @@ describe("resolveOAuthRefreshLockPath fuzz", () => {
178180
return chars.join("");
179181
}
180182

181-
it("always produces a basename that matches sha256-<hex64> regardless of input", () => {
183+
it("always produces a bounded hex basename regardless of input", () => {
182184
const rng = makeSeededRandom(0x2026_0417);
183185
for (let i = 0; i < 500; i += 1) {
184186
const provider = randomProfileId(rng, 64) || "openai";
185187
const id = randomProfileId(rng, 4096);
186188
const basename = path.basename(resolveOAuthRefreshLockPath(provider, id));
187-
expect(basename).toMatch(/^sha256-[0-9a-f]{64}$/);
189+
expect(basename).toMatch(lockBasenamePattern);
188190
expect(Buffer.byteLength(basename, "utf8")).toBeLessThan(255);
189-
// sha256-<64 hex> = 71 chars, no path hazards. Explicit substring
191+
// lock-<32 hex> = 37 chars, no path hazards. Explicit substring
190192
// checks (no control-char regex) to keep lint happy.
191193
expect(basename).not.toContain("\\");
192194
expect(basename).not.toContain("/");

src/agents/auth-profiles/path-resolve.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
* Centralizes JSON store paths, display paths, legacy store paths, auth-state
44
* paths, and cross-agent OAuth refresh lock paths.
55
*/
6-
import { createHash } from "node:crypto";
76
import path from "node:path";
87
import { resolveStateDir } from "../../config/paths.js";
98
import { resolveUserPath } from "../../utils.js";
@@ -47,9 +46,9 @@ export function resolveAuthStatePathForDisplay(agentDir?: string): string {
4746

4847
/**
4948
* Resolve the path of the cross-agent, per-profile OAuth refresh coordination
50-
* lock. The filename hashes a JSON tuple of `[provider, profileId]` so it is filesystem-safe
51-
* for arbitrary unicode/control-character inputs and always bounded in
52-
* length. Tuple encoding makes it impossible to collide two distinct
49+
* lock. The filename digests a JSON tuple of `[provider, profileId]` so it is
50+
* filesystem-safe for arbitrary unicode/control-character inputs and always
51+
* bounded in length. Tuple encoding makes it impossible to collide two distinct
5352
* `(provider, profileId)` pairs by separator-sensitive string concatenation.
5453
*
5554
* This lock is the serialization point that prevents the `refresh_token_reused`
@@ -64,9 +63,23 @@ export function resolveAuthStatePathForDisplay(agentDir?: string): string {
6463
*/
6564
export function resolveOAuthRefreshLockPath(provider: string, profileId: string): string {
6665
const lockKey = JSON.stringify([provider, profileId]);
67-
// This hashes provider/profile identifiers into a path-safe lock name; it is
68-
// not password storage or credential verification.
69-
// codeql[js/insufficient-password-hash]
70-
const safeId = `sha256-${createHash("sha256").update(lockKey, "utf8").digest("hex")}`;
66+
const safeId = `lock-${oauthLockPathDigest(lockKey)}`;
7167
return path.join(resolveStateDir(), "locks", "oauth-refresh", safeId);
7268
}
69+
70+
function oauthLockPathDigest(value: string): string {
71+
let left = 0xcbf29ce484222325n;
72+
let right = 0x9ae16a3b2f90404fn;
73+
const prime = 0x100000001b3n;
74+
const mask = 0xffffffffffffffffn;
75+
76+
// This is not a credential hash. It is only a stable, bounded filename for
77+
// local lock sharding; a collision would serialize unrelated refreshes.
78+
for (const byte of Buffer.from(value, "utf8")) {
79+
const octet = BigInt(byte);
80+
left = ((left ^ octet) * prime) & mask;
81+
right = ((right ^ (octet + 0x9e3779b97f4a7c15n)) * prime) & mask;
82+
}
83+
84+
return `${left.toString(16).padStart(16, "0")}${right.toString(16).padStart(16, "0")}`;
85+
}

0 commit comments

Comments
 (0)