creditdaddy 0.9.5 → 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.
- package/LICENSE +21 -21
- package/README.md +56 -8
- package/bin/creditdaddy.js +209 -191
- package/package.json +4 -3
- package/src/accounts.js +161 -143
- package/src/authDevice.js +205 -205
- package/src/catpawClient.js +103 -0
- package/src/catpawLocal.js +229 -0
- package/src/checkin.js +357 -230
- package/src/constants.js +97 -81
- package/src/daemon.js +935 -658
- package/src/localDetect.js +147 -147
- package/src/logger.js +39 -1
- package/src/mirasimClient.js +189 -189
- package/src/mirasimLocal.js +386 -307
- package/src/panel.html +765 -214
- package/src/providers.js +190 -100
- package/src/qoderApp.js +524 -332
- package/src/qoderClient.js +237 -199
- package/src/qoderUmid.js +198 -198
- package/src/store.js +238 -222
- package/src/tenrouter.js +263 -221
- package/src/tenrouterAccounts.js +163 -0
- package/src/traeClient.js +258 -0
- package/src/traeLocal.js +337 -0
- package/src/transfer.js +160 -141
- package/src/usageSync.js +505 -319
- package/src/workbuddyAuth.js +127 -126
- package/src/workbuddyClient.js +302 -302
- package/src/workbuddyLocal.js +133 -133
- package/src/zcodeAuth.js +131 -131
- package/src/zcodeAutoClaim.js +97 -96
- package/src/zcodeClient.js +580 -529
- package/src/zcodeGateway.js +570 -0
- package/src/zcodeLocal.js +334 -319
- package/src/zcodePlanShape.json +29 -0
- package/src/zcrypto.js +160 -160
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"system": [
|
|
3
|
+
{
|
|
4
|
+
"type": "text",
|
|
5
|
+
"text": "You are ZCode, an interactive coding agent",
|
|
6
|
+
"cache_control": {
|
|
7
|
+
"type": "ephemeral"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"type": "text",
|
|
12
|
+
"text": "\nYou are an interactive ZCode agent that helps users with software engineering tasks.\n\nIMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.\n\n# Harness\n- Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.\n- Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.\n- The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.\n- Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.\n- Reference code as `file_path:line_number` — it's clickable.\n\n# ZCode Desktop Context\n\n### Files & URLs\n- Return local web URLs as Markdown links (e.g., [label](http://127.0.0.1:8080)).\n- File should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.\n- Unless otherwise specified, return local file references as Markdown links (e.g., [name.md](/absolute/path/to/name.md)).\n\n### Inline Code Comments\n- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines.\n- Emit one directive per inline comment; emit none when there are no actionable inline comments.\n- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).\n- Optional attributes: start, end (1-based line numbers), priority (0-3).\n- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.\n- Keep line ranges tight; end defaults to start.\n- Example: ::code-comment{title=\"[P2] Off-by-one\" body=\"Loop iterates past the end when length is 0.\" file=\"/path/to/foo.ts\" start=10 end=11 priority=2}",
|
|
13
|
+
"cache_control": {
|
|
14
|
+
"type": "ephemeral"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"type": "text",
|
|
19
|
+
"text": "\n\n# Communicating with the user\n\nYour text output is what the user reads; they usually can't see your thinking or the raw tool results. Write it for a teammate who stepped away and is catching up, not for a log file: they don't know the codenames or shorthand you created along the way, and they didn't watch your process unfold. Before your first tool call, say in a sentence what you're about to do; while working, give brief updates when you find something load-bearing or change direction.\n\nText you write between tool calls may not be shown to the user. Everything the user needs from this turn — answers, summaries, findings, conclusions, deliverables — must be in the final text message of your turn, with no tool calls after it. Keep text between tool calls to brief status notes. If something important appeared only mid-turn or in your thinking, restate it in that final message.\n\nLead with the outcome. Your first sentence after finishing should answer \"what happened\" or \"what did you find\" — the thing the user would ask for if they said \"just give me the TLDR.\" Supporting detail and reasoning come after, for readers who want them.\n\nBeing readable and being concise are different things, and readable matters more. If the user has to reread your summary or ask you to explain, any time saved by brevity is gone. The way to keep output short is to be selective about what you include (drop details that don't change what the reader would do next), not to compress the writing into fragments, abbreviations, arrow chains like `A → B → fails`, or jargon. What you do include, write in complete sentences with the technical terms spelled out. Don't make the reader cross-reference labels or numbering you invented earlier; say what you mean in place.\n\nMatch the response to the question: a simple question gets a direct answer in prose, not headers and sections. Use tables only for short enumerable facts, with explanations in the surrounding prose rather than the cells. Calibrate to the user — a bit tighter for an expert, more explanatory for someone newer.\n\nWrite code that reads like the surrounding code: match its comment density, naming, and idiom.\nOnly write a code comment to state a constraint the code itself can't show — never to say where it came from, what the next line does, or why your change is correct; that's you talking to the reviewer, not the next reader, and it's noise the moment the PR merges.\n\nFor actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target — if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.\n\n# Environment\nYou have been invoked in the following environment:\n- Primary working directory: C:\\Users\\YangYu\\zproxy-test\n- Is a git repository: no\n- Platform: win32\n- Shell: bash.exe\n- OS Version: win32 10.0.19045 x64\n- You are powered by the model named bigmodel-api/glm-5.3-flash.\n\n# Context management\nWhen the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task.\n\nWhen you have enough information to act, act. Do not re-derive facts already established in the conversation, re-litigate a decision the user has already made, or narrate options you will not pursue. If you are weighing a choice, give a recommendation, not an exhaustive survey\n\nYou are operating autonomously. The user is not watching in real time and cannot answer questions mid-task, so asking 'Want me to…?' or 'Shall I…?' will block the work. For reversible actions that follow from the original request, proceed without asking. Stop only for destructive actions or genuine scope changes the user must decide. Offering follow-ups after the task is done is fine; asking permission before doing the work is not.\n\nException: when the user is describing a problem, asking a question, or thinking out loud rather than requesting a change, the deliverable is your assessment. Report your findings and stop. Don't apply a fix until they ask for one.\n\nBefore ending your turn, check your last paragraph. If it is a plan, an analysis, a question, a list of next steps, or a promise about work you have not done ('I'll…', 'let me know when…'), do that work now with tool calls. That includes retrying after errors and gathering missing information yourself. Do not stop because the context or session is long. End your turn only when the task is complete or you are blocked on input only the user can provide.\n\nBefore running a command that changes system state — restarts, deletes, config edits — check that the evidence actually supports that specific action. A signal that pattern-matches to a known failure may have a different cause.",
|
|
20
|
+
"cache_control": {
|
|
21
|
+
"type": "ephemeral"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"reminderText": "<system-reminder>As you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is 2026-09-29.\n\n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.</system-reminder>",
|
|
26
|
+
"metadata": {
|
|
27
|
+
"user_id": "{\"account_uuid\":\"\",\"session_id\":\"ses_f0a670f7b1f1\"}"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/zcrypto.js
CHANGED
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ZCode(智谱 GLM / Z.ai 桌面客户端)本机凭据加解密 — 移植自 zcode-switch(MIT)src-tauri/src/zcrypto.rs。
|
|
3
|
-
*
|
|
4
|
-
* 凭据文件 ~/.zcode/v2/credentials.json 里每个值可能是:
|
|
5
|
-
* - 明文字符串
|
|
6
|
-
* - "enc:v1:<nonce>.<tag>.<密文>"(均为 URL-safe base64,无 padding),AES-256-GCM,
|
|
7
|
-
* key = SHA256(secret),secret 默认 "zcode-credential-fallback:<平台>:<home>:<用户名>",
|
|
8
|
-
* 可用环境变量 ZCODE_CREDENTIAL_SECRET 覆盖(客户端本身也读这个变量)。
|
|
9
|
-
* 纯函数、无 I/O,供 zcodeLocal.js 调用,也便于单测。
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import crypto from 'node:crypto';
|
|
13
|
-
|
|
14
|
-
export const ENC_PREFIX = 'enc:v1:';
|
|
15
|
-
|
|
16
|
-
/** node/rust 平台名 → zcode 使用的平台标识(win32 / darwin / linux) */
|
|
17
|
-
export function nodePlatformFor(platform) {
|
|
18
|
-
if (platform === 'windows') return 'win32';
|
|
19
|
-
if (platform === 'macos') return 'darwin';
|
|
20
|
-
return platform;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** 未设置 ZCODE_CREDENTIAL_SECRET 时的回退密钥来源 */
|
|
24
|
-
export function defaultSecret(home, { platform = process.platform, username } = {}) {
|
|
25
|
-
const envSecret = process.env.ZCODE_CREDENTIAL_SECRET;
|
|
26
|
-
if (envSecret) return envSecret;
|
|
27
|
-
const user = username || process.env.USERNAME || process.env.USER || process.env.LOGNAME || 'unknown';
|
|
28
|
-
return `zcode-credential-fallback:${platform}:${home}:${user}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function deriveKey(secret) {
|
|
32
|
-
return crypto.createHash('sha256').update(secret, 'utf8').digest();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function isEncrypted(v) {
|
|
36
|
-
return typeof v === 'string' && v.startsWith(ENC_PREFIX);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function b64u(s) {
|
|
40
|
-
return Buffer.from(s, 'base64url');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** 解密单个 "enc:v1:…" 值;输入非该格式时报错(调用方按需 try/catch 或用 safeDecrypt) */
|
|
44
|
-
export function decryptWithSecret(value, secret) {
|
|
45
|
-
if (!isEncrypted(value)) throw new Error('不是 enc:v1 格式');
|
|
46
|
-
const parts = value.slice(ENC_PREFIX.length).split('.');
|
|
47
|
-
if (parts.length !== 3) throw new Error('enc:v1 格式不正确');
|
|
48
|
-
const [nonceB64, tagB64, ctB64] = parts;
|
|
49
|
-
const nonce = b64u(nonceB64);
|
|
50
|
-
if (nonce.length !== 12) throw new Error('nonce 长度异常');
|
|
51
|
-
const tag = b64u(tagB64);
|
|
52
|
-
const ct = b64u(ctB64);
|
|
53
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(secret), nonce);
|
|
54
|
-
decipher.setAuthTag(tag);
|
|
55
|
-
try {
|
|
56
|
-
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
57
|
-
} catch {
|
|
58
|
-
throw new Error('解密失败(密钥不匹配或数据损坏)');
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** 加密成 "enc:v1:…"(与 decryptWithSecret 互逆):浏览器登录拿到的凭据按客户端格式落盘,切换时才能被 ZCode 读懂 */
|
|
63
|
-
export function encryptWithSecret(plain, secret) {
|
|
64
|
-
const nonce = crypto.randomBytes(12);
|
|
65
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(secret), nonce);
|
|
66
|
-
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
67
|
-
return ENC_PREFIX + [nonce, cipher.getAuthTag(), ct].map((b) => b.toString('base64url')).join('.');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** 值可能加密也可能明文,统一转明文;失败返回 null(不抛错) */
|
|
71
|
-
export function safeDecrypt(value, secret) {
|
|
72
|
-
if (typeof value !== 'string') return null;
|
|
73
|
-
if (!isEncrypted(value)) return value;
|
|
74
|
-
try { return decryptWithSecret(value, secret); } catch { return null; }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** 明文/加密的 JSON 字符串 → 解析后的对象;失败返回 null */
|
|
78
|
-
export function decryptJsonOpt(value, secret) {
|
|
79
|
-
const plain = safeDecrypt(value, secret);
|
|
80
|
-
if (plain === null) return null;
|
|
81
|
-
try { return JSON.parse(plain); } catch { return null; }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** 解码 JWT payload(不校验签名,仅用于展示/识别用户,与 zcode 客户端自身用途一致) */
|
|
85
|
-
export function decodeJwt(jwt) {
|
|
86
|
-
try {
|
|
87
|
-
const seg = String(jwt).split('.')[1];
|
|
88
|
-
if (!seg) return null;
|
|
89
|
-
return JSON.parse(Buffer.from(seg, 'base64url').toString('utf8'));
|
|
90
|
-
} catch {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* 从凭据对象里提取账号身份:{ provider, username, displayName, email, userId }。
|
|
97
|
-
* 逻辑对齐 zcrypto.rs identity_with_secret:先看 active_provider 的 user_info,
|
|
98
|
-
* 找不到 user_id 时退而解码 access_token 这个 JWT。
|
|
99
|
-
*/
|
|
100
|
-
export function identityWithSecret(creds, secret) {
|
|
101
|
-
const id = { provider: 'bigmodel', username: null, displayName: null, email: null, userId: null };
|
|
102
|
-
if (!creds || typeof creds !== 'object') return id;
|
|
103
|
-
const apRaw = creds['oauth:active_provider'];
|
|
104
|
-
if (typeof apRaw === 'string') {
|
|
105
|
-
const plain = isEncrypted(apRaw) ? safeDecrypt(apRaw, secret) : apRaw;
|
|
106
|
-
if (plain) id.provider = plain;
|
|
107
|
-
}
|
|
108
|
-
const ui = decryptJsonOpt(creds[`oauth:${id.provider}:user_info`], secret);
|
|
109
|
-
if (ui && typeof ui === 'object') {
|
|
110
|
-
id.username = typeof ui.username === 'string' ? ui.username : null;
|
|
111
|
-
id.displayName = typeof ui.displayName === 'string' ? ui.displayName : null;
|
|
112
|
-
id.email = typeof ui.email === 'string' ? ui.email
|
|
113
|
-
: typeof ui.rawProfile?.email === 'string' ? ui.rawProfile.email : null;
|
|
114
|
-
if (ui.id !== undefined && ui.id !== null) {
|
|
115
|
-
id.userId = typeof ui.id === 'string' ? ui.id : JSON.stringify(ui.id);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (!id.userId) {
|
|
119
|
-
const at = creds[`oauth:${id.provider}:access_token`];
|
|
120
|
-
const plain = typeof at === 'string' ? (isEncrypted(at) ? safeDecrypt(at, secret) : at) : null;
|
|
121
|
-
const jwt = plain ? decodeJwt(plain) : null;
|
|
122
|
-
if (jwt) id.userId = jwt.user_id ?? jwt.sub ?? null;
|
|
123
|
-
}
|
|
124
|
-
return id;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function identityLabel(id) {
|
|
128
|
-
return id.displayName || id.username || id.email || null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** 是否已登录:有任意 oauth:*:access_token,或有非空 zcodejwttoken */
|
|
132
|
-
export function isLoggedIn(creds) {
|
|
133
|
-
if (!creds || typeof creds !== 'object') return false;
|
|
134
|
-
if (Object.keys(creds).some((k) => k.startsWith('oauth:') && k.endsWith(':access_token'))) return true;
|
|
135
|
-
const jwt = creds.zcodejwttoken;
|
|
136
|
-
return typeof jwt === 'string' && jwt.trim().length > 0;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const DEVICE_KEY_PREFIX = 'web-remote-control:';
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* 账号去重用的规范化哈希:剔除设备相关键(web-remote-control:…,含远程控制中继密钥,
|
|
143
|
-
* 这些跟着设备走而不是账号,不能影响“是否是同一个登录”的判断)后对 JSON 取 SHA256。
|
|
144
|
-
*/
|
|
145
|
-
function stableStringify(v) {
|
|
146
|
-
if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
|
|
147
|
-
if (v && typeof v === 'object') {
|
|
148
|
-
return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
|
|
149
|
-
}
|
|
150
|
-
return JSON.stringify(v);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function canonicalHash(creds) {
|
|
154
|
-
let obj = creds;
|
|
155
|
-
if (creds && typeof creds === 'object' && !Array.isArray(creds)) {
|
|
156
|
-
obj = Object.fromEntries(Object.entries(creds).filter(([k]) => !k.startsWith(DEVICE_KEY_PREFIX)));
|
|
157
|
-
}
|
|
158
|
-
// 键序无关的稳定序列化:credentials.json 被 ZCode 重写后键序可能变,但同一登录应得同一 hash
|
|
159
|
-
return crypto.createHash('sha256').update(stableStringify(obj)).digest('hex');
|
|
160
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ZCode(智谱 GLM / Z.ai 桌面客户端)本机凭据加解密 — 移植自 zcode-switch(MIT)src-tauri/src/zcrypto.rs。
|
|
3
|
+
*
|
|
4
|
+
* 凭据文件 ~/.zcode/v2/credentials.json 里每个值可能是:
|
|
5
|
+
* - 明文字符串
|
|
6
|
+
* - "enc:v1:<nonce>.<tag>.<密文>"(均为 URL-safe base64,无 padding),AES-256-GCM,
|
|
7
|
+
* key = SHA256(secret),secret 默认 "zcode-credential-fallback:<平台>:<home>:<用户名>",
|
|
8
|
+
* 可用环境变量 ZCODE_CREDENTIAL_SECRET 覆盖(客户端本身也读这个变量)。
|
|
9
|
+
* 纯函数、无 I/O,供 zcodeLocal.js 调用,也便于单测。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
export const ENC_PREFIX = 'enc:v1:';
|
|
15
|
+
|
|
16
|
+
/** node/rust 平台名 → zcode 使用的平台标识(win32 / darwin / linux) */
|
|
17
|
+
export function nodePlatformFor(platform) {
|
|
18
|
+
if (platform === 'windows') return 'win32';
|
|
19
|
+
if (platform === 'macos') return 'darwin';
|
|
20
|
+
return platform;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 未设置 ZCODE_CREDENTIAL_SECRET 时的回退密钥来源 */
|
|
24
|
+
export function defaultSecret(home, { platform = process.platform, username } = {}) {
|
|
25
|
+
const envSecret = process.env.ZCODE_CREDENTIAL_SECRET;
|
|
26
|
+
if (envSecret) return envSecret;
|
|
27
|
+
const user = username || process.env.USERNAME || process.env.USER || process.env.LOGNAME || 'unknown';
|
|
28
|
+
return `zcode-credential-fallback:${platform}:${home}:${user}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function deriveKey(secret) {
|
|
32
|
+
return crypto.createHash('sha256').update(secret, 'utf8').digest();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isEncrypted(v) {
|
|
36
|
+
return typeof v === 'string' && v.startsWith(ENC_PREFIX);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function b64u(s) {
|
|
40
|
+
return Buffer.from(s, 'base64url');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 解密单个 "enc:v1:…" 值;输入非该格式时报错(调用方按需 try/catch 或用 safeDecrypt) */
|
|
44
|
+
export function decryptWithSecret(value, secret) {
|
|
45
|
+
if (!isEncrypted(value)) throw new Error('不是 enc:v1 格式');
|
|
46
|
+
const parts = value.slice(ENC_PREFIX.length).split('.');
|
|
47
|
+
if (parts.length !== 3) throw new Error('enc:v1 格式不正确');
|
|
48
|
+
const [nonceB64, tagB64, ctB64] = parts;
|
|
49
|
+
const nonce = b64u(nonceB64);
|
|
50
|
+
if (nonce.length !== 12) throw new Error('nonce 长度异常');
|
|
51
|
+
const tag = b64u(tagB64);
|
|
52
|
+
const ct = b64u(ctB64);
|
|
53
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(secret), nonce);
|
|
54
|
+
decipher.setAuthTag(tag);
|
|
55
|
+
try {
|
|
56
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error('解密失败(密钥不匹配或数据损坏)');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 加密成 "enc:v1:…"(与 decryptWithSecret 互逆):浏览器登录拿到的凭据按客户端格式落盘,切换时才能被 ZCode 读懂 */
|
|
63
|
+
export function encryptWithSecret(plain, secret) {
|
|
64
|
+
const nonce = crypto.randomBytes(12);
|
|
65
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(secret), nonce);
|
|
66
|
+
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
67
|
+
return ENC_PREFIX + [nonce, cipher.getAuthTag(), ct].map((b) => b.toString('base64url')).join('.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 值可能加密也可能明文,统一转明文;失败返回 null(不抛错) */
|
|
71
|
+
export function safeDecrypt(value, secret) {
|
|
72
|
+
if (typeof value !== 'string') return null;
|
|
73
|
+
if (!isEncrypted(value)) return value;
|
|
74
|
+
try { return decryptWithSecret(value, secret); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 明文/加密的 JSON 字符串 → 解析后的对象;失败返回 null */
|
|
78
|
+
export function decryptJsonOpt(value, secret) {
|
|
79
|
+
const plain = safeDecrypt(value, secret);
|
|
80
|
+
if (plain === null) return null;
|
|
81
|
+
try { return JSON.parse(plain); } catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 解码 JWT payload(不校验签名,仅用于展示/识别用户,与 zcode 客户端自身用途一致) */
|
|
85
|
+
export function decodeJwt(jwt) {
|
|
86
|
+
try {
|
|
87
|
+
const seg = String(jwt).split('.')[1];
|
|
88
|
+
if (!seg) return null;
|
|
89
|
+
return JSON.parse(Buffer.from(seg, 'base64url').toString('utf8'));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 从凭据对象里提取账号身份:{ provider, username, displayName, email, userId }。
|
|
97
|
+
* 逻辑对齐 zcrypto.rs identity_with_secret:先看 active_provider 的 user_info,
|
|
98
|
+
* 找不到 user_id 时退而解码 access_token 这个 JWT。
|
|
99
|
+
*/
|
|
100
|
+
export function identityWithSecret(creds, secret) {
|
|
101
|
+
const id = { provider: 'bigmodel', username: null, displayName: null, email: null, userId: null };
|
|
102
|
+
if (!creds || typeof creds !== 'object') return id;
|
|
103
|
+
const apRaw = creds['oauth:active_provider'];
|
|
104
|
+
if (typeof apRaw === 'string') {
|
|
105
|
+
const plain = isEncrypted(apRaw) ? safeDecrypt(apRaw, secret) : apRaw;
|
|
106
|
+
if (plain) id.provider = plain;
|
|
107
|
+
}
|
|
108
|
+
const ui = decryptJsonOpt(creds[`oauth:${id.provider}:user_info`], secret);
|
|
109
|
+
if (ui && typeof ui === 'object') {
|
|
110
|
+
id.username = typeof ui.username === 'string' ? ui.username : null;
|
|
111
|
+
id.displayName = typeof ui.displayName === 'string' ? ui.displayName : null;
|
|
112
|
+
id.email = typeof ui.email === 'string' ? ui.email
|
|
113
|
+
: typeof ui.rawProfile?.email === 'string' ? ui.rawProfile.email : null;
|
|
114
|
+
if (ui.id !== undefined && ui.id !== null) {
|
|
115
|
+
id.userId = typeof ui.id === 'string' ? ui.id : JSON.stringify(ui.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!id.userId) {
|
|
119
|
+
const at = creds[`oauth:${id.provider}:access_token`];
|
|
120
|
+
const plain = typeof at === 'string' ? (isEncrypted(at) ? safeDecrypt(at, secret) : at) : null;
|
|
121
|
+
const jwt = plain ? decodeJwt(plain) : null;
|
|
122
|
+
if (jwt) id.userId = jwt.user_id ?? jwt.sub ?? null;
|
|
123
|
+
}
|
|
124
|
+
return id;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function identityLabel(id) {
|
|
128
|
+
return id.displayName || id.username || id.email || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 是否已登录:有任意 oauth:*:access_token,或有非空 zcodejwttoken */
|
|
132
|
+
export function isLoggedIn(creds) {
|
|
133
|
+
if (!creds || typeof creds !== 'object') return false;
|
|
134
|
+
if (Object.keys(creds).some((k) => k.startsWith('oauth:') && k.endsWith(':access_token'))) return true;
|
|
135
|
+
const jwt = creds.zcodejwttoken;
|
|
136
|
+
return typeof jwt === 'string' && jwt.trim().length > 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const DEVICE_KEY_PREFIX = 'web-remote-control:';
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 账号去重用的规范化哈希:剔除设备相关键(web-remote-control:…,含远程控制中继密钥,
|
|
143
|
+
* 这些跟着设备走而不是账号,不能影响“是否是同一个登录”的判断)后对 JSON 取 SHA256。
|
|
144
|
+
*/
|
|
145
|
+
function stableStringify(v) {
|
|
146
|
+
if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
|
|
147
|
+
if (v && typeof v === 'object') {
|
|
148
|
+
return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
|
|
149
|
+
}
|
|
150
|
+
return JSON.stringify(v);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function canonicalHash(creds) {
|
|
154
|
+
let obj = creds;
|
|
155
|
+
if (creds && typeof creds === 'object' && !Array.isArray(creds)) {
|
|
156
|
+
obj = Object.fromEntries(Object.entries(creds).filter(([k]) => !k.startsWith(DEVICE_KEY_PREFIX)));
|
|
157
|
+
}
|
|
158
|
+
// 键序无关的稳定序列化:credentials.json 被 ZCode 重写后键序可能变,但同一登录应得同一 hash
|
|
159
|
+
return crypto.createHash('sha256').update(stableStringify(obj)).digest('hex');
|
|
160
|
+
}
|