@geoly-ai/skills-hub 0.3.7 → 0.3.8
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 +150 -190
- package/package.json +1 -1
- package/src/cli.mjs +183 -8
- package/src/commands/context.mjs +4 -12
- package/src/delete-proof.mjs +86 -0
- package/src/telemetry.mjs +524 -26
- package/src/upload.mjs +197 -16
- package/src/version.mjs +27 -0
package/src/cli.mjs
CHANGED
|
@@ -37,7 +37,10 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
|
|
|
37
37
|
\`--yes\` 只表示**确认了那个风险**,不跳过任何校验。
|
|
38
38
|
|
|
39
39
|
stats [--json] [--export <file>] 本地埋点报表
|
|
40
|
-
telemetry <status|flush>
|
|
40
|
+
telemetry <status|flush|on|off|delete>
|
|
41
|
+
埋点开关、队列、身份三项的退出;delete 先向服务端申请删除
|
|
42
|
+
已发出的身份记录(--offline / GEOLY_TELEMETRY=0 /
|
|
43
|
+
GEOLY_TELEMETRY_UPLOAD=0 下不出网),再清本机
|
|
41
44
|
|
|
42
45
|
全局 flag(09-cli.md §2):
|
|
43
46
|
--clients <list> 默认 = 本机已存在的全部;含未安装 client 是硬错误
|
|
@@ -65,7 +68,7 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
|
|
|
65
68
|
|
|
66
69
|
埋点(docs/telemetry/00-spec.md):
|
|
67
70
|
🔴 上报**默认开**(有内置默认端点)。**install 成功收尾后**会静默发一次
|
|
68
|
-
(24 小时最多一次,超时 1
|
|
71
|
+
(24 小时最多一次,超时 3 秒,失败后 1 小时可再试,不影响安装);别的命令只写本地,
|
|
69
72
|
也可以随时 \`telemetry flush\` 手动发。
|
|
70
73
|
采集面是穷举白名单 —— 不含路径、目录清单、文件内容、用户名。
|
|
71
74
|
GEOLY_TELEMETRY=0 完全关闭:本地一个字节都不写
|
|
@@ -156,9 +159,19 @@ export async function main(argv, deps = {}) {
|
|
|
156
159
|
return EXIT.OK;
|
|
157
160
|
}
|
|
158
161
|
|
|
159
|
-
// 埋点两个子命令是纯本地的,不需要快照/锁/target
|
|
162
|
+
// 埋点两个子命令是纯本地的,不需要快照/锁/target。
|
|
163
|
+
// 🔴 **但它们照样要守输出契约**:早先 telemetry 那条在顶层 try/catch 之外,
|
|
164
|
+
// 于是 `telemetry delete --json` 在状态目录不存在时抛 ERR_SQLITE_ERROR,
|
|
165
|
+
// stdout / stderr 双双为空 —— 调用方拿到一个非零退出码和零个字节
|
|
166
|
+
// (Codex 2026-09-09 实测复现)。这里补一层:异常按 §7 的错误形状出。
|
|
160
167
|
if (cmd === 'stats') return cmdStats(rest.slice(1), globals, stdout, stderr);
|
|
161
|
-
if (cmd === 'telemetry')
|
|
168
|
+
if (cmd === 'telemetry') {
|
|
169
|
+
try {
|
|
170
|
+
return await cmdTelemetry(rest.slice(1), globals, out, stdout, stderr);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return out.emitError('telemetry', classify(err), err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
162
175
|
|
|
163
176
|
const load = COMMANDS[cmd];
|
|
164
177
|
if (!load) {
|
|
@@ -187,9 +200,9 @@ export async function main(argv, deps = {}) {
|
|
|
187
200
|
// 不是靠记得写 if,而是靠控制流。
|
|
188
201
|
//
|
|
189
202
|
// 🔴 这里 `await`,不是发了就不管:进程一退,在途的 POST 就没了。
|
|
190
|
-
// ⚠️ 代价是**这一次 install 会等它**。网络那一段有
|
|
203
|
+
// ⚠️ 代价是**这一次 install 会等它**。网络那一段有 3 秒上界
|
|
191
204
|
// (AUTO_UPLOAD_TIMEOUT_MS),但**整段没有**:认领戳的 fsync、上报锁、
|
|
192
|
-
// stage/reap 都是超时之外的同步 I/O。别把「最多多
|
|
205
|
+
// stage/reap 都是超时之外的同步 I/O。别把「最多多 3 秒」说成整体保证
|
|
193
206
|
// (规格 §5.1.1 已按实话改写)。
|
|
194
207
|
//
|
|
195
208
|
// 🔴 返回值**故意丢弃**:不打印、不改退出码。一次成功的 install 绝不能
|
|
@@ -235,6 +248,9 @@ async function noticeOnce(stderr) {
|
|
|
235
248
|
let url = null;
|
|
236
249
|
try { url = endpoint(); } catch { return; } // 端点配坏了 = 不会出网
|
|
237
250
|
tm.maybeNoticeUpload((s) => stderr.write(s), url);
|
|
251
|
+
// 🔴 身份告知是**另一段**:只有在有人显式打开身份采集时才打。
|
|
252
|
+
// 它同时是 identityEnabled() 的硬前置 —— 没打过就一项都不采。
|
|
253
|
+
tm.maybeNoticeIdentity((s) => stderr.write(s), url);
|
|
238
254
|
} catch { /* 告知不是主命令的一部分(T-5) */ }
|
|
239
255
|
}
|
|
240
256
|
|
|
@@ -278,13 +294,35 @@ async function cmdStats(args, globals, stdout, stderr) {
|
|
|
278
294
|
return EXIT.OK;
|
|
279
295
|
}
|
|
280
296
|
|
|
281
|
-
|
|
297
|
+
// `--json` 是**全局** flag,解析后放在 globals 里、已经从 args 里摘走了 ——
|
|
298
|
+
// 所以这里读 globals.json,不是 args.includes('--json')。
|
|
299
|
+
// ⚠️ 我第一版就是写成后者,跑出来一点反应都没有:flag 根本不在 args 里。
|
|
300
|
+
async function cmdTelemetry(args, globals, out, stdout, stderr) {
|
|
282
301
|
const tm = await import('./telemetry.mjs');
|
|
283
302
|
const up = await import('./upload.mjs');
|
|
284
303
|
switch (args[0]) {
|
|
285
304
|
case 'status': {
|
|
286
305
|
let ep;
|
|
287
306
|
try { ep = up.endpoint(); } catch (e) { ep = `无效(${e.message})`; }
|
|
307
|
+
// 🔴 身份与匿名计数分两行显示。合成一行「上报:开」的话,
|
|
308
|
+
// 关掉身份的用户看不出「我关的那个到底关了没有」。
|
|
309
|
+
const identity = tm.identityEnabled() ? '开'
|
|
310
|
+
: tm.enabled() ? '关(身份三项不发,匿名计数照发)' : '关(埋点整体已关)';
|
|
311
|
+
if (globals.json) {
|
|
312
|
+
stdout.write(`${JSON.stringify({
|
|
313
|
+
schema: 'geoly.skills.telemetry-status/1',
|
|
314
|
+
telemetry: tm.enabled(),
|
|
315
|
+
upload: tm.uploadEnabled(),
|
|
316
|
+
identity: tm.identityEnabled(),
|
|
317
|
+
endpoint: ep ?? null,
|
|
318
|
+
endpointIsDefault: ep ? up.isDefaultEndpoint() : null,
|
|
319
|
+
permWarning: tm.permWarning(),
|
|
320
|
+
queued: tm.readAll().length,
|
|
321
|
+
history: tm.readHistory().length,
|
|
322
|
+
stateDir: tm.stateDir(),
|
|
323
|
+
})}\n`);
|
|
324
|
+
return EXIT.OK;
|
|
325
|
+
}
|
|
288
326
|
const uploadOff = !tm.enabled() ? '关(埋点整体已关)'
|
|
289
327
|
: tm.offline() ? '关(--offline)'
|
|
290
328
|
: '关(GEOLY_TELEMETRY_UPLOAD=0)';
|
|
@@ -293,13 +331,23 @@ async function cmdTelemetry(args, stdout, stderr) {
|
|
|
293
331
|
// 🔴 端点默认开(2026-09-01 拍板),所以「这是内置默认值」必须写在脸上:
|
|
294
332
|
// 用户有权知道数据默认发去哪,而不是去翻源码才发现有个默认端点。
|
|
295
333
|
stdout.write(`端点 ${ep ?? '无'}${ep && up.isDefaultEndpoint() ? '(内置默认值 —— 未配 GEOLY_TELEMETRY_ENDPOINT)' : ''}\n`);
|
|
334
|
+
stdout.write(`身份三项 ${identity}\n`);
|
|
296
335
|
stdout.write(`待上报 ${tm.readAll().length} 条\n`);
|
|
297
336
|
stdout.write(`报表历史 ${tm.readHistory().length} 条(上报不消费它)\n`);
|
|
298
337
|
stdout.write(`状态目录 ${tm.stateDir()}\n`);
|
|
338
|
+
// 🔴 权限收不紧要**说出来**:悄悄地继续写,意味着没人知道这台机器上的
|
|
339
|
+
// 埋点目录是所有人可读的 —— 而里面可能有登录名与主机名。
|
|
340
|
+
const pw = tm.permWarning();
|
|
341
|
+
if (pw) stdout.write(`⚠️ 权限 ${pw}\n`);
|
|
299
342
|
return EXIT.OK;
|
|
300
343
|
}
|
|
301
344
|
case 'flush': {
|
|
302
345
|
const r = await up.flush();
|
|
346
|
+
if (globals.json) {
|
|
347
|
+
const bad = r.skipped && (r.reason === 'bad-endpoint' || r.reason?.startsWith('error:'));
|
|
348
|
+
return out.emit('telemetry', { action: 'flush', sent: r.sent ?? 0, skipped: !!r.skipped, reason: r.reason ?? null },
|
|
349
|
+
bad ? EXIT.USAGE : EXIT.OK);
|
|
350
|
+
}
|
|
303
351
|
stdout.write(r.skipped
|
|
304
352
|
? `未上报:${r.reason}${r.detail ? `(${r.detail})` : ''}\n`
|
|
305
353
|
: `已上报 ${r.sent} 条\n`);
|
|
@@ -309,8 +357,135 @@ async function cmdTelemetry(args, stdout, stderr) {
|
|
|
309
357
|
const bad = r.skipped && (r.reason === 'bad-endpoint' || r.reason?.startsWith('error:'));
|
|
310
358
|
return bad ? EXIT.USAGE : EXIT.OK;
|
|
311
359
|
}
|
|
360
|
+
case 'off': {
|
|
361
|
+
const all = args.includes('--all');
|
|
362
|
+
// 🔴 **`--all` 做不到它字面的意思,所以它不许假装做到了。**
|
|
363
|
+
// 「什么都不发」等价于 GEOLY_TELEMETRY=0,而那个开关承诺的是
|
|
364
|
+
// 「本地一个字节都不写」—— 一个要先写标记文件才能生效的持久开关,
|
|
365
|
+
// 与那句承诺自相矛盾。所以这里只关身份,并**如实说**全关要走环境变量。
|
|
366
|
+
// 早先这条的文案写着「什么都不发」而实现只关了身份(Codex 指出)。
|
|
367
|
+
// 🔴 埋点整体已关时**什么都不写**:那种状态下建目录、落标记,
|
|
368
|
+
// 本身就违反了那句承诺。
|
|
369
|
+
if (!tm.enabled()) {
|
|
370
|
+
const msg = '埋点整体已经是关的(GEOLY_TELEMETRY=0),没有可关的东西,也不写任何文件。';
|
|
371
|
+
if (globals.json) return out.emit('telemetry', { action: 'off', changed: false, identity: false }, EXIT.OK);
|
|
372
|
+
stdout.write(`${msg}\n`);
|
|
373
|
+
return EXIT.OK;
|
|
374
|
+
}
|
|
375
|
+
tm.identityOff();
|
|
376
|
+
if (globals.json) {
|
|
377
|
+
return out.emit('telemetry', {
|
|
378
|
+
action: 'off', all, changed: true, identity: tm.identityEnabled(),
|
|
379
|
+
note: all ? '全关需要环境变量 GEOLY_TELEMETRY=0' : undefined,
|
|
380
|
+
}, EXIT.OK);
|
|
381
|
+
}
|
|
382
|
+
stdout.write('已关闭身份三项(登录名 / 主机名 / 来源 IP)。\n');
|
|
383
|
+
stdout.write('匿名计数照发 —— 装了什么、成不成功、耗时。\n');
|
|
384
|
+
if (all) {
|
|
385
|
+
stdout.write('\n⚠️ `--all` 只关掉了身份三项。要连匿名计数一起停,请设\n');
|
|
386
|
+
stdout.write(' GEOLY_TELEMETRY=0 —— 它必须是环境变量:那个开关承诺的是\n');
|
|
387
|
+
stdout.write(' 「本地一个字节都不写」,而一个要先写标记文件才生效的持久开关\n');
|
|
388
|
+
stdout.write(' 与这句承诺自相矛盾。\n');
|
|
389
|
+
}
|
|
390
|
+
stdout.write('已经发出去的身份记录不会自动删除:skills-hub telemetry delete\n');
|
|
391
|
+
return EXIT.OK;
|
|
392
|
+
}
|
|
393
|
+
case 'on': {
|
|
394
|
+
if (!tm.enabled()) {
|
|
395
|
+
if (globals.json) return out.emit('telemetry', { action: 'on', changed: false }, EXIT.OK);
|
|
396
|
+
stdout.write('埋点整体已关(GEOLY_TELEMETRY=0),不写任何文件。\n');
|
|
397
|
+
return EXIT.OK;
|
|
398
|
+
}
|
|
399
|
+
tm.identityOn();
|
|
400
|
+
if (globals.json) {
|
|
401
|
+
return out.emit('telemetry', { action: 'on', changed: true, identity: tm.identityEnabled() }, EXIT.OK);
|
|
402
|
+
}
|
|
403
|
+
stdout.write('已撤销「关闭身份」的标记。\n');
|
|
404
|
+
// 🔴 说清楚它**没有**打开身份采集:还要过环境变量与告知两道门。
|
|
405
|
+
// 不写这句的话,用户会以为 `telemetry on` 就是开了。
|
|
406
|
+
stdout.write(`当前身份三项:${tm.identityEnabled() ? '开' : '仍然是关的'}\n`);
|
|
407
|
+
if (!tm.identityEnabled()) {
|
|
408
|
+
stdout.write('要真正打开还需要 GEOLY_TELEMETRY_IDENTITY=on,并看过一次身份告知。\n');
|
|
409
|
+
}
|
|
410
|
+
return EXIT.OK;
|
|
411
|
+
}
|
|
412
|
+
case 'delete': {
|
|
413
|
+
// 🔴 **先远程、后本机。** 本机那一步会删掉私钥,而私钥是远程删除唯一的所有权证明
|
|
414
|
+
// (规格 §4.4)。倒过来做,服务端那批数据就再也证明不了是谁的。
|
|
415
|
+
const key = tm.deleteKey();
|
|
416
|
+
// 私钥只在身份开启时生成,身份开启又要求告知过。所以「没私钥、也没告知过」=
|
|
417
|
+
// 这台机器从没发过带身份的数据,服务端没有可按所有权删的东西。
|
|
418
|
+
// 反过来「告知过却没私钥」= 私钥丢了或坏了,**不能**说服务端没有数据(Codex 2026-09-13 P1)。
|
|
419
|
+
const identityEver = key !== null || tm.identityNoticeShown();
|
|
420
|
+
const remote = key ? await up.remoteDelete({ key }) : null;
|
|
421
|
+
const r = tm.purgeLocal({ keepDeleteKey: key !== null && !remote.ok });
|
|
422
|
+
const vetoed = remote !== null && (remote.reason === 'offline' || remote.reason === 'upload-disabled');
|
|
423
|
+
|
|
424
|
+
/** @type {'deleted'|'deleted-key-left'|'vetoed'|'remote-failed'|'unprovable'|'nothing-remote'} */
|
|
425
|
+
let status;
|
|
426
|
+
let code;
|
|
427
|
+
if (remote?.ok) {
|
|
428
|
+
status = r.deleteKeyRemoved ? 'deleted' : 'deleted-key-left';
|
|
429
|
+
code = r.deleteKeyRemoved ? EXIT.OK : EXIT.PARTIAL;
|
|
430
|
+
} else if (vetoed) {
|
|
431
|
+
status = 'vetoed'; code = EXIT.PARTIAL;
|
|
432
|
+
} else if (remote) {
|
|
433
|
+
status = 'remote-failed'; code = EXIT.NETWORK;
|
|
434
|
+
} else if (identityEver) {
|
|
435
|
+
status = 'unprovable'; code = EXIT.PARTIAL;
|
|
436
|
+
} else {
|
|
437
|
+
status = 'nothing-remote'; code = EXIT.OK;
|
|
438
|
+
}
|
|
439
|
+
// 🔴 本机删不干净也要反映到退出码上:「已清空」和「清了一部分」是两件事
|
|
440
|
+
const leftovers = r.remaining.filter((n) => !n.startsWith('upload.lock'));
|
|
441
|
+
if (leftovers.length > 0 && code === EXIT.OK) code = EXIT.PARTIAL;
|
|
442
|
+
|
|
443
|
+
if (globals.json) {
|
|
444
|
+
return out.emit('telemetry', {
|
|
445
|
+
action: 'delete', remote: status, reason: remote?.ok ? null : (remote?.reason ?? null),
|
|
446
|
+
removed: r.removed, remaining: leftovers, kept: r.kept, deleteKeyRemoved: r.deleteKeyRemoved,
|
|
447
|
+
}, code);
|
|
448
|
+
}
|
|
449
|
+
switch (status) {
|
|
450
|
+
case 'deleted':
|
|
451
|
+
stdout.write('服务端已删除这台机器发出的身份记录(登录名 / 主机名 / 来源 IP),\n');
|
|
452
|
+
stdout.write('并剥掉了那些事件上的 install_id。匿名计数保留到到期。\n');
|
|
453
|
+
break;
|
|
454
|
+
case 'deleted-key-left':
|
|
455
|
+
stdout.write('服务端已删除身份记录,但本机的删除私钥没删掉。\n');
|
|
456
|
+
stdout.write('⚠️ 在删掉它之前,这台机器之后发出的身份记录会被服务端当作「已申请删除」而丢弃。\n');
|
|
457
|
+
break;
|
|
458
|
+
case 'vetoed':
|
|
459
|
+
stdout.write('⚠️ 没有向服务端申请删除:--offline / GEOLY_TELEMETRY=0 / GEOLY_TELEMETRY_UPLOAD=0\n');
|
|
460
|
+
stdout.write(' 都承诺过不发网络请求,删除请求也是网络请求。\n');
|
|
461
|
+
stdout.write(' 删除私钥已保留 —— 临时解除这些开关后再跑一次 `skills-hub telemetry delete`。\n');
|
|
462
|
+
break;
|
|
463
|
+
case 'remote-failed':
|
|
464
|
+
stdout.write(`⚠️ 服务端删除没有成功(${remote.reason}${remote.detail ? `:${remote.detail}` : ''})。\n`);
|
|
465
|
+
stdout.write(' 删除私钥已保留 —— 稍后再跑一次 `skills-hub telemetry delete`。\n');
|
|
466
|
+
break;
|
|
467
|
+
case 'unprovable':
|
|
468
|
+
stdout.write('⚠️ 这台机器看过身份告知,但找不到可用的删除私钥(丢失或损坏),\n');
|
|
469
|
+
stdout.write(' 无法向服务端证明那些身份记录是你的。它们会在 90 天后到期删除。\n');
|
|
470
|
+
break;
|
|
471
|
+
default:
|
|
472
|
+
stdout.write('这台机器从没发出过带身份的记录,服务端没有需要按所有权删除的东西。\n');
|
|
473
|
+
}
|
|
474
|
+
stdout.write(`本机埋点数据已清空(删了 ${r.removed} 个文件,含 install-id 与上报墓碑)。\n`);
|
|
475
|
+
if (r.kept.includes('identity-off')) stdout.write('「关闭身份」的设置保留着。\n');
|
|
476
|
+
if (leftovers.length > 0) stdout.write(`⚠️ 这些没删掉:${leftovers.join('、')}\n`);
|
|
477
|
+
// 删除的是「过去的」;身份采集如果仍开着,之后的新事件照样会带身份
|
|
478
|
+
if (tm.identityEnabled()) {
|
|
479
|
+
stdout.write('ℹ️ 身份采集仍然开着,之后的事件会带一把新的删除密钥重新采集 —— 要停请 `skills-hub telemetry off`。\n');
|
|
480
|
+
}
|
|
481
|
+
return code;
|
|
482
|
+
}
|
|
312
483
|
default:
|
|
313
|
-
|
|
484
|
+
if (globals.json) {
|
|
485
|
+
return out.emitError('telemetry', classify(new UsageError('x')),
|
|
486
|
+
new Error('用法:skills-hub telemetry <status|flush|on|off [--all]|delete>'));
|
|
487
|
+
}
|
|
488
|
+
stderr.write('用法:skills-hub telemetry <status|flush|on|off [--all]|delete>\n');
|
|
314
489
|
return EXIT.USAGE;
|
|
315
490
|
}
|
|
316
491
|
}
|
package/src/commands/context.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
17
17
|
import { isAbsolute, resolve as resolvePath } from 'node:path';
|
|
18
18
|
import { UsageError, UnsupportedError } from '../exit-codes.mjs';
|
|
19
19
|
import { SCAN_CEILINGS } from '../target.mjs';
|
|
20
|
+
import { ownVersion } from '../version.mjs';
|
|
20
21
|
|
|
21
22
|
/** §2 那张表。`arg: true` = 后面跟一个值。 */
|
|
22
23
|
const GLOBAL_FLAGS = Object.freeze({
|
|
@@ -226,18 +227,9 @@ export function uintArg(name, val) {
|
|
|
226
227
|
* 🔴 读不到就**抛**,不要退回一个假版本号:一个编出来的版本号会让
|
|
227
228
|
* 版本门做出错误判定,而那正是这道门要防的事。
|
|
228
229
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const p = new URL('../../package.json', import.meta.url);
|
|
233
|
-
const v = JSON.parse(readFileSync(p, 'utf8')).version;
|
|
234
|
-
if (typeof v !== 'string' || v === '') {
|
|
235
|
-
throw new Error('读不出本 CLI 的版本号(package.json 的 version 不是非空字符串)');
|
|
236
|
-
}
|
|
237
|
-
cachedVersion = v;
|
|
238
|
-
}
|
|
239
|
-
return cachedVersion;
|
|
240
|
-
}
|
|
230
|
+
// 实现搬到 src/version.mjs:埋点的 `cli` 字段要取同一个值(见那个文件的注释)。
|
|
231
|
+
// 这里保留导出,调用方与测试不用改。
|
|
232
|
+
export { ownVersion };
|
|
241
233
|
|
|
242
234
|
export function makeContext(globals, deps = {}) {
|
|
243
235
|
const env = deps.env ?? process.env;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 删除通道的签名协议 —— **客户端与服务端共用的唯一定义**。
|
|
2
|
+
//
|
|
3
|
+
// 🔴 放在 src/ 而不是 server/:客户端要照着它签,服务端要照着它验。
|
|
4
|
+
// 两边各写一份,迟早一边改了另一边没改 —— 结果不是报错,是「所有删除请求
|
|
5
|
+
// 都验不过」或者更糟的「验得过一个不该验过的形状」。与 assertValidEvent 只有一份
|
|
6
|
+
// 是同一条纪律。server/delete.mjs 从这里 re-export。
|
|
7
|
+
//
|
|
8
|
+
// 🔴 **零依赖**:只用 node:buffer。服务端的路由模块会 import 它,而仓库根的测试
|
|
9
|
+
// 只装根依赖(server/delete.mjs 文件头那段教训)。
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 协议版本。**改了它就是换了一套签名语义**,旧签名一律失效 —— 这正是要的。
|
|
13
|
+
* v1 → v2(2026-09-13):把服务端的 audience 纳入签名(Codex 指出的中继重放,见 signedMessage)。
|
|
14
|
+
*/
|
|
15
|
+
export const DELETE_PROTOCOL = 'geoly.skills.telemetry-delete/2';
|
|
16
|
+
|
|
17
|
+
// 三个定长量。**定长不是「不超过」**:可变长度会让塞了别的东西的值混进来。
|
|
18
|
+
export const RE_PUBKEY = /^[A-Za-z0-9_-]{43}$/; // 32 字节 Ed25519 公钥
|
|
19
|
+
export const RE_NONCE = /^[A-Za-z0-9_-]{43}$/; // 32 字节
|
|
20
|
+
export const RE_SIGNATURE = /^[A-Za-z0-9_-]{86}$/; // 64 字节签名
|
|
21
|
+
|
|
22
|
+
/** audience 的长度上界 —— 它进签名消息,长度前缀是两个字节。 */
|
|
23
|
+
export const MAX_AUDIENCE_BYTES = 2048;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 是不是**规范**的 base64url,且恰好解出 `bytes` 个字节。
|
|
27
|
+
*
|
|
28
|
+
* 🔴 光过正则不够:43 个字符解出 32 字节时,最后一个字符的低 4 位是填充位,
|
|
29
|
+
* `…A` 与 `…B` 可能解出同一串字节。同一把公钥有多个文本表示,
|
|
30
|
+
* tag(按文本算 HMAC)就会不同 —— 同一个人被算成两个,删一个漏一个。
|
|
31
|
+
* 判据:解码再编码,必须逐字相等(Codex 2026-09-13)。
|
|
32
|
+
*/
|
|
33
|
+
export function isCanonicalB64u(s, bytes) {
|
|
34
|
+
if (typeof s !== 'string') return false;
|
|
35
|
+
const buf = Buffer.from(s, 'base64url');
|
|
36
|
+
return buf.length === bytes && buf.toString('base64url') === s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 规范化 audience:它是**删除端点的完整 URL**,客户端与服务端各自独立算出,逐字比对。
|
|
41
|
+
*
|
|
42
|
+
* 只收 http/https、不许带凭据、query 与 fragment —— 这三样都能让「同一个端点」
|
|
43
|
+
* 有多种写法,而 audience 比的是字符串。
|
|
44
|
+
* @returns {string|null} 不合法返回 null
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeAudience(raw) {
|
|
47
|
+
if (typeof raw !== 'string' || raw === '') return null;
|
|
48
|
+
let u;
|
|
49
|
+
try { u = new URL(raw); } catch { return null; }
|
|
50
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
|
51
|
+
if (u.username || u.password || u.search || u.hash) return null;
|
|
52
|
+
const s = u.toString();
|
|
53
|
+
return Buffer.byteLength(s, 'utf8') <= MAX_AUDIENCE_BYTES ? s : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 被签名的消息:`协议名 || u16be(len(audience)) || audience || 公钥32 || nonce32`。
|
|
58
|
+
*
|
|
59
|
+
* 🔴 **不是「签 nonce 原文」。** 裸签一个随机串意味着这个签名在**任何**
|
|
60
|
+
* 也用 Ed25519 的协议里都可能被复用(跨协议重放)。所以:
|
|
61
|
+
* · 协议名做**域分离** —— 换了协议就是另一段消息
|
|
62
|
+
* · 把公钥包进消息 —— 签名与「谁在签」绑死,不能挪给别的公钥用
|
|
63
|
+
* 🔴 **audience 必须进签名**(v2,Codex 2026-09-13 的 P0):客户端允许自定义端点,
|
|
64
|
+
* 一个恶意端点可以拿受害者的公钥去正式服务取 nonce、原样转给 CLI 签、
|
|
65
|
+
* 再把签名转发回正式服务 —— 禁止重定向挡不住这种代理式中继。
|
|
66
|
+
* 签名里写死「我签给的是哪个端点」,转发出去就对不上。
|
|
67
|
+
* 🔴 audience 是唯一的变长段,所以带**长度前缀**:没有前缀的拼接存在歧义
|
|
68
|
+
* (audience 末尾挪几个字节给公钥,拼出来一样)。
|
|
69
|
+
*/
|
|
70
|
+
export function signedMessage({ audience, pubkey, nonce }) {
|
|
71
|
+
const aud = normalizeAudience(audience);
|
|
72
|
+
if (aud === null || aud !== audience) throw new Error('delete: audience 不是规范形式');
|
|
73
|
+
if (!RE_PUBKEY.test(pubkey) || !isCanonicalB64u(pubkey, 32)) throw new Error('delete: pubkey 形状不合法');
|
|
74
|
+
if (!RE_NONCE.test(nonce) || !isCanonicalB64u(nonce, 32)) throw new Error('delete: nonce 形状不合法');
|
|
75
|
+
const audBytes = Buffer.from(aud, 'utf8');
|
|
76
|
+
const len = Buffer.alloc(2);
|
|
77
|
+
len.writeUInt16BE(audBytes.length);
|
|
78
|
+
return Buffer.concat([
|
|
79
|
+
Buffer.from(DELETE_PROTOCOL, 'utf8'), len, audBytes,
|
|
80
|
+
Buffer.from(pubkey, 'base64url'), Buffer.from(nonce, 'base64url'),
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 请求与响应信封的 schema。两边共用,理由同上。 */
|
|
85
|
+
export const CHALLENGE_SCHEMA = 'geoly.skills.telemetry-delete-challenge/1';
|
|
86
|
+
export const DELETE_SCHEMA = 'geoly.skills.telemetry-delete/1';
|