creditdaddy 1.1.1 → 1.2.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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Trae(ByteDance TRAE SOLO / Trae CN)本机凭据读取。
3
+ *
4
+ * 登录态在 <userData>/User/globalStorage/storage.json 的 iCubeAuthInfo://icube.cloudide 键,
5
+ * 值为 Trae 自研「tc」信封(out/vs/base/common/byteCrypto.js 逆向):pepper 是随安装包分发的
6
+ * 公开常量表(混淆而非加密),派生 = SHA512(random)||pepper 再 SHA512,取前 32B 为 key/iv,
7
+ * AES-128-CBC/PKCS7 解密后前 64B 是明文的 SHA512 完整性校验值。
8
+ *
9
+ * 只读本机自有凭据,token 不进日志、不外传。
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import crypto from 'node:crypto';
16
+ import { execFileSync } from 'node:child_process';
17
+ import { dataDir } from './store.js';
18
+
19
+ const APP_NAMES = ['TRAE SOLO CN', 'Trae CN', 'TRAE SOLO', 'Trae'];
20
+ const EXE_NAMES = ['TRAE SOLO CN.exe', 'Trae CN.exe', 'TRAE SOLO.exe', 'Trae.exe'];
21
+ const AUTH_KEY = 'iCubeAuthInfo://icube.cloudide';
22
+ const DC_PREFIX = 'iCubeAuthInfo://icube-dc:';
23
+ const HEADER = Buffer.from([116, 99, 5, 16, 0, 0]);
24
+
25
+ // byteCrypto 四常量表(Trae CN resources/app/out/main.js 提取;上游换版本时重新提取)
26
+ const WOE = [82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73, 109, 139, 209, 37];
27
+ const VOE = [31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25, 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176, 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99, 85, 33, 12, 125];
28
+ const JOE = [191, 192, 216, 250, 122, 246, 220, 97, 31, 254, 98, 27, 8, 72, 71, 176, 135, 99, 96, 18, 127, 101, 203, 104, 211, 102, 191, 125, 37, 72, 150, 156, 51, 229, 121, 35, 17, 153, 141, 177, 110, 131, 150, 128, 172, 255, 254, 6, 18, 140, 55, 62, 236, 249, 135, 64, 135, 12, 117, 4, 89, 149, 168, 209];
29
+ const HOE = [246, 204, 26, 232, 232, 70, 129, 109, 223, 146, 169, 242, 23, 241, 105, 145, 50, 196, 165, 42, 254, 120, 3, 54, 244, 207, 209, 85, 53, 6, 138, 106, 175, 148, 31, 204, 186, 186, 165, 182, 87, 142, 49, 10, 39, 110, 26, 154, 86, 56, 173, 125, 18, 64, 198, 225, 99, 99, 83, 82, 191, 134, 76, 170];
30
+
31
+ const sha512 = (b) => crypto.createHash('sha512').update(b).digest();
32
+ const pepper = (a, b) => Buffer.from(a.map((v, i) => v ^ b[i]));
33
+
34
+ /** tc 信封解密(默认 AES 模式,失败回落 AES_PRIVATE 模式);解不开返回 null 由调用方降级 */
35
+ export function tcDecrypt(b64) {
36
+ if (typeof b64 !== 'string' || !b64) return null;
37
+ const raw = Buffer.from(b64.trim(), 'base64');
38
+ if (raw.length < 54 || !raw.subarray(0, 6).equals(HEADER)) return null;
39
+ const cipher = raw.subarray(38);
40
+ for (const p of [pepper(WOE, VOE), pepper(JOE, HOE)]) {
41
+ try {
42
+ const k = sha512(Buffer.concat([sha512(raw.subarray(6, 38)), p]));
43
+ const d = crypto.createDecipheriv('aes-128-cbc', k.subarray(0, 16), k.subarray(16, 32));
44
+ const plain = Buffer.concat([d.update(cipher), d.final()]);
45
+ if (plain.length <= 64) continue;
46
+ const body = plain.subarray(64);
47
+ if (!sha512(body).equals(plain.subarray(0, 64))) continue;
48
+ return JSON.parse(body.toString('utf8'));
49
+ } catch { /* 换下一个 pepper 模式 */ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function appDataRoot() {
55
+ if (process.platform === 'win32') return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
56
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
57
+ return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
58
+ }
59
+
60
+ /** 本机 Trae 数据目录(第一个含 storage.json 的),没有则 null */
61
+ export function traeDataDir() {
62
+ const override = process.env.TRAE_HOME;
63
+ if (override) {
64
+ const file = path.join(override, 'User', 'globalStorage', 'storage.json');
65
+ return fs.existsSync(file) ? override : null;
66
+ }
67
+ for (const name of APP_NAMES) {
68
+ const file = path.join(appDataRoot(), name, 'User', 'globalStorage', 'storage.json');
69
+ if (fs.existsSync(file)) return path.dirname(path.dirname(path.dirname(file)));
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function readStorageJson(dir) {
75
+ const file = path.join(dir, 'User', 'globalStorage', 'storage.json');
76
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
77
+ }
78
+
79
+ /**
80
+ * 签到接口风控要求 x-device-id 是 16 位纯数字,传 GUID/UUID 会返回 code 9074。
81
+ * storage.json 里形如 iCubeAuthInfo://icube-dc:3049374157909753 的键名后缀就是客户端自报的设备号,
82
+ * 直接取用可保证与 IDE 自己的指纹一致(同目录另有 icube-dc:aha-<hash> 一条,按纯数字过滤掉)。
83
+ */
84
+ function deviceIdOf(storage) {
85
+ for (const key of Object.keys(storage)) {
86
+ if (!key.startsWith(DC_PREFIX)) continue;
87
+ const id = key.slice(DC_PREFIX.length);
88
+ if (id.length >= 8 && /^[0-9]+$/.test(id)) return id;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /** 解出客户端当前登录的完整凭据;未登录/解不开返回 null */
94
+ export function readLiveAuth() {
95
+ const dir = traeDataDir();
96
+ if (!dir) return null;
97
+ const storage = readStorageJson(dir);
98
+ if (!storage) return null;
99
+ const auth = tcDecrypt(storage[AUTH_KEY]);
100
+ if (!auth || typeof auth.token !== 'string') return null;
101
+ return { dataDir: dir, deviceId: deviceIdOf(storage), auth };
102
+ }
103
+
104
+ const decodeTasklist = (buf) => {
105
+ try { return new TextDecoder('gbk').decode(buf); } catch { return buf.toString('utf8'); }
106
+ };
107
+
108
+ let runningOverride = null;
109
+
110
+ /** 任一变体的 Trae 客户端是否在运行 */
111
+ export function traeRunning() {
112
+ if (runningOverride !== null) return runningOverride;
113
+ if (process.platform !== 'win32') return false;
114
+ for (const exe of EXE_NAMES) {
115
+ try {
116
+ const out = decodeTasklist(execFileSync('tasklist.exe', ['/FI', `IMAGENAME eq ${exe}`, '/NH'],
117
+ { windowsHide: true, encoding: 'buffer', timeout: 8000 }));
118
+ if (out.toLowerCase().includes(exe.toLowerCase()) && !/no tasks|没有找到|没有任务/i.test(out)) return true;
119
+ } catch { /* 下一个变体 */ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ export function candidateClientPath() {
125
+ if (process.platform !== 'win32') return null;
126
+ const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
127
+ for (const name of APP_NAMES) {
128
+ for (const exe of EXE_NAMES) {
129
+ const p = path.join(local, 'Programs', name, exe);
130
+ if (fs.existsSync(p)) return p;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ export function detectTrae() {
137
+ const dir = traeDataDir();
138
+ const clientPath = candidateClientPath();
139
+ let live = null;
140
+ try { live = readLiveAuth(); } catch { /* 视为未登录 */ }
141
+ return {
142
+ dataDir: dir || '',
143
+ exists: Boolean(dir),
144
+ signedIn: Boolean(live),
145
+ clientInstalled: Boolean(clientPath),
146
+ clientPath,
147
+ running: traeRunning(),
148
+ uid: live?.auth?.userId || null,
149
+ name: live?.auth?.account?.username || null,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * 读取 Trae 客户端当前登录 → CreditDaddy 标准账号对象。
155
+ * uid / name 直接从 JWT 与内嵌 account 取,不需要网络往返。
156
+ */
157
+ export async function liveToAccount() {
158
+ const live = readLiveAuth();
159
+ if (!live) return null;
160
+ const { auth, deviceId } = live;
161
+ const account = {
162
+ provider: 'trae',
163
+ token: auth.token,
164
+ // 故意不导入 refreshToken:Trae 的 refresh 会轮转 refreshToken 并作废 IDE 自己那份,
165
+ // 等于把用户正在用的 Trae 踢下线。面板据 hasRefreshToken 显示「自动续期」,
166
+ // 不存它就会改为显示到期日并在临期时变红,提示到 Trae 里重新登录后重新导入。
167
+ refreshToken: null,
168
+ uid: auth.userId ? String(auth.userId) : null,
169
+ name: auth.account?.username || null,
170
+ email: auth.account?.email || null,
171
+ expiresAt: auth.expiredAt || null,
172
+ source: 'local-app',
173
+ meta: {
174
+ deviceId: deviceId || undefined,
175
+ host: auth.host || undefined,
176
+ region: auth.userRegion?.region || undefined,
177
+ avatarUrl: auth.account?.avatar_url || undefined,
178
+ capturedAt: new Date().toISOString(),
179
+ },
180
+ };
181
+ if (!account.uid) account.uid = uidFromJwt(auth.token) || null;
182
+ return account;
183
+ }
184
+
185
+ /** Cloud-IDE-JWT 的 payload.data.id 即 uid */
186
+ export function uidFromJwt(token) {
187
+ const seg = String(token || '').split('.')[1];
188
+ if (!seg) return null;
189
+ try {
190
+ const p = JSON.parse(Buffer.from(seg, 'base64url').toString('utf8'));
191
+ return p?.data?.id ? String(p.data.id) : null;
192
+ } catch { return null; }
193
+ }
194
+
195
+ export function terminateTrae({ timeoutMs = 8000 } = {}) {
196
+ if (process.platform !== 'win32') return { closed: false, supported: false };
197
+ if (!traeRunning()) return { closed: false, running: false };
198
+ const kill = (force) => {
199
+ for (const exe of EXE_NAMES) {
200
+ try {
201
+ execFileSync('taskkill.exe', force ? ['/IM', exe, '/T', '/F'] : ['/IM', exe, '/T'],
202
+ { windowsHide: true, stdio: 'ignore', timeout: 15_000 });
203
+ } catch { /* 该变体没在跑 */ }
204
+ }
205
+ };
206
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
207
+ const deadline = Date.now() + timeoutMs;
208
+ kill(false);
209
+ while (Date.now() < deadline && traeRunning()) nap(250);
210
+ if (traeRunning()) {
211
+ kill(true);
212
+ const hard = Date.now() + 3000;
213
+ while (Date.now() < hard && traeRunning()) nap(250);
214
+ }
215
+ return { closed: !traeRunning(), running: traeRunning() };
216
+ }
217
+
218
+ /** 测试钩子 */
219
+ export function _setRunningForTests(running) { runningOverride = running; }
220
+
221
+ // ── 登录态快照与切换 ──
222
+
223
+ /**
224
+ * 登录态白名单(对齐 TraeWorkAssistant switcher/icube.rs 的 ICUBE_ITEMS)。
225
+ * 备份与恢复用同一张表:表外的文件不动,避免把用户的工程配置一起换掉。
226
+ */
227
+ const SLOT_ITEMS = [
228
+ 'User/globalStorage/storage.json',
229
+ 'User/globalStorage/state.vscdb',
230
+ 'User/globalStorage/state.vscdb-wal',
231
+ 'User/globalStorage/state.vscdb-shm',
232
+ 'User/globalStorage/state.vscdb.backup',
233
+ 'machineid',
234
+ 'aha',
235
+ 'Preferences',
236
+ 'Local State',
237
+ 'Local Storage/leveldb',
238
+ 'Local Storage/config.db',
239
+ 'Network',
240
+ 'Partitions/trae-webview',
241
+ 'Partitions/icube-web-crawler-shared-session-v1.0',
242
+ 'Session Storage',
243
+ ];
244
+
245
+ const slotDir = (uid) => path.join(dataDir(), 'trae-slots', String(uid));
246
+
247
+ export function hasSlot(uid) {
248
+ return Boolean(uid) && fs.existsSync(path.join(slotDir(uid), 'User', 'globalStorage', 'storage.json'));
249
+ }
250
+
251
+ /**
252
+ * Chromium 缓存目录:与登录态无关,但本机实测占快照体积的 91/106 MB
253
+ * (Partitions/trae-webview/Cache 43MB + icube-web-crawler 的 Code Cache 48MB),
254
+ * 每个账号都拷一份既慢又白占磁盘,跳过。
255
+ */
256
+ const CACHE_NAMES = new Set(['Cache', 'Code Cache', 'GPUCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'Shared Dictionary', 'blob_storage']);
257
+ const notCache = (p) => !CACHE_NAMES.has(path.basename(p));
258
+
259
+ function copyInto(src, dst) {
260
+ fs.rmSync(dst, { recursive: true, force: true });
261
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
262
+ const isDir = fs.statSync(src).isDirectory();
263
+ fs.cpSync(src, dst, isDir ? { recursive: true, filter: notCache } : undefined);
264
+ }
265
+
266
+ /** 把当前登录的 15 项快照到 trae-slots/<uid>/,旧快照转成 .bak 保留一代 */
267
+ function saveSlot(uid) {
268
+ const root = traeDataDir();
269
+ if (!root || !uid) return 0;
270
+ const dest = slotDir(uid);
271
+ if (fs.existsSync(dest)) {
272
+ fs.rmSync(dest + '.bak', { recursive: true, force: true });
273
+ fs.renameSync(dest, dest + '.bak');
274
+ }
275
+ fs.mkdirSync(dest, { recursive: true });
276
+ let n = 0;
277
+ for (const rel of SLOT_ITEMS) {
278
+ const src = path.join(root, ...rel.split('/'));
279
+ if (!fs.existsSync(src)) continue;
280
+ copyInto(src, path.join(dest, ...rel.split('/')));
281
+ n++;
282
+ }
283
+ return n;
284
+ }
285
+
286
+ function restoreSlot(uid) {
287
+ const root = traeDataDir();
288
+ const src = slotDir(uid);
289
+ if (!root || !fs.existsSync(src)) return 0;
290
+ let n = 0;
291
+ for (const rel of SLOT_ITEMS) {
292
+ const from = path.join(src, ...rel.split('/'));
293
+ if (!fs.existsSync(from)) continue;
294
+ copyInto(from, path.join(root, ...rel.split('/')));
295
+ n++;
296
+ }
297
+ return n;
298
+ }
299
+
300
+ /**
301
+ * 把本机 Trae 切到目标账号的登录态:先快照当前登录(防丢号),再覆盖回目标快照。
302
+ * Trae 在运行时会把内存里的旧登录写回文件,所以必须先退出(force 时代为退出)。
303
+ */
304
+ export async function switchTo(account, { force = false } = {}) {
305
+ const live = readLiveAuth();
306
+ if (!live) throw new Error('本机 Trae 没有可读取的登录态(storage.json 缺失或解不开)');
307
+ const cur = live.auth.userId;
308
+ if (account.uid && cur && String(account.uid) === String(cur)) return { switched: false, alreadyActive: true };
309
+
310
+ // 只认整份快照:实测「只把 token 写回 storage.json」的冷切换会让 Trae 显示未登录
311
+ // (它判登录还要看 state.vscdb),比不切更糟,所以这条路已删除。
312
+ if (!hasSlot(account.uid)) {
313
+ throw new Error(`账号「${account.name || account.uid}」还没有登录态快照:请先在 Trae 里登录该账号,再回这里点一次「本机导入」`);
314
+ }
315
+
316
+ let closedClient = false;
317
+ if (traeRunning()) {
318
+ if (!force) {
319
+ const e = new Error('Trae 客户端正在运行,请先退出后再切换(或强制切换,切换后自动重新拉起 Trae)');
320
+ e.traeRunning = true;
321
+ throw e;
322
+ }
323
+ closedClient = terminateTrae().closed === true;
324
+ }
325
+
326
+ if (cur) saveSlot(cur);
327
+ restoreSlot(account.uid);
328
+ return { switched: true, alreadyActive: false, closedClient };
329
+ }
330
+
331
+ /** 供 daemon 在导入成功后立即建快照(否则新导入的账号没有可恢复的登录态) */
332
+ export function snapshotLive() {
333
+ try {
334
+ const live = readLiveAuth();
335
+ return live?.auth?.userId ? saveSlot(live.auth.userId) : 0;
336
+ } catch { return 0; }
337
+ }
package/src/transfer.js CHANGED
@@ -1,160 +1,160 @@
1
- /**
2
- * 账号导入 / 导出 — 与 10router 的 OAuth 迁移文件互通。
3
- *
4
- * 加密信封与 10router src/lib/auth/secureTransfer.js 逐字节一致(10router-oauth-secure-v1):
5
- * scrypt(N=16384, r=8, p=1, 32B) 由口令派生密钥 → AES-256-GCM;salt/iv/tag/payload 均为标准 base64
6
- *
7
- * 解密后的载荷(两边通用):
8
- * { provider, exportedAt, accounts: [{ provider, name, email, uid, accessToken, refreshToken, expiresAt, providerSpecificData }] }
9
- * CreditDaddy 另写入 format/version,并保留旧字段 token 以兼容 v0.1.x 的导入。
10
- *
11
- * 能导入:CreditDaddy(含更名前的 QoderDaddy)明文/加密导出、10router 加密导出(取 qoder / qoder-cn / codebuddy-cn / codebuddy-intl 账号)、
12
- * [{name,provider,token}] 数组。10router 的 codebuddy-* 映射为 WorkBuddy。
13
- */
14
-
15
- import crypto from 'node:crypto';
16
- import { PROVIDERS, productOf } from './constants.js';
17
-
18
- export const TRANSFER_FORMAT = '10router-oauth-secure-v1';
19
- export const EXPORT_FORMAT = 'creditdaddy-accounts';
20
- const OWN_FORMATS = new Set([EXPORT_FORMAT, 'qoderdaddy-accounts']); // 兼容更名前的 QoderDaddy 导出
21
- const KDF = { alg: 'scrypt', N: 16384, r: 8, p: 1, keyLen: 32 };
22
-
23
- // 10router 的提供商 ID → 本工具 provider
24
- const PROVIDER_ALIASES = { 'codebuddy-cn': 'workbuddy', 'codebuddy-intl': 'workbuddy-intl', codebuddy: 'workbuddy' };
25
- const toProvider = (p) => (PROVIDERS.includes(p) ? p : PROVIDER_ALIASES[p] || null);
26
-
27
- const b64 = (buf) => Buffer.from(buf).toString('base64');
28
- const unb64 = (s) => Buffer.from(String(s || ''), 'base64');
29
-
30
- export class TransferError extends Error {
31
- constructor(code, message) { super(message); this.code = code; }
32
- }
33
-
34
- function deriveKey(password, salt, kdf = KDF) {
35
- return crypto.scryptSync(String(password), salt, kdf.keyLen || 32, {
36
- N: kdf.N || KDF.N, r: kdf.r || KDF.r, p: kdf.p || KDF.p, maxmem: 64 * 1024 * 1024,
37
- });
38
- }
39
-
40
- export function sealTransfer(payload, password) {
41
- if (typeof password !== 'string' || password.length < 4) {
42
- throw new TransferError('PASSPHRASE_TOO_SHORT', '加密口令至少 4 个字符');
43
- }
44
- const salt = crypto.randomBytes(16);
45
- const iv = crypto.randomBytes(12);
46
- const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(password, salt), iv);
47
- const ct = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(payload), 'utf8')), cipher.final()]);
48
- return {
49
- format: TRANSFER_FORMAT,
50
- kdf: { ...KDF, salt: b64(salt) },
51
- cipher: 'aes-256-gcm',
52
- iv: b64(iv),
53
- tag: b64(cipher.getAuthTag()),
54
- payload: b64(ct),
55
- };
56
- }
57
-
58
- export function openTransfer(blob, password) {
59
- if (blob?.format !== TRANSFER_FORMAT) throw new TransferError('UNSUPPORTED_FORMAT', '不是加密迁移文件');
60
- if (!password) throw new TransferError('NEED_PASSWORD', '该文件已加密,请输入导出时设置的口令');
61
- try {
62
- const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(password, unb64(blob.kdf?.salt), blob.kdf), unb64(blob.iv));
63
- decipher.setAuthTag(unb64(blob.tag));
64
- const plain = Buffer.concat([decipher.update(unb64(blob.payload)), decipher.final()]);
65
- return JSON.parse(plain.toString('utf8'));
66
- } catch {
67
- throw new TransferError('WRONG_PASSWORD', '口令错误或文件已损坏');
68
- }
69
- }
70
-
71
- /** 构造导出载荷(10router 兼容字段 + CreditDaddy 旧字段) */
72
- export function buildExportPayload(accounts, { provider, product } = {}) {
73
- const list = accounts.filter((a) => (!provider || a.provider === provider) && (!product || productOf(a.provider) === product));
74
- const providers = [...new Set(list.map((a) => a.provider))];
75
- return {
76
- format: EXPORT_FORMAT,
77
- version: 2,
78
- provider: providers.length === 1 ? providers[0] : (provider || null),
79
- exportedAt: new Date().toISOString(),
80
- accounts: list.map((a) => ({
81
- provider: a.provider,
82
- name: a.name || a.email || null,
83
- email: a.email || null,
84
- uid: a.uid || null,
85
- accessToken: a.token,
86
- token: a.token,
87
- refreshToken: a.refreshToken || null,
88
- expiresAt: a.expiresAt || null,
89
- createdAt: a.createdAt,
90
- providerSpecificData: {
91
- authMethod: a.token.startsWith('pt-') ? 'pat' : 'device',
92
- userId: a.uid || null,
93
- // Qoder 网页会话随账号一起同步:10Router 的逐资源包明细接口只认这个 Cookie
94
- // (openapi 只有聚合值)。userId 供接收方核对会话归属,防止张冠李戴。
95
- ...(a.meta?.qoderWebSession?.cookie && String(a.provider).startsWith('qoder')
96
- ? { creditDaddyWebSession: { cookie: a.meta.qoderWebSession.cookie, capturedAt: a.meta.qoderWebSession.capturedAt || null, userId: a.uid || null } }
97
- : {}),
98
- },
99
- ...(a.meta && Object.keys(a.meta).length ? { meta: a.meta } : {}),
100
- })),
101
- };
102
- }
103
-
104
- /**
105
- * 导出:只输出 10router 兼容的加密信封(scrypt + AES-256-GCM)。
106
- * 账号含 token,导出必须设置口令——不再提供明文导出路径;口令校验复用 sealTransfer。
107
- */
108
- export function exportAccounts(accounts, { password, provider, product } = {}) {
109
- if (typeof password !== 'string' || !password) {
110
- throw new TransferError('PASSWORD_REQUIRED', '导出账号必须设置加密口令(至少 4 个字符)');
111
- }
112
- return sealTransfer(buildExportPayload(accounts, { provider, product }), password);
113
- }
114
-
115
- /**
116
- * 解析任意支持的导入文件,统一成 normalizeAccountInput 可接受的记录。
117
- * @returns {{ accounts: Array, skipped: number, source: string }}
118
- */
119
- export function parseImport(data, { password } = {}) {
120
- let source = 'creditdaddy';
121
- let payload = data;
122
- if (data?.format === TRANSFER_FORMAT) {
123
- payload = openTransfer(data, password);
124
- source = OWN_FORMATS.has(payload?.format) ? 'creditdaddy' : '10router';
125
- }
126
- const list = Array.isArray(payload) ? payload : payload?.accounts;
127
- if (!Array.isArray(list)) {
128
- throw new TransferError('BAD_FORMAT', '无法识别的文件:应为 CreditDaddy 或 10router 导出的 JSON');
129
- }
130
- const fallbackProvider = toProvider(payload?.provider);
131
-
132
- const accounts = [];
133
- let skipped = 0;
134
- for (const item of list) {
135
- // 显式标注了其他提供商(如 10router 的 cursor 账号)→ 跳过;未标注才用文件级 provider
136
- const provider = item?.provider ? toProvider(item.provider) : fallbackProvider;
137
- const token = item?.accessToken || item?.access_token || item?.token || item?.apiKey;
138
- if (!provider || typeof token !== 'string' || !token.trim()) { skipped++; continue; }
139
- const psd = item.providerSpecificData || {};
140
- const email = typeof item.email === 'string' && item.email.includes('@') && !/^qoder(-cn)?-user-/.test(item.email) ? item.email : null;
141
- // 10Router 侧保留的网页会话(psd.creditDaddyWebSession)回导入时捡回 meta,双向闭环
142
- const webSession = psd.creditDaddyWebSession && typeof psd.creditDaddyWebSession === 'object'
143
- && typeof psd.creditDaddyWebSession.cookie === 'string' && psd.creditDaddyWebSession.cookie
144
- ? { cookie: psd.creditDaddyWebSession.cookie, capturedAt: psd.creditDaddyWebSession.capturedAt || null } : null;
145
- const meta = { ...(item.meta && typeof item.meta === 'object' ? item.meta : {}) };
146
- if (webSession && !meta.qoderWebSession) meta.qoderWebSession = webSession;
147
- accounts.push({
148
- provider,
149
- token,
150
- name: item.name || item.nickname || email || null,
151
- email,
152
- uid: item.uid || psd.userId || null,
153
- refreshToken: item.refreshToken || item.refresh_token || null,
154
- expiresAt: item.expiresAt ?? (item.expiresIn ? Date.now() + Number(item.expiresIn) * 1000 : null),
155
- source: source === '10router' ? '10router' : 'import',
156
- ...(Object.keys(meta).length ? { meta } : {}),
157
- });
158
- }
159
- return { accounts, skipped, source };
160
- }
1
+ /**
2
+ * 账号导入 / 导出 — 与 10router 的 OAuth 迁移文件互通。
3
+ *
4
+ * 加密信封与 10router src/lib/auth/secureTransfer.js 逐字节一致(10router-oauth-secure-v1):
5
+ * scrypt(N=16384, r=8, p=1, 32B) 由口令派生密钥 → AES-256-GCM;salt/iv/tag/payload 均为标准 base64
6
+ *
7
+ * 解密后的载荷(两边通用):
8
+ * { provider, exportedAt, accounts: [{ provider, name, email, uid, accessToken, refreshToken, expiresAt, providerSpecificData }] }
9
+ * CreditDaddy 另写入 format/version,并保留旧字段 token 以兼容 v0.1.x 的导入。
10
+ *
11
+ * 能导入:CreditDaddy(含更名前的 QoderDaddy)明文/加密导出、10router 加密导出(取 qoder / qoder-cn / codebuddy-cn / codebuddy-intl 账号)、
12
+ * [{name,provider,token}] 数组。10router 的 codebuddy-* 映射为 WorkBuddy。
13
+ */
14
+
15
+ import crypto from 'node:crypto';
16
+ import { PROVIDERS, productOf } from './constants.js';
17
+
18
+ export const TRANSFER_FORMAT = '10router-oauth-secure-v1';
19
+ export const EXPORT_FORMAT = 'creditdaddy-accounts';
20
+ const OWN_FORMATS = new Set([EXPORT_FORMAT, 'qoderdaddy-accounts']); // 兼容更名前的 QoderDaddy 导出
21
+ const KDF = { alg: 'scrypt', N: 16384, r: 8, p: 1, keyLen: 32 };
22
+
23
+ // 10router 的提供商 ID → 本工具 provider
24
+ const PROVIDER_ALIASES = { 'codebuddy-cn': 'workbuddy', 'codebuddy-intl': 'workbuddy-intl', codebuddy: 'workbuddy' };
25
+ const toProvider = (p) => (PROVIDERS.includes(p) ? p : PROVIDER_ALIASES[p] || null);
26
+
27
+ const b64 = (buf) => Buffer.from(buf).toString('base64');
28
+ const unb64 = (s) => Buffer.from(String(s || ''), 'base64');
29
+
30
+ export class TransferError extends Error {
31
+ constructor(code, message) { super(message); this.code = code; }
32
+ }
33
+
34
+ function deriveKey(password, salt, kdf = KDF) {
35
+ return crypto.scryptSync(String(password), salt, kdf.keyLen || 32, {
36
+ N: kdf.N || KDF.N, r: kdf.r || KDF.r, p: kdf.p || KDF.p, maxmem: 64 * 1024 * 1024,
37
+ });
38
+ }
39
+
40
+ export function sealTransfer(payload, password) {
41
+ if (typeof password !== 'string' || password.length < 4) {
42
+ throw new TransferError('PASSPHRASE_TOO_SHORT', '加密口令至少 4 个字符');
43
+ }
44
+ const salt = crypto.randomBytes(16);
45
+ const iv = crypto.randomBytes(12);
46
+ const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(password, salt), iv);
47
+ const ct = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(payload), 'utf8')), cipher.final()]);
48
+ return {
49
+ format: TRANSFER_FORMAT,
50
+ kdf: { ...KDF, salt: b64(salt) },
51
+ cipher: 'aes-256-gcm',
52
+ iv: b64(iv),
53
+ tag: b64(cipher.getAuthTag()),
54
+ payload: b64(ct),
55
+ };
56
+ }
57
+
58
+ export function openTransfer(blob, password) {
59
+ if (blob?.format !== TRANSFER_FORMAT) throw new TransferError('UNSUPPORTED_FORMAT', '不是加密迁移文件');
60
+ if (!password) throw new TransferError('NEED_PASSWORD', '该文件已加密,请输入导出时设置的口令');
61
+ try {
62
+ const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(password, unb64(blob.kdf?.salt), blob.kdf), unb64(blob.iv));
63
+ decipher.setAuthTag(unb64(blob.tag));
64
+ const plain = Buffer.concat([decipher.update(unb64(blob.payload)), decipher.final()]);
65
+ return JSON.parse(plain.toString('utf8'));
66
+ } catch {
67
+ throw new TransferError('WRONG_PASSWORD', '口令错误或文件已损坏');
68
+ }
69
+ }
70
+
71
+ /** 构造导出载荷(10router 兼容字段 + CreditDaddy 旧字段) */
72
+ export function buildExportPayload(accounts, { provider, product } = {}) {
73
+ const list = accounts.filter((a) => (!provider || a.provider === provider) && (!product || productOf(a.provider) === product));
74
+ const providers = [...new Set(list.map((a) => a.provider))];
75
+ return {
76
+ format: EXPORT_FORMAT,
77
+ version: 2,
78
+ provider: providers.length === 1 ? providers[0] : (provider || null),
79
+ exportedAt: new Date().toISOString(),
80
+ accounts: list.map((a) => ({
81
+ provider: a.provider,
82
+ name: a.name || a.email || null,
83
+ email: a.email || null,
84
+ uid: a.uid || null,
85
+ accessToken: a.token,
86
+ token: a.token,
87
+ refreshToken: a.refreshToken || null,
88
+ expiresAt: a.expiresAt || null,
89
+ createdAt: a.createdAt,
90
+ providerSpecificData: {
91
+ authMethod: a.token.startsWith('pt-') ? 'pat' : 'device',
92
+ userId: a.uid || null,
93
+ // Qoder 网页会话随账号一起同步:10Router 的逐资源包明细接口只认这个 Cookie
94
+ // (openapi 只有聚合值)。userId 供接收方核对会话归属,防止张冠李戴。
95
+ ...(a.meta?.qoderWebSession?.cookie && String(a.provider).startsWith('qoder')
96
+ ? { creditDaddyWebSession: { cookie: a.meta.qoderWebSession.cookie, capturedAt: a.meta.qoderWebSession.capturedAt || null, userId: a.uid || null } }
97
+ : {}),
98
+ },
99
+ ...(a.meta && Object.keys(a.meta).length ? { meta: a.meta } : {}),
100
+ })),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * 导出:只输出 10router 兼容的加密信封(scrypt + AES-256-GCM)。
106
+ * 账号含 token,导出必须设置口令——不再提供明文导出路径;口令校验复用 sealTransfer。
107
+ */
108
+ export function exportAccounts(accounts, { password, provider, product } = {}) {
109
+ if (typeof password !== 'string' || !password) {
110
+ throw new TransferError('PASSWORD_REQUIRED', '导出账号必须设置加密口令(至少 4 个字符)');
111
+ }
112
+ return sealTransfer(buildExportPayload(accounts, { provider, product }), password);
113
+ }
114
+
115
+ /**
116
+ * 解析任意支持的导入文件,统一成 normalizeAccountInput 可接受的记录。
117
+ * @returns {{ accounts: Array, skipped: number, source: string }}
118
+ */
119
+ export function parseImport(data, { password } = {}) {
120
+ let source = 'creditdaddy';
121
+ let payload = data;
122
+ if (data?.format === TRANSFER_FORMAT) {
123
+ payload = openTransfer(data, password);
124
+ source = OWN_FORMATS.has(payload?.format) ? 'creditdaddy' : '10router';
125
+ }
126
+ const list = Array.isArray(payload) ? payload : payload?.accounts;
127
+ if (!Array.isArray(list)) {
128
+ throw new TransferError('BAD_FORMAT', '无法识别的文件:应为 CreditDaddy 或 10router 导出的 JSON');
129
+ }
130
+ const fallbackProvider = toProvider(payload?.provider);
131
+
132
+ const accounts = [];
133
+ let skipped = 0;
134
+ for (const item of list) {
135
+ // 显式标注了其他提供商(如 10router 的 cursor 账号)→ 跳过;未标注才用文件级 provider
136
+ const provider = item?.provider ? toProvider(item.provider) : fallbackProvider;
137
+ const token = item?.accessToken || item?.access_token || item?.token || item?.apiKey;
138
+ if (!provider || typeof token !== 'string' || !token.trim()) { skipped++; continue; }
139
+ const psd = item.providerSpecificData || {};
140
+ const email = typeof item.email === 'string' && item.email.includes('@') && !/^qoder(-cn)?-user-/.test(item.email) ? item.email : null;
141
+ // 10Router 侧保留的网页会话(psd.creditDaddyWebSession)回导入时捡回 meta,双向闭环
142
+ const webSession = psd.creditDaddyWebSession && typeof psd.creditDaddyWebSession === 'object'
143
+ && typeof psd.creditDaddyWebSession.cookie === 'string' && psd.creditDaddyWebSession.cookie
144
+ ? { cookie: psd.creditDaddyWebSession.cookie, capturedAt: psd.creditDaddyWebSession.capturedAt || null } : null;
145
+ const meta = { ...(item.meta && typeof item.meta === 'object' ? item.meta : {}) };
146
+ if (webSession && !meta.qoderWebSession) meta.qoderWebSession = webSession;
147
+ accounts.push({
148
+ provider,
149
+ token,
150
+ name: item.name || item.nickname || email || null,
151
+ email,
152
+ uid: item.uid || psd.userId || null,
153
+ refreshToken: item.refreshToken || item.refresh_token || null,
154
+ expiresAt: item.expiresAt ?? (item.expiresIn ? Date.now() + Number(item.expiresIn) * 1000 : null),
155
+ source: source === '10router' ? '10router' : 'import',
156
+ ...(Object.keys(meta).length ? { meta } : {}),
157
+ });
158
+ }
159
+ return { accounts, skipped, source };
160
+ }