qq-bot-roost 0.9.7 → 0.9.9
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/README.md +6 -5
- package/package.json +5 -4
- package/src/ledger.js +8 -4
- package/src/message.js +14 -1
- package/src/outbound.js +3 -2
- package/src/plugin/loader.js +77 -0
- package/src/plugin/manifest.js +149 -0
- package/src/plugin/market.js +70 -3
- package/src/plugin/protocol.js +2 -0
- package/src/storage/events-schema.js +29 -1
- package/src/tarball.js +32 -4
- package/src/trace.js +28 -0
- package/src/trust.js +264 -0
- package/src/updater.js +58 -8
- package/src/web/server.js +55 -4
- package/web/app.js +63 -0
- package/web/index.html +18 -0
- package/web/style.css +20 -0
package/src/tarball.js
CHANGED
|
@@ -34,18 +34,32 @@ export function parseTar(buf) {
|
|
|
34
34
|
let off = 0;
|
|
35
35
|
|
|
36
36
|
while (off + 512 <= buf.length) {
|
|
37
|
+
/** 本轮起点:用来判断「有没有真的往前走」,见下面的 next 检查 */
|
|
38
|
+
const start = off;
|
|
37
39
|
const header = buf.subarray(off, off + 512);
|
|
38
40
|
// 全零块 = 结束
|
|
39
41
|
if (header.every((b) => b === 0)) break;
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* 参数叫 `pos` 而不是 `start`:外层那个 `start` 是「本轮在 buf 里的起点」,
|
|
45
|
+
* 两者含义完全不同,同名遮蔽会让人以为能互相引用。
|
|
46
|
+
*/
|
|
47
|
+
const readStr = (pos, len) => {
|
|
48
|
+
const slice = header.subarray(pos, pos + len);
|
|
43
49
|
const end = slice.indexOf(0);
|
|
44
50
|
return slice.subarray(0, end === -1 ? len : end).toString('utf8');
|
|
45
51
|
};
|
|
46
52
|
|
|
47
53
|
const name = readStr(0, 100);
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* size 必须夹到非负。
|
|
56
|
+
*
|
|
57
|
+
* 八进制字段里出现 `-` 时 `parseInt` 会给出负数,后面
|
|
58
|
+
* `Math.ceil(size / 512) * 512` 就是负的,游标「前进 512 又倒退 512」
|
|
59
|
+
* 原地打转——而这个循环每轮都 push 一条条目,于是**内存直接爆掉**。
|
|
60
|
+
* 一个几十字节的畸形 tar 就能把机器人打死(2026-10-31 用 fuzz 撞出来的)。
|
|
61
|
+
*/
|
|
62
|
+
const size = Math.max(0, parseInt(readStr(124, 12).trim(), 8) || 0);
|
|
49
63
|
const type = String.fromCharCode(header[156]) || '0';
|
|
50
64
|
// ustar 前缀,长路径会用它
|
|
51
65
|
const prefix = readStr(345, 155);
|
|
@@ -53,7 +67,21 @@ export function parseTar(buf) {
|
|
|
53
67
|
|
|
54
68
|
off += 512;
|
|
55
69
|
const content = buf.subarray(off, off + size);
|
|
56
|
-
|
|
70
|
+
/**
|
|
71
|
+
* 游标必须严格前进。即使 size 已经夹到非负,这里再兜一道:
|
|
72
|
+
* 畸形头导致的任何「原地踏步」都会变成死循环 + 内存爆炸,
|
|
73
|
+
* 而正常的 tar 每轮至少前进 512 字节,不可能触发。
|
|
74
|
+
*/
|
|
75
|
+
const next = off + Math.ceil(size / 512) * 512;
|
|
76
|
+
/**
|
|
77
|
+
* 和**本轮起点**比,不是和 `off` 比。
|
|
78
|
+
*
|
|
79
|
+
* `size = 0`(目录条目)时 `next` 正好等于 `off`,拿 `off` 比会把
|
|
80
|
+
* 正常的空条目误判成「没前进」而提前结束——第一版就是这么写的,
|
|
81
|
+
* 结果一个含目录的正常 tar 只解析出第一条。
|
|
82
|
+
*/
|
|
83
|
+
if (next <= start) break;
|
|
84
|
+
off = next;
|
|
57
85
|
|
|
58
86
|
if (type === '5') entries.push({ name: fullName, dir: true });
|
|
59
87
|
else if (type === '0' || type === '\0') entries.push({ name: fullName, dir: false, content });
|
package/src/trace.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 链路追踪 id
|
|
3
|
+
* ===========
|
|
4
|
+
*
|
|
5
|
+
* 一条消息要穿过四段:QQ 事件 → Roost 内核 → ai-chat 插件 → dsh-roost → 模型。
|
|
6
|
+
* 出问题时现在只能翻四个日志文件对时间戳——用户说「刚才那条没回」,
|
|
7
|
+
* 根本不知道该看哪一段。
|
|
8
|
+
*
|
|
9
|
+
* 这里生成一个短 id,进站时打上,每段日志都带着它,面板也能按它串起来。
|
|
10
|
+
*
|
|
11
|
+
* 为什么不是 UUID:要能在日志里一眼扫到、能手打、能贴进群里问。
|
|
12
|
+
* 时间前缀(36 进制)让同一秒内的 id 也有序,后面 6 位十六进制够区分。
|
|
13
|
+
* 碰撞概率:同一毫秒内 1/1677 万,对一个群聊机器人足够。
|
|
14
|
+
*/
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @returns {string} 形如 `m3k9x2a1f3c4`
|
|
19
|
+
*/
|
|
20
|
+
export function newTraceId() {
|
|
21
|
+
return `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 日志前缀:统一成 `[trace:xxxx]`,方便 grep */
|
|
25
|
+
export function traceTag(id) {
|
|
26
|
+
const t = String(id ?? '').trim();
|
|
27
|
+
return t ? `[trace:${t}]` : '';
|
|
28
|
+
}
|
package/src/trust.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供应链信任:签名与验签
|
|
3
|
+
* ======================
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它:`index.json` 里的 `sha256` 和索引本身同仓同源,能改索引的人
|
|
6
|
+
* 顺手就能把哈希一起改了,校验照过。哈希只证明「内容和这份清单一致」,
|
|
7
|
+
* 不证明「这份清单是作者发的」——后者需要**独立于业务仓的密钥**。
|
|
8
|
+
*
|
|
9
|
+
* 所以这里只做一件事:**用一把离线私钥给清单签名,用内置公钥验签**。
|
|
10
|
+
* 哈希仍然保留(它挡的是传输篡改和内容漂移),两者职责不同。
|
|
11
|
+
*
|
|
12
|
+
* 设计取舍:
|
|
13
|
+
* · 签的是**规范化后的 JSON**,不是原始字节。镜像站可能重新序列化
|
|
14
|
+
* (缩进、键序、末尾换行),签字节会被这些无关差异搞挂。
|
|
15
|
+
* · 规范化规则必须稳定且可复现:键排序、无空白、数组保序。
|
|
16
|
+
* · 信封格式 `{alg, keyId, sig}`,也接受裸 base64,方便手工签。
|
|
17
|
+
* · **没有公钥 = unavailable,不是通过也不是拒绝**。调用方必须显式决定
|
|
18
|
+
* 怎么处理,不能让「没配公钥」悄悄变成放行。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
/** 发版时填进来的内置公钥(PEM 或 32 字节 base64)。留空 = 本版不启用验签。 */
|
|
24
|
+
export const EMBEDDED_PUBKEY = 'bqmZDZi/gnW7FppqKCOa5Zvc1XUvT/vw7fiiHR2hcIo=';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 内置强制开关。
|
|
28
|
+
*
|
|
29
|
+
* **发版时把它改成 `true`,所有没显式配置的用户就默认开启强制验签**——
|
|
30
|
+
* 这就是「后续一个更新就能马上打开」的那个开关。
|
|
31
|
+
*
|
|
32
|
+
* 为什么不在这次直接开:签名文件还没铺到索引里,现在开会让所有安装失败。
|
|
33
|
+
* 顺序必须是「先发签名 → 再开开关」。
|
|
34
|
+
*
|
|
35
|
+
* 优先级(见 `resolveTrust`):
|
|
36
|
+
* 配置 `security.trust.enforce` > 环境变量 `ROOST_TRUST_ENFORCE` > 本常量
|
|
37
|
+
*/
|
|
38
|
+
export const EMBEDDED_ENFORCE = false;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* **发版时保持 false。** 签名链路是 beta:
|
|
42
|
+
* · `index.json.sig` 还没跟着一次正式发版走完流程
|
|
43
|
+
* · 现在打开会让没签名的插件装不上
|
|
44
|
+
* 等发版验证过一轮,再决定是否默认开启(或继续让用户自己在面板里开)。
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 规范化:键排序 + 无空白 + 数组保序。
|
|
49
|
+
*
|
|
50
|
+
* 不能直接用 `JSON.stringify(data)`——它按插入顺序输出键,
|
|
51
|
+
* 同一个对象经过一次 parse/stringify 往返就可能换序。
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} v
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function canonicalize(v) {
|
|
57
|
+
if (v === undefined) throw new Error('canonicalize 不接受 undefined');
|
|
58
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
59
|
+
if (Array.isArray(v)) return `[${v.map((x) => canonicalize(x)).join(',')}]`;
|
|
60
|
+
const keys = Object.keys(v).sort();
|
|
61
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(v[k])}`).join(',')}}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 文本规范化:CRLF → LF,去掉末尾空白。
|
|
66
|
+
*
|
|
67
|
+
* 为什么需要:`SHA256SUMS` 这类纯文本文件会被 git 的 `eol` 属性和编辑器改写
|
|
68
|
+
* 换行符。签原始字节的话,Windows 检出一次签名就失效了。
|
|
69
|
+
*
|
|
70
|
+
* 注意:JSON 不走这里,走 `canonicalize`。
|
|
71
|
+
*
|
|
72
|
+
* @param {string} s
|
|
73
|
+
*/
|
|
74
|
+
export function normalizeText(s) {
|
|
75
|
+
return String(s ?? '').replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 把 PEM / 裸 base64 / JWK 统一成 KeyObject。
|
|
80
|
+
*
|
|
81
|
+
* @param {string} text
|
|
82
|
+
* @returns {import('node:crypto').KeyObject|null}
|
|
83
|
+
*/
|
|
84
|
+
export function publicKeyFrom(text) {
|
|
85
|
+
const t = String(text ?? '').trim();
|
|
86
|
+
if (!t) return null;
|
|
87
|
+
try {
|
|
88
|
+
if (t.includes('BEGIN PUBLIC KEY')) return crypto.createPublicKey(t);
|
|
89
|
+
const raw = Buffer.from(t, 'base64');
|
|
90
|
+
if (raw.length !== 32) return null;
|
|
91
|
+
return crypto.createPublicKey({
|
|
92
|
+
key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') },
|
|
93
|
+
format: 'jwk',
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 签名。Ed25519 的算法参数是 `null`(不是 'sha256')。
|
|
102
|
+
*
|
|
103
|
+
* @param {string} text 已规范化的内容
|
|
104
|
+
* @param {string} privateKeyPem
|
|
105
|
+
* @returns {string} base64 签名
|
|
106
|
+
*/
|
|
107
|
+
export function signText(text, privateKeyPem) {
|
|
108
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
109
|
+
return crypto.sign(null, Buffer.from(text, 'utf8'), key).toString('base64');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 验签。任何异常都返回 false —— 验签失败的原因不该抛给上层,
|
|
114
|
+
* 上层只需要「过 / 不过」和一句能看懂的话。
|
|
115
|
+
*
|
|
116
|
+
* @param {string} text
|
|
117
|
+
* @param {string} sigB64
|
|
118
|
+
* @param {string} publicKeyPem
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function verifyText(text, sigB64, publicKeyPem) {
|
|
122
|
+
try {
|
|
123
|
+
const key = typeof publicKeyPem === 'string' ? publicKeyFrom(publicKeyPem) : publicKeyPem;
|
|
124
|
+
if (!key) return false;
|
|
125
|
+
return crypto.verify(null, Buffer.from(text, 'utf8'), key, Buffer.from(String(sigB64), 'base64'));
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 解析签名信封。接受 `{alg,keyId,sig}` 或裸 base64。
|
|
133
|
+
*
|
|
134
|
+
* @param {string} raw
|
|
135
|
+
* @returns {{alg:string, keyId:string, sig:string}|null}
|
|
136
|
+
*/
|
|
137
|
+
export function parseSig(raw) {
|
|
138
|
+
const t = String(raw ?? '').trim();
|
|
139
|
+
if (!t) return null;
|
|
140
|
+
if (t.startsWith('{')) {
|
|
141
|
+
try {
|
|
142
|
+
const o = JSON.parse(t);
|
|
143
|
+
if (!o || typeof o.sig !== 'string' || !o.sig) return null;
|
|
144
|
+
return { alg: String(o.alg ?? ''), keyId: String(o.keyId ?? ''), sig: o.sig };
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { alg: 'ed25519', keyId: '', sig: t };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 从配置解析信任策略。
|
|
154
|
+
*
|
|
155
|
+
* `security.trust = { pubkey, enforce, allowUnsigned: [] }`
|
|
156
|
+
* 也接受直接传 trust 对象(测试用)。
|
|
157
|
+
*
|
|
158
|
+
* @param {object} cfg
|
|
159
|
+
*/
|
|
160
|
+
export function resolveTrust(cfg = {}) {
|
|
161
|
+
const t = cfg?.security?.trust ?? cfg ?? {};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 优先级:配置(面板开关)> 环境变量 > 内置常量。
|
|
165
|
+
*
|
|
166
|
+
* **配置必须最后生效**:面板上的开关是用户能看见的唯一入口,
|
|
167
|
+
* 如果环境变量能压过它,就会出现「我明明关了它还拦」这种查不出来的怪事。
|
|
168
|
+
* 环境变量只作为没写配置时的兜底(排查、临时灰度)。
|
|
169
|
+
*/
|
|
170
|
+
let enforce = EMBEDDED_ENFORCE;
|
|
171
|
+
const env = String(process.env.ROOST_TRUST_ENFORCE ?? '').trim().toLowerCase();
|
|
172
|
+
if (env === '1' || env === 'true') enforce = true;
|
|
173
|
+
else if (env === '0' || env === 'false') enforce = false;
|
|
174
|
+
if (t.enforce !== undefined) enforce = t.enforce === true;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
pubkey: String(t.pubkey ?? '') || EMBEDDED_PUBKEY,
|
|
178
|
+
enforce,
|
|
179
|
+
allowUnsigned: Array.isArray(t.allowUnsigned) ? t.allowUnsigned.map(String) : [],
|
|
180
|
+
/** 额外声明为「官方源」的地址(自建代理 / 镜像用),默认只认 market.js 里的官方地址 */
|
|
181
|
+
officialUrls: Array.isArray(t.officialUrls) ? t.officialUrls.map(String) : [],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 公钥能不能用。
|
|
187
|
+
*
|
|
188
|
+
* 面板要拿它区分「没配」和「配错了」——这两种都让开关不起作用,
|
|
189
|
+
* 但用户的修法完全不同:一个是去配,一个是去改。
|
|
190
|
+
*
|
|
191
|
+
* @param {string} pubkey
|
|
192
|
+
*/
|
|
193
|
+
export function pubkeyUsable(pubkey) {
|
|
194
|
+
return Boolean(publicKeyFrom(pubkey));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 校验一份清单。
|
|
199
|
+
*
|
|
200
|
+
* 返回的 `status` 有五种,调用方必须显式处理每一种:
|
|
201
|
+
* ok 签名有效
|
|
202
|
+
* unsigned 有公钥、没签名文件
|
|
203
|
+
* bad 签名存在但不匹配(内容被改过 / 用错密钥)
|
|
204
|
+
* invalid 配了公钥但解析不了(**不是「没配」**,强制时必须拦)
|
|
205
|
+
* unavailable 没配公钥,本版无法验签
|
|
206
|
+
*
|
|
207
|
+
* @param {{data:unknown, sigText?:string, trust:object, kind?:string}} o
|
|
208
|
+
*/
|
|
209
|
+
export function checkTrust({ data, sigText, trust, kind = '清单' }) {
|
|
210
|
+
if (!trust?.pubkey) return { status: 'unavailable', why: `没有配置信任公钥,${kind}未验签` };
|
|
211
|
+
/**
|
|
212
|
+
* 配了但解析不了 → `invalid`,不能并进 `unavailable`。
|
|
213
|
+
*
|
|
214
|
+
* 并进去的后果很严重:用户把公钥粘错一个字符、打开强制开关,然后
|
|
215
|
+
* **一切照常放行**——他以为受保护了,实际什么都没验。这类「安全开关
|
|
216
|
+
* 看起来开着、其实没生效」比没有开关更危险,所以必须单独一个状态。
|
|
217
|
+
*/
|
|
218
|
+
if (!publicKeyFrom(trust.pubkey)) {
|
|
219
|
+
return { status: 'invalid', why: '信任公钥无法解析(应为 PEM 或 32 字节 base64)' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const env = parseSig(sigText);
|
|
223
|
+
if (!env) return { status: 'unsigned', why: `${kind}没有签名文件` };
|
|
224
|
+
if (env.alg && env.alg !== 'ed25519') return { status: 'bad', why: `不支持的签名算法:${env.alg}` };
|
|
225
|
+
|
|
226
|
+
const ok = verifyText(canonicalize(data), env.sig, trust.pubkey);
|
|
227
|
+
if (!ok) return { status: 'bad', why: `${kind}的签名与内容不匹配` };
|
|
228
|
+
return { status: 'ok', keyId: env.keyId };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 该不该拦。
|
|
233
|
+
*
|
|
234
|
+
* 返回 `null` = 放行;返回字符串 = 拦截理由。
|
|
235
|
+
*
|
|
236
|
+
* **只对官方源强制**。理由:手写插件不走市场索引(`loader.js` 只扫 `plugins/`
|
|
237
|
+
* 目录,完全不碰验签),所以「用户自己写插件」这条路从来不受影响;
|
|
238
|
+
* 而用户自己填的 `plugins.marketUrl` 属于他自己的信任决定——他信那个仓库,
|
|
239
|
+
* 我们没资格替他拦。签名要防的是「官方渠道被悄悄换掉」,不是「用户自己的源」。
|
|
240
|
+
*
|
|
241
|
+
* `unavailable` **永远放行**:没配公钥就拦,等于让所有没升级配置的用户
|
|
242
|
+
* 一次性断掉,那不是安全,是事故。
|
|
243
|
+
*
|
|
244
|
+
* @param {object} trust
|
|
245
|
+
* @param {string} status
|
|
246
|
+
* @param {string} [id]
|
|
247
|
+
* @param {{official?: boolean}} [opts]
|
|
248
|
+
* @returns {string|null}
|
|
249
|
+
*/
|
|
250
|
+
export function blockReason(trust, status, id = '', opts = {}) {
|
|
251
|
+
if (!trust?.enforce) return null;
|
|
252
|
+
if (opts.official === false) return null;
|
|
253
|
+
if (status === 'ok' || status === 'unavailable') return null;
|
|
254
|
+
/**
|
|
255
|
+
* 配错公钥必须拦:否则「开关开着、实际没验」,用户以为受保护了。
|
|
256
|
+
* 这条不能算进 `unavailable`——那个是「没配」,放行是有意的。
|
|
257
|
+
*/
|
|
258
|
+
if (status === 'invalid') {
|
|
259
|
+
return '信任公钥无法解析,强制校验无法生效(检查 security.trust.pubkey,或等内置公钥的版本)';
|
|
260
|
+
}
|
|
261
|
+
if (status === 'unsigned' && trust.allowUnsigned.includes(String(id))) return null;
|
|
262
|
+
if (status === 'bad') return '签名校验失败:内容与签名不一致(可能被篡改,或用错了密钥)';
|
|
263
|
+
return `没有签名,且不在 allowUnsigned 白名单里(${id || '该项'})`;
|
|
264
|
+
}
|
package/src/updater.js
CHANGED
|
@@ -23,6 +23,8 @@ import { fetchGiteeTree, detectGiteeRef, giteeRawUrl } from './gitee.js';
|
|
|
23
23
|
import { hashDir } from './hashdir.js';
|
|
24
24
|
import { CODE_ENTRIES, PROTECTED_ENTRIES as PROTECTED } from './update-scope.js';
|
|
25
25
|
import { ROOT } from './config.js';
|
|
26
|
+
import { loadGlobalConfig } from './config.js';
|
|
27
|
+
import { checkTrust, resolveTrust, normalizeText, blockReason } from './trust.js';
|
|
26
28
|
|
|
27
29
|
const log = createLogger('update');
|
|
28
30
|
|
|
@@ -108,9 +110,9 @@ async function updatedButNotRestarted() {
|
|
|
108
110
|
* 两个源都试:GitHub raw(经下载源改写)和 Gitee raw。任何一边拿到都行,
|
|
109
111
|
* 因为指纹是「代码内容」的,与从哪个源下载无关。
|
|
110
112
|
*
|
|
111
|
-
* @returns {Promise<Map<string,string
|
|
113
|
+
* @returns {Promise<{map: Map<string,string>, trust: object}>} 版本号 → 64 位十六进制,以及验签结论
|
|
112
114
|
*/
|
|
113
|
-
async function
|
|
115
|
+
async function fetchSums() {
|
|
114
116
|
const urls = [
|
|
115
117
|
`https://raw.githubusercontent.com/${REPO}/master/SHA256SUMS`,
|
|
116
118
|
`https://gitee.com/${REPO}/raw/master/SHA256SUMS`,
|
|
@@ -128,12 +130,40 @@ async function fetchSumsMap() {
|
|
|
128
130
|
const m = /^([a-f0-9]{64})\s+v?([0-9][\w.+-]*)$/i.exec(line.trim());
|
|
129
131
|
if (m) map.set(m[2].replace(/^v/i, ''), m[1].toLowerCase());
|
|
130
132
|
}
|
|
131
|
-
if (map.size)
|
|
133
|
+
if (!map.size) continue;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 验签。指纹和代码同仓,只验指纹等于「让被篡改的清单自己证明自己」——
|
|
137
|
+
* 必须由独立密钥签过的 `SHA256SUMS.sig` 背书。
|
|
138
|
+
*
|
|
139
|
+
* 本版只记录结论、不阻断(`enforce` 还没接进来);`bad` 要打 error,
|
|
140
|
+
* 因为它意味着「清单内容和签名对不上」,比「没签名」严重得多。
|
|
141
|
+
*/
|
|
142
|
+
let sigText = '';
|
|
143
|
+
try {
|
|
144
|
+
const sres = await fetchViaMirror(`${u}.sig`, {
|
|
145
|
+
headers: { 'User-Agent': 'qq-bot-roost-updater' },
|
|
146
|
+
signal: AbortSignal.timeout(8000),
|
|
147
|
+
});
|
|
148
|
+
if (sres.ok) sigText = await sres.text();
|
|
149
|
+
} catch {
|
|
150
|
+
// 拿不到签名文件不是网络错误,继续按「未签名」处理
|
|
151
|
+
}
|
|
152
|
+
const trust = checkTrust({
|
|
153
|
+
data: normalizeText(text),
|
|
154
|
+
sigText,
|
|
155
|
+
trust: resolveTrust({}),
|
|
156
|
+
kind: 'SHA256SUMS',
|
|
157
|
+
});
|
|
158
|
+
if (trust.status === 'bad' || trust.status === 'invalid') log.error(trust.why);
|
|
159
|
+
else if (trust.status === 'ok') log.info('SHA256SUMS 签名校验通过');
|
|
160
|
+
|
|
161
|
+
return { map, trust };
|
|
132
162
|
} catch {
|
|
133
163
|
// 换下一个源
|
|
134
164
|
}
|
|
135
165
|
}
|
|
136
|
-
return new Map();
|
|
166
|
+
return { map: new Map(), trust: { status: 'unavailable', why: '没有拿到 SHA256SUMS' } };
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
/**
|
|
@@ -142,10 +172,10 @@ async function fetchSumsMap() {
|
|
|
142
172
|
*/
|
|
143
173
|
async function fetchTreeSha256(version) {
|
|
144
174
|
try {
|
|
145
|
-
const map = await
|
|
146
|
-
return map.get(String(version).replace(/^v/, '')) ?? null;
|
|
175
|
+
const { map, trust } = await fetchSums();
|
|
176
|
+
return { sha: map.get(String(version).replace(/^v/, '')) ?? null, trust };
|
|
147
177
|
} catch {
|
|
148
|
-
return null;
|
|
178
|
+
return { sha: null, trust: { status: 'unavailable', why: '查询指纹失败' } };
|
|
149
179
|
}
|
|
150
180
|
}
|
|
151
181
|
|
|
@@ -153,7 +183,11 @@ export async function checkUpdate() {
|
|
|
153
183
|
const needsRestart = await updatedButNotRestarted();
|
|
154
184
|
const r = await checkUpdateCore();
|
|
155
185
|
// 有新版本才值得多跑一次网络请求
|
|
156
|
-
if (r?.ok && r.hasUpdate)
|
|
186
|
+
if (r?.ok && r.hasUpdate) {
|
|
187
|
+
const info = await fetchTreeSha256(r.latest);
|
|
188
|
+
r.treeSha256 = info.sha;
|
|
189
|
+
r.sumsTrust = info.trust;
|
|
190
|
+
}
|
|
157
191
|
return { ...r, needsRestart };
|
|
158
192
|
}
|
|
159
193
|
|
|
@@ -477,6 +511,22 @@ async function applyUpdateOnce(info) {
|
|
|
477
511
|
if (!rel.hasUpdate) throw new Error(`已经是最新版本 ${cur}`);
|
|
478
512
|
if (!rel.tarball) throw new Error('没有可下载的包');
|
|
479
513
|
|
|
514
|
+
/**
|
|
515
|
+
* 签名闸门:和插件市场共用面板上那个开关。
|
|
516
|
+
*
|
|
517
|
+
* 为什么放在下载之前:被拒的时候用户什么都没损失,不用先下一份再删。
|
|
518
|
+
* `rel.sumsTrust` 是 `checkUpdate()` 拉 `SHA256SUMS` 时顺手算出来的,
|
|
519
|
+
* 没拿到就当 `unavailable`(没配公钥 / 网络问题),按「不拦」处理。
|
|
520
|
+
*/
|
|
521
|
+
const trustCfg = resolveTrust(await loadGlobalConfig().catch(() => ({})));
|
|
522
|
+
const trustWhy = blockReason(trustCfg, rel.sumsTrust?.status || 'unavailable', '', { official: true });
|
|
523
|
+
if (trustWhy) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`自更新被拒绝:${trustWhy}\n` +
|
|
526
|
+
'你的代码没有任何改动。可以到 GitHub Releases 手动下载安装,或先在设置里关掉「插件来源校验」。',
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
480
530
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
481
531
|
const workDir = path.join(ROOT, 'data', `.update-${stamp}`);
|
|
482
532
|
const backupDir = path.join(ROOT, 'data', 'backups', `${cur}-${stamp}`);
|
package/src/web/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
mergeWithInstalled,
|
|
17
17
|
detectUpdates,
|
|
18
18
|
compareVersion,
|
|
19
|
+
isOfficialSource,
|
|
19
20
|
} from '../plugin/market.js';
|
|
20
21
|
import {
|
|
21
22
|
checkUpdate as checkFrameworkUpdate,
|
|
@@ -32,6 +33,7 @@ import { onMessage as onBusMessage } from '../message-bus.js';
|
|
|
32
33
|
import { handleQqCall, capabilityPayload } from './qq-call.js';
|
|
33
34
|
import { runDoctor, summarize } from '../doctor.js';
|
|
34
35
|
import { createBindTask, pollBindResult } from '../qq/qrlogin.js';
|
|
36
|
+
import { resolveTrust, blockReason, pubkeyUsable } from '../trust.js';
|
|
35
37
|
import { toSvg as qrToSvg } from '../qq/qrsvg.js';
|
|
36
38
|
import {
|
|
37
39
|
PRESETS,
|
|
@@ -851,7 +853,7 @@ export async function startWebServer({
|
|
|
851
853
|
|
|
852
854
|
const out = { at: Date.now(), updates: [], error: '' };
|
|
853
855
|
try {
|
|
854
|
-
const idx = await fetchIndex(globalCfg.plugins?.marketUrl || undefined);
|
|
856
|
+
const idx = await fetchIndex(globalCfg.plugins?.marketUrl || undefined, { trust: resolveTrust(globalCfg) });
|
|
855
857
|
const localVers = await r.plugins.localVersions();
|
|
856
858
|
out.updates = detectUpdates(idx.plugins, localVers).map((u) => ({
|
|
857
859
|
id: u.id,
|
|
@@ -1336,6 +1338,7 @@ export async function startWebServer({
|
|
|
1336
1338
|
|
|
1337
1339
|
// 配置读取(隐藏密钥)
|
|
1338
1340
|
if (pathname === '/api/config' && req.method === 'GET') {
|
|
1341
|
+
const trust = resolveTrust(globalCfg);
|
|
1339
1342
|
return json(res, 200, {
|
|
1340
1343
|
...cfg,
|
|
1341
1344
|
// webui 和 logLevel 是**全局**设置(config/bot.json),所有机器人共用,
|
|
@@ -1348,6 +1351,19 @@ export async function startWebServer({
|
|
|
1348
1351
|
accessKeySet: Boolean(globalCfg.webui?.accessKey),
|
|
1349
1352
|
},
|
|
1350
1353
|
logLevel: globalCfg.logLevel ?? 'info',
|
|
1354
|
+
/**
|
|
1355
|
+
* 插件来源校验(全局设置)。只回「开没开」和「有没有公钥」——
|
|
1356
|
+
* 公钥本身是公开的,但配置里可能是整段 PEM,回显没意义还占地方。
|
|
1357
|
+
* `pubkeySet: false` 时面板要提示「开关不起作用」。
|
|
1358
|
+
*/
|
|
1359
|
+
security: {
|
|
1360
|
+
trust: {
|
|
1361
|
+
enforce: trust.enforce,
|
|
1362
|
+
pubkeySet: Boolean(trust.pubkey),
|
|
1363
|
+
// 「配了但解析不了」和「没配」要分开:前者开开关等于没开
|
|
1364
|
+
pubkeyValid: pubkeyUsable(trust.pubkey),
|
|
1365
|
+
},
|
|
1366
|
+
},
|
|
1351
1367
|
appSecret: cfg.appSecret ? '********' : '',
|
|
1352
1368
|
appSecretSet: Boolean(cfg.appSecret),
|
|
1353
1369
|
});
|
|
@@ -1409,6 +1425,21 @@ export async function startWebServer({
|
|
|
1409
1425
|
await saveGlobalConfig?.(globalCfg).catch(() => {});
|
|
1410
1426
|
configureMirror(globalCfg);
|
|
1411
1427
|
}
|
|
1428
|
+
/**
|
|
1429
|
+
* 插件来源校验开关(全局,默认关)。
|
|
1430
|
+
*
|
|
1431
|
+
* 只接受布尔值:面板传字符串 "false" 时如果按真值判断,会把「关」
|
|
1432
|
+
* 读成「开」——这类错误在安全开关上代价最大,所以这里严格比类型。
|
|
1433
|
+
*
|
|
1434
|
+
* 立即生效,不需要重启:`resolveTrust` 每次调用都现读 `globalCfg`。
|
|
1435
|
+
*/
|
|
1436
|
+
if (body.security?.trust && typeof body.security.trust.enforce === 'boolean') {
|
|
1437
|
+
globalCfg.security = {
|
|
1438
|
+
...(globalCfg.security ?? {}),
|
|
1439
|
+
trust: { ...(globalCfg.security?.trust ?? {}), enforce: body.security.trust.enforce },
|
|
1440
|
+
};
|
|
1441
|
+
await saveGlobalConfig?.(globalCfg).catch(() => {});
|
|
1442
|
+
}
|
|
1412
1443
|
if (body.whitelist && typeof body.whitelist === 'object') {
|
|
1413
1444
|
cfg.whitelist = {
|
|
1414
1445
|
enabled: Boolean(body.whitelist.enabled),
|
|
@@ -1948,7 +1979,7 @@ export async function startWebServer({
|
|
|
1948
1979
|
if (pathname === '/api/market' && req.method === 'GET') {
|
|
1949
1980
|
const url2 = url.searchParams.get('source') || globalCfg.plugins?.marketUrl || undefined;
|
|
1950
1981
|
try {
|
|
1951
|
-
const idx = await fetchIndex(url2);
|
|
1982
|
+
const idx = await fetchIndex(url2, { trust: resolveTrust(globalCfg) });
|
|
1952
1983
|
const installedIds = new Set(plugins.loaded.keys());
|
|
1953
1984
|
// 目录里存在但没加载成功的也算「已装」
|
|
1954
1985
|
try {
|
|
@@ -1970,6 +2001,8 @@ export async function startWebServer({
|
|
|
1970
2001
|
latest: u.latest,
|
|
1971
2002
|
})),
|
|
1972
2003
|
localVersions: localVers,
|
|
2004
|
+
// 索引签名状态:ok / unsigned / bad / unavailable(面板可据此提示)
|
|
2005
|
+
trust: idx?._trust?.status || 'unavailable',
|
|
1973
2006
|
// 网络挂了用的是本地缓存,面板要提示用户「这是旧数据」
|
|
1974
2007
|
stale: idx.stale === true,
|
|
1975
2008
|
staleAge: idx.staleAge || '',
|
|
@@ -1986,10 +2019,19 @@ export async function startWebServer({
|
|
|
1986
2019
|
if (!id) return json(res, 400, { error: '缺少插件 id' });
|
|
1987
2020
|
try {
|
|
1988
2021
|
const src = globalCfg.plugins?.marketUrl || undefined;
|
|
1989
|
-
const idx = await fetchIndex(src);
|
|
2022
|
+
const idx = await fetchIndex(src, { trust: resolveTrust(globalCfg) });
|
|
1990
2023
|
const entry = idx.plugins.find((p) => p.id === id);
|
|
1991
2024
|
if (!entry) return json(res, 404, { error: `市场里没有 ${id}` });
|
|
1992
2025
|
|
|
2026
|
+
/**
|
|
2027
|
+
* 信任闸。**只对官方源生效**:手写插件走 `plugins/` 目录,根本不经过
|
|
2028
|
+
* 市场索引;用户自己填的市场源是他自己的信任决定,不拦。
|
|
2029
|
+
* 本版 `enforce` 默认 false,所以这里恒放行。
|
|
2030
|
+
*/
|
|
2031
|
+
const official = isOfficialSource(src, globalCfg);
|
|
2032
|
+
const trustWhy = blockReason(resolveTrust(globalCfg), idx?._trust?.status || 'unavailable', id, { official });
|
|
2033
|
+
if (trustWhy) return json(res, 403, { error: trustWhy });
|
|
2034
|
+
|
|
1993
2035
|
/**
|
|
1994
2036
|
* 下载 + 解包期间要挂起插件热重载。
|
|
1995
2037
|
*
|
|
@@ -2614,10 +2656,19 @@ export async function startWebServer({
|
|
|
2614
2656
|
let repo = null;
|
|
2615
2657
|
try {
|
|
2616
2658
|
const src = globalCfg.plugins?.marketUrl || undefined;
|
|
2617
|
-
const idx = await fetchIndex(src);
|
|
2659
|
+
const idx = await fetchIndex(src, { trust: resolveTrust(globalCfg) });
|
|
2618
2660
|
const entry = idx.plugins.find((p) => p.id === id);
|
|
2619
2661
|
if (!entry) return json(res, 404, { error: `市场里没有 ${id}` });
|
|
2620
2662
|
|
|
2663
|
+
/**
|
|
2664
|
+
* 信任闸。**只对官方源生效**:手写插件走 `plugins/` 目录,根本不经过
|
|
2665
|
+
* 市场索引;用户自己填的市场源是他自己的信任决定,不拦。
|
|
2666
|
+
* 本版 `enforce` 默认 false,所以这里恒放行。
|
|
2667
|
+
*/
|
|
2668
|
+
const official = isOfficialSource(src, globalCfg);
|
|
2669
|
+
const trustWhy = blockReason(resolveTrust(globalCfg), idx?._trust?.status || 'unavailable', id, { official });
|
|
2670
|
+
if (trustWhy) return json(res, 403, { error: trustWhy });
|
|
2671
|
+
|
|
2621
2672
|
const localVers = await plugins.localVersions();
|
|
2622
2673
|
const cur = localVers[id];
|
|
2623
2674
|
if (!cur) return json(res, 400, { error: `本地没装 ${id},先用「安装」` });
|