@quenty/nevermore-cli-helpers 1.8.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -47
- package/dist/auth/cookie/cookie-parser.d.ts +7 -0
- package/dist/auth/cookie/cookie-parser.d.ts.map +1 -0
- package/dist/auth/cookie/cookie-parser.js +18 -0
- package/dist/auth/cookie/cookie-parser.js.map +1 -0
- package/dist/auth/cookie/cookie-parser.test.d.ts +2 -0
- package/dist/auth/cookie/cookie-parser.test.d.ts.map +1 -0
- package/dist/auth/cookie/cookie-parser.test.js +32 -0
- package/dist/auth/cookie/cookie-parser.test.js.map +1 -0
- package/dist/auth/cookie/index.d.ts +41 -0
- package/dist/auth/cookie/index.d.ts.map +1 -0
- package/dist/auth/cookie/index.js +188 -0
- package/dist/auth/cookie/index.js.map +1 -0
- package/dist/auth/cookie/linux.d.ts +14 -0
- package/dist/auth/cookie/linux.d.ts.map +1 -0
- package/dist/auth/cookie/linux.js +147 -0
- package/dist/auth/cookie/linux.js.map +1 -0
- package/dist/auth/cookie/macos.d.ts +2 -0
- package/dist/auth/cookie/macos.d.ts.map +1 -0
- package/dist/auth/cookie/macos.js +66 -0
- package/dist/auth/cookie/macos.js.map +1 -0
- package/dist/auth/cookie/validate-cookie.test.d.ts +2 -0
- package/dist/auth/cookie/validate-cookie.test.d.ts.map +1 -0
- package/dist/auth/cookie/validate-cookie.test.js +27 -0
- package/dist/auth/cookie/validate-cookie.test.js.map +1 -0
- package/dist/auth/cookie/windows.d.ts +2 -0
- package/dist/auth/cookie/windows.d.ts.map +1 -0
- package/dist/auth/cookie/windows.js +76 -0
- package/dist/auth/cookie/windows.js.map +1 -0
- package/dist/auth/open-cloud/credential-store.d.ts +14 -0
- package/dist/auth/open-cloud/credential-store.d.ts.map +1 -0
- package/dist/auth/open-cloud/credential-store.js +108 -0
- package/dist/auth/open-cloud/credential-store.js.map +1 -0
- package/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +3 -0
- package/dist/utils.js.map +1 -1
- package/dist/version-checker.d.ts +2 -0
- package/dist/version-checker.d.ts.map +1 -1
- package/dist/version-checker.js +22 -20
- package/dist/version-checker.js.map +1 -1
- package/package.json +8 -4
- package/src/auth/cookie/cookie-parser.test.ts +38 -0
- package/src/auth/cookie/cookie-parser.ts +18 -0
- package/src/auth/cookie/index.ts +271 -0
- package/src/auth/cookie/linux.ts +175 -0
- package/src/auth/cookie/macos.ts +88 -0
- package/src/auth/cookie/validate-cookie.test.ts +39 -0
- package/src/auth/cookie/windows.ts +96 -0
- package/src/auth/open-cloud/credential-store.ts +149 -0
- package/src/utils.ts +25 -0
- package/src/version-checker.ts +28 -24
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie auth and place creation for Roblox legacy APIs.
|
|
3
|
+
* Based on Mantle: https://github.com/blake-mealey/mantle
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import inquirer from 'inquirer';
|
|
7
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
8
|
+
import { COOKIE_NAME } from './cookie-parser.js';
|
|
9
|
+
import { readCookie as readWindowsCookie } from './windows.js';
|
|
10
|
+
import { readCookie as readMacOSCookie } from './macos.js';
|
|
11
|
+
import { readCookie as readLinuxCookie } from './linux.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the .ROBLOSECURITY cookie for legacy Roblox API calls.
|
|
15
|
+
*
|
|
16
|
+
* Resolution order (matching Mantle's rbx_cookie crate):
|
|
17
|
+
* 1. ROBLOSECURITY environment variable
|
|
18
|
+
* 2. Platform credential store (Windows Credential Manager / macOS HTTPStorages / Wine Credential Manager)
|
|
19
|
+
* 3. Platform legacy store (Windows Registry / macOS plist)
|
|
20
|
+
* 4. Interactive prompt
|
|
21
|
+
*/
|
|
22
|
+
export async function getRobloxCookieAsync(): Promise<string> {
|
|
23
|
+
const envCookie = process.env.ROBLOSECURITY;
|
|
24
|
+
if (envCookie) {
|
|
25
|
+
return envCookie;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const platformCookie = readPlatformCookie();
|
|
29
|
+
if (platformCookie) {
|
|
30
|
+
return platformCookie;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// No interactive prompt in non-TTY environments (CI)
|
|
34
|
+
if (!process.stdin.isTTY) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'No .ROBLOSECURITY cookie available (set ROBLOSECURITY env var for CI)'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { cookie } = await inquirer.prompt([
|
|
41
|
+
{
|
|
42
|
+
type: 'password',
|
|
43
|
+
name: 'cookie',
|
|
44
|
+
message: 'Enter your .ROBLOSECURITY cookie (from browser or Studio):',
|
|
45
|
+
mask: '*',
|
|
46
|
+
validate: (input: string) => input.length > 0 || 'Cookie cannot be empty',
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
return cookie;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readPlatformCookie(): string | undefined {
|
|
54
|
+
switch (process.platform) {
|
|
55
|
+
case 'win32':
|
|
56
|
+
return readWindowsCookie();
|
|
57
|
+
case 'darwin':
|
|
58
|
+
return readMacOSCookie();
|
|
59
|
+
case 'linux':
|
|
60
|
+
return readLinuxCookie();
|
|
61
|
+
default:
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface CsrfFetchResult {
|
|
67
|
+
response: Response;
|
|
68
|
+
rotatedCookie?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract a rotated .ROBLOSECURITY cookie from a response's set-cookie header.
|
|
73
|
+
*/
|
|
74
|
+
function extractRotatedCookie(response: Response): string | undefined {
|
|
75
|
+
const setCookie = response.headers.get('set-cookie');
|
|
76
|
+
if (!setCookie) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// set-cookie may contain multiple cookies separated by commas (or multiple headers).
|
|
81
|
+
// Look for .ROBLOSECURITY=<value>.
|
|
82
|
+
const match = setCookie.match(/\.ROBLOSECURITY=([^;,\s]+)/);
|
|
83
|
+
return match?.[1];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Make a cookie-authenticated request to Roblox, handling CSRF token exchange,
|
|
88
|
+
* cookie rotation capture, and 429 rate-limit retries.
|
|
89
|
+
*/
|
|
90
|
+
async function fetchWithCsrfAsync(
|
|
91
|
+
url: string,
|
|
92
|
+
cookie: string,
|
|
93
|
+
options: RequestInit = {}
|
|
94
|
+
): Promise<CsrfFetchResult> {
|
|
95
|
+
const headers: Record<string, string> = {
|
|
96
|
+
Cookie: `${COOKIE_NAME}=${cookie}`,
|
|
97
|
+
'User-Agent': 'Roblox/WinInet',
|
|
98
|
+
...(options.headers as Record<string, string> | undefined),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let response = await fetch(url, {
|
|
102
|
+
...options,
|
|
103
|
+
headers,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (response.status === 403) {
|
|
107
|
+
const csrfToken = response.headers.get('x-csrf-token');
|
|
108
|
+
if (csrfToken) {
|
|
109
|
+
headers['X-CSRF-TOKEN'] = csrfToken;
|
|
110
|
+
response = await fetch(url, {
|
|
111
|
+
...options,
|
|
112
|
+
headers,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Retry once on rate limit
|
|
118
|
+
if (response.status === 429) {
|
|
119
|
+
const retryAfter = response.headers.get('retry-after');
|
|
120
|
+
const delaySec = retryAfter
|
|
121
|
+
? Math.min(parseInt(retryAfter, 10) || 2, 30)
|
|
122
|
+
: 2;
|
|
123
|
+
OutputHelper.verbose(`Rate limited (429). Retrying after ${delaySec}s...`);
|
|
124
|
+
await new Promise((resolve) => setTimeout(resolve, delaySec * 1000));
|
|
125
|
+
response = await fetch(url, { ...options, headers });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const rotatedCookie = extractRotatedCookie(response);
|
|
129
|
+
if (rotatedCookie) {
|
|
130
|
+
OutputHelper.verbose(
|
|
131
|
+
'Captured rotated .ROBLOSECURITY cookie from response.'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { response, rotatedCookie };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a new place in a universe using the legacy cookie-authenticated API.
|
|
140
|
+
* Returns the new place ID.
|
|
141
|
+
*/
|
|
142
|
+
export async function createPlaceInUniverseAsync(
|
|
143
|
+
cookie: string,
|
|
144
|
+
universeId: number,
|
|
145
|
+
placeName: string
|
|
146
|
+
): Promise<number> {
|
|
147
|
+
OutputHelper.verbose(
|
|
148
|
+
`Creating place "${placeName}" in universe ${universeId}...`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const createResult = await fetchWithCsrfAsync(
|
|
152
|
+
`https://apis.roblox.com/universes/v1/user/universes/${universeId}/places`,
|
|
153
|
+
cookie,
|
|
154
|
+
{
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: {
|
|
157
|
+
'Content-Type': 'application/json',
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({ templatePlaceId: 95206881 }),
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (!createResult.response.ok) {
|
|
164
|
+
const text = await createResult.response.text();
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Failed to create place: ${createResult.response.status} ${createResult.response.statusText}: ${text}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const createData = (await createResult.response.json()) as {
|
|
171
|
+
placeId: number;
|
|
172
|
+
};
|
|
173
|
+
const placeId = createData.placeId;
|
|
174
|
+
|
|
175
|
+
// Use rotated cookie if the create request triggered rotation
|
|
176
|
+
const { response: renameResponse } = await fetchWithCsrfAsync(
|
|
177
|
+
`https://develop.roblox.com/v2/places/${placeId}`,
|
|
178
|
+
createResult.rotatedCookie ?? cookie,
|
|
179
|
+
{
|
|
180
|
+
method: 'PATCH',
|
|
181
|
+
headers: {
|
|
182
|
+
'Content-Type': 'application/json',
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify({ name: placeName }),
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (!renameResponse.ok) {
|
|
189
|
+
OutputHelper.warn(
|
|
190
|
+
`Place created (${placeId}) but rename failed — you can rename it manually.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
OutputHelper.verbose(`Created place "${placeName}" — ID: ${placeId}`);
|
|
195
|
+
return placeId;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface CookieValidationResult {
|
|
199
|
+
valid: boolean;
|
|
200
|
+
reason?: 'invalid' | 'network_error';
|
|
201
|
+
status?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validates the ROBLOSECURITY cookie against the Roblox API.
|
|
206
|
+
* Returns a result indicating whether the cookie is valid.
|
|
207
|
+
* Network errors are treated as "unknown" (not invalid) so callers
|
|
208
|
+
* can decide whether to continue in offline scenarios.
|
|
209
|
+
*/
|
|
210
|
+
export async function validateCookieAsync(
|
|
211
|
+
cookie: string
|
|
212
|
+
): Promise<CookieValidationResult> {
|
|
213
|
+
try {
|
|
214
|
+
const response = await fetch(
|
|
215
|
+
'https://users.roblox.com/v1/users/authenticated',
|
|
216
|
+
{
|
|
217
|
+
headers: {
|
|
218
|
+
Cookie: `.ROBLOSECURITY=${cookie}`,
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (response.status !== 200) {
|
|
224
|
+
return { valid: false, reason: 'invalid', status: response.status };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { valid: true };
|
|
228
|
+
} catch {
|
|
229
|
+
return { valid: false, reason: 'network_error' };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface RenamePlaceResult {
|
|
234
|
+
success: boolean;
|
|
235
|
+
reason?: 'no_cookie' | 'api_error';
|
|
236
|
+
status?: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Try to rename an existing place via the develop.roblox.com API.
|
|
241
|
+
*/
|
|
242
|
+
export async function tryRenamePlaceAsync(
|
|
243
|
+
placeId: number,
|
|
244
|
+
placeName: string
|
|
245
|
+
): Promise<RenamePlaceResult> {
|
|
246
|
+
let cookie: string;
|
|
247
|
+
try {
|
|
248
|
+
cookie = await getRobloxCookieAsync();
|
|
249
|
+
} catch {
|
|
250
|
+
return { success: false, reason: 'no_cookie' };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const { response } = await fetchWithCsrfAsync(
|
|
254
|
+
`https://develop.roblox.com/v2/places/${placeId}`,
|
|
255
|
+
cookie,
|
|
256
|
+
{
|
|
257
|
+
method: 'PATCH',
|
|
258
|
+
headers: {
|
|
259
|
+
'Content-Type': 'application/json',
|
|
260
|
+
},
|
|
261
|
+
body: JSON.stringify({ name: placeName }),
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
if (response.ok) {
|
|
266
|
+
OutputHelper.verbose(`Renamed place ${placeId} to "${placeName}"`);
|
|
267
|
+
return { success: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { success: false, reason: 'api_error', status: response.status };
|
|
271
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
5
|
+
import { COOKIE_NAME } from './cookie-parser.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read .ROBLOSECURITY from Wine's Credential Manager stored in the Wine
|
|
9
|
+
* registry file ($WINEPREFIX/user.reg).
|
|
10
|
+
*
|
|
11
|
+
* Wine stores Windows Credential Manager entries as registry keys under
|
|
12
|
+
* [Software\\Wine\\Credential Manager]. Each credential target becomes a
|
|
13
|
+
* subkey with hex-encoded blob values.
|
|
14
|
+
*
|
|
15
|
+
* Resolution order (mirrors windows.ts):
|
|
16
|
+
* 1. Modern: user-specific credential (RobloxStudioAuth.ROBLOSECURITY{userId})
|
|
17
|
+
* 2. Legacy: RobloxStudioAuth.ROBLOSECURITY (no user suffix)
|
|
18
|
+
*/
|
|
19
|
+
export function readCookie(): string | undefined {
|
|
20
|
+
const userReg = getWineUserRegPath();
|
|
21
|
+
if (!userReg || !fs.existsSync(userReg)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let regContent: string;
|
|
26
|
+
try {
|
|
27
|
+
regContent = fs.readFileSync(userReg, 'utf-8');
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const credentials = parseWineCredentials(regContent);
|
|
33
|
+
|
|
34
|
+
// Modern: user-specific credential
|
|
35
|
+
const userId = credentials.get(
|
|
36
|
+
'https://www.roblox.com:RobloxStudioAuthuserid'
|
|
37
|
+
);
|
|
38
|
+
if (userId) {
|
|
39
|
+
const cookie = credentials.get(
|
|
40
|
+
`https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}${userId}`
|
|
41
|
+
);
|
|
42
|
+
if (cookie) {
|
|
43
|
+
OutputHelper.verbose(
|
|
44
|
+
`Loaded cookie from Wine Credential Manager (user ${userId}).`
|
|
45
|
+
);
|
|
46
|
+
return cookie;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Legacy: no user suffix
|
|
51
|
+
const legacyCookie = credentials.get(
|
|
52
|
+
`https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}`
|
|
53
|
+
);
|
|
54
|
+
if (legacyCookie) {
|
|
55
|
+
OutputHelper.verbose(
|
|
56
|
+
'Loaded cookie from Wine Credential Manager (legacy).'
|
|
57
|
+
);
|
|
58
|
+
return legacyCookie;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getWineUserRegPath(): string | undefined {
|
|
65
|
+
const wineprefix = process.env.WINEPREFIX || path.join(os.homedir(), '.wine');
|
|
66
|
+
return path.join(wineprefix, 'user.reg');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse Wine's user.reg file for Credential Manager entries.
|
|
71
|
+
*
|
|
72
|
+
* Wine stores credentials under registry keys like:
|
|
73
|
+
* [Software\\Wine\\Credential Manager]
|
|
74
|
+
*
|
|
75
|
+
* Each credential is a named value where the name is the target and the
|
|
76
|
+
* value is a hex-encoded binary blob. The credential blob (the actual
|
|
77
|
+
* secret) is stored as UTF-8 bytes within the binary structure.
|
|
78
|
+
*
|
|
79
|
+
* Returns a Map of target name -> credential value (decoded string).
|
|
80
|
+
*/
|
|
81
|
+
function parseWineCredentials(regContent: string): Map<string, string> {
|
|
82
|
+
const credentials = new Map<string, string>();
|
|
83
|
+
|
|
84
|
+
// Wine Credential Manager stores creds as individual hex blobs under
|
|
85
|
+
// [Software\\Wine\\Credential Manager]. The key format is:
|
|
86
|
+
// "Target Name"=hex:xx,xx,xx,...
|
|
87
|
+
// The hex blob is a serialized CREDENTIAL struct.
|
|
88
|
+
const credSectionMatch = regContent.match(
|
|
89
|
+
/\[Software\\\\Wine\\\\Credential Manager\]([\s\S]*?)(?=\n\[|$)/i
|
|
90
|
+
);
|
|
91
|
+
if (!credSectionMatch) {
|
|
92
|
+
return credentials;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const section = credSectionMatch[1];
|
|
96
|
+
|
|
97
|
+
// Match each credential entry: "TargetName"=hex:bytes
|
|
98
|
+
const entryRegex = /^"(.+?)"=hex:(.+)$/gm;
|
|
99
|
+
let match;
|
|
100
|
+
while ((match = entryRegex.exec(section)) !== null) {
|
|
101
|
+
const targetName = unescapeRegString(match[1]);
|
|
102
|
+
const hexStr = match[2].replace(/\\\n\s*/g, '').replace(/,/g, '');
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const blob = Buffer.from(hexStr, 'hex');
|
|
106
|
+
const value = extractCredentialBlob(blob);
|
|
107
|
+
if (value) {
|
|
108
|
+
credentials.set(targetName, value);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Malformed hex data
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return credentials;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extract the credential value from a Wine serialized CREDENTIAL blob.
|
|
120
|
+
*
|
|
121
|
+
* The blob layout follows the Windows CREDENTIAL struct. The credential
|
|
122
|
+
* value (CredentialBlob) is stored as UTF-8 bytes. We look for the
|
|
123
|
+
* actual cookie/value content by searching for known patterns.
|
|
124
|
+
*/
|
|
125
|
+
function extractCredentialBlob(blob: Buffer): string | undefined {
|
|
126
|
+
// Wine's serialized credential format stores the blob data inline.
|
|
127
|
+
// The simplest approach: the credential value for Roblox entries is
|
|
128
|
+
// plain UTF-8 text. Try to find it by looking for cookie-like content
|
|
129
|
+
// or numeric user IDs.
|
|
130
|
+
|
|
131
|
+
// Try interpreting the entire blob as UTF-8 and looking for the value
|
|
132
|
+
const text = blob.toString('utf-8');
|
|
133
|
+
|
|
134
|
+
// For simple values (user IDs, cookie names), the blob may just be
|
|
135
|
+
// the raw UTF-8 string
|
|
136
|
+
if (text && isPrintableAscii(text)) {
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// For structured blobs, search for the credential data section.
|
|
141
|
+
// Wine writes a serialized struct — scan for the longest printable
|
|
142
|
+
// ASCII substring that looks like a credential value.
|
|
143
|
+
let best = '';
|
|
144
|
+
let current = '';
|
|
145
|
+
for (let i = 0; i < blob.length; i++) {
|
|
146
|
+
const byte = blob[i];
|
|
147
|
+
if (byte >= 0x20 && byte < 0x7f) {
|
|
148
|
+
current += String.fromCharCode(byte);
|
|
149
|
+
} else {
|
|
150
|
+
if (current.length > best.length) {
|
|
151
|
+
best = current;
|
|
152
|
+
}
|
|
153
|
+
current = '';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (current.length > best.length) {
|
|
157
|
+
best = current;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return best.length > 0 ? best : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isPrintableAscii(str: string): boolean {
|
|
164
|
+
for (let i = 0; i < str.length; i++) {
|
|
165
|
+
const code = str.charCodeAt(i);
|
|
166
|
+
if (code < 0x20 || code > 0x7e) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return str.length > 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function unescapeRegString(str: string): string {
|
|
174
|
+
return str.replace(/\\\\/g, '\\');
|
|
175
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read from ~/Library/HTTPStorages/com.Roblox.RobloxStudio.binarycookies.
|
|
9
|
+
* This is a binary format — we shell out to Python to parse it since Node
|
|
10
|
+
* doesn't have a native binarycookies parser.
|
|
11
|
+
*/
|
|
12
|
+
function readFromHTTPStorages(): string | undefined {
|
|
13
|
+
const cookiePath = path.join(
|
|
14
|
+
os.homedir(),
|
|
15
|
+
'Library/HTTPStorages/com.Roblox.RobloxStudio.binarycookies'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (!fs.existsSync(cookiePath)) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pyScript = `
|
|
23
|
+
import struct, sys
|
|
24
|
+
with open(sys.argv[1], 'rb') as f:
|
|
25
|
+
data = f.read()
|
|
26
|
+
idx = data.find(b'_|WARNING')
|
|
27
|
+
if idx >= 0:
|
|
28
|
+
end = data.find(b'\\x00', idx)
|
|
29
|
+
if end < 0: end = len(data)
|
|
30
|
+
print(data[idx:end].decode('utf-8', errors='ignore'))
|
|
31
|
+
`.trim();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const result = execSync(
|
|
35
|
+
`python3 -c ${JSON.stringify(pyScript)} ${JSON.stringify(cookiePath)}`,
|
|
36
|
+
{ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
37
|
+
).trim();
|
|
38
|
+
|
|
39
|
+
if (result && result.startsWith('_|')) {
|
|
40
|
+
OutputHelper.verbose('Loaded cookie from macOS HTTPStorages.');
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Python parse failed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readFromPlist(): string | undefined {
|
|
51
|
+
const plistPath = path.join(
|
|
52
|
+
os.homedir(),
|
|
53
|
+
'Library/Preferences/com.roblox.RobloxStudioBrowser.plist'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!fs.existsSync(plistPath)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const result = execSync(
|
|
62
|
+
`defaults read com.roblox.RobloxStudioBrowser 2>/dev/null | grep ROBLOSECURITY`,
|
|
63
|
+
{ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
64
|
+
).trim();
|
|
65
|
+
|
|
66
|
+
if (result) {
|
|
67
|
+
const cookieMatch = result.match(/COOK::<(.+?)>/);
|
|
68
|
+
if (cookieMatch) {
|
|
69
|
+
OutputHelper.verbose('Loaded cookie from macOS plist.');
|
|
70
|
+
return cookieMatch[1];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const valueMatch = result.match(/"([^"]*_\|[^"]*)"/);
|
|
74
|
+
if (valueMatch) {
|
|
75
|
+
OutputHelper.verbose('Loaded cookie from macOS plist.');
|
|
76
|
+
return valueMatch[1];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// plist read failed
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readCookie(): string | undefined {
|
|
87
|
+
return readFromHTTPStorages() ?? readFromPlist();
|
|
88
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { validateCookieAsync } from './index.js';
|
|
4
|
+
|
|
5
|
+
describe('validateCookieAsync', () => {
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
globalThis.fetch = originalFetch;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns valid when cookie is accepted (HTTP 200)', async () => {
|
|
17
|
+
globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 });
|
|
18
|
+
|
|
19
|
+
const result = await validateCookieAsync('valid-cookie');
|
|
20
|
+
|
|
21
|
+
expect(result).toEqual({ valid: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns invalid with status when cookie is rejected (HTTP 401)', async () => {
|
|
25
|
+
globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 });
|
|
26
|
+
|
|
27
|
+
const result = await validateCookieAsync('expired-cookie');
|
|
28
|
+
|
|
29
|
+
expect(result).toEqual({ valid: false, reason: 'invalid', status: 401 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns network_error when fetch throws', async () => {
|
|
33
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error'));
|
|
34
|
+
|
|
35
|
+
const result = await validateCookieAsync('some-cookie');
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual({ valid: false, reason: 'network_error' });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
3
|
+
import { COOKIE_NAME, parseStudioCookieValue } from './cookie-parser.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read a generic credential from Windows Credential Manager via CredRead.
|
|
7
|
+
* The blob is decoded as UTF-8 (matching Mantle's wincred.rs).
|
|
8
|
+
*/
|
|
9
|
+
function winCredRead(target: string): string | undefined {
|
|
10
|
+
const escapedTarget = target.replace(/'/g, "''");
|
|
11
|
+
const script = [
|
|
12
|
+
`Add-Type -TypeDefinition '`,
|
|
13
|
+
`using System; using System.Runtime.InteropServices; using System.Text;`,
|
|
14
|
+
`public class NevCred {`,
|
|
15
|
+
` [DllImport("advapi32.dll",SetLastError=true,CharSet=CharSet.Unicode)]`,
|
|
16
|
+
` public static extern bool CredRead(string t,int ty,int f,out IntPtr c);`,
|
|
17
|
+
` [DllImport("advapi32.dll",SetLastError=true)]`,
|
|
18
|
+
` public static extern bool CredFree(IntPtr c);`,
|
|
19
|
+
` [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]`,
|
|
20
|
+
` public struct CRED { public int F; public int T; public string TN; public string Co;`,
|
|
21
|
+
` public System.Runtime.InteropServices.ComTypes.FILETIME LW;`,
|
|
22
|
+
` public int CBS; public IntPtr CB; public int P; public int AC; public IntPtr At;`,
|
|
23
|
+
` public string TA; public string UN; }`,
|
|
24
|
+
` public static string Read(string t) {`,
|
|
25
|
+
` IntPtr p; if(!CredRead(t,1,0,out p)) return null;`,
|
|
26
|
+
` try { CRED c=(CRED)Marshal.PtrToStructure(p,typeof(CRED));`,
|
|
27
|
+
` if(c.CBS<=0) return "";`,
|
|
28
|
+
` byte[] b=new byte[c.CBS]; Marshal.Copy(c.CB,b,0,c.CBS);`,
|
|
29
|
+
` return Encoding.UTF8.GetString(b);`,
|
|
30
|
+
` } finally { CredFree(p); } } }`,
|
|
31
|
+
`'; [NevCred]::Read('${escapedTarget}')`,
|
|
32
|
+
].join(' ');
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const result = execSync(
|
|
36
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -Command "${script.replace(
|
|
37
|
+
/"/g,
|
|
38
|
+
'\\"'
|
|
39
|
+
)}"`,
|
|
40
|
+
{ encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
41
|
+
).trim();
|
|
42
|
+
return result.length > 0 ? result : undefined;
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readFromRegistry(): string | undefined {
|
|
49
|
+
try {
|
|
50
|
+
const script = `(Get-ItemProperty -Path 'HKCU:\\Software\\Roblox\\RobloxStudioBrowser\\roblox.com' -Name '${COOKIE_NAME}' -ErrorAction SilentlyContinue).'${COOKIE_NAME}'`;
|
|
51
|
+
const result = execSync(
|
|
52
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -Command "${script}"`,
|
|
53
|
+
{ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
54
|
+
).trim();
|
|
55
|
+
|
|
56
|
+
if (result && result.length > 10) {
|
|
57
|
+
const parsed = parseStudioCookieValue(result);
|
|
58
|
+
if (parsed) {
|
|
59
|
+
OutputHelper.verbose('Loaded cookie from Windows Registry.');
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Registry read failed
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readCookie(): string | undefined {
|
|
71
|
+
// Modern Studio: user-specific credential
|
|
72
|
+
const userId = winCredRead('https://www.roblox.com:RobloxStudioAuthuserid');
|
|
73
|
+
if (userId) {
|
|
74
|
+
const cookie = winCredRead(
|
|
75
|
+
`https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}${userId}`
|
|
76
|
+
);
|
|
77
|
+
if (cookie) {
|
|
78
|
+
OutputHelper.verbose(
|
|
79
|
+
`Loaded cookie from Windows Credentials (user ${userId}).`
|
|
80
|
+
);
|
|
81
|
+
return cookie;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Legacy credential (no user ID suffix)
|
|
86
|
+
const legacyCookie = winCredRead(
|
|
87
|
+
`https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}`
|
|
88
|
+
);
|
|
89
|
+
if (legacyCookie) {
|
|
90
|
+
OutputHelper.verbose('Loaded cookie from Windows Credentials (legacy).');
|
|
91
|
+
return legacyCookie;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Oldest fallback: Windows Registry
|
|
95
|
+
return readFromRegistry();
|
|
96
|
+
}
|