@geoly-ai/skills-hub 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
@@ -0,0 +1,480 @@
1
+ // 真正的 Sigstore 验签器 —— **纯离线**,验证时不出网。
2
+ //
3
+ // 规范:02-registry.md §8(签名身份)、§8.1(TUF 根随 CLI 内置)、§9.2(缓存命中不跳过验签)、
4
+ // 07-threat-model.md、00-decisions.md ⑤(Sigstore keyless + npm provenance)
5
+ //
6
+ // ── 为什么只引 `@sigstore/verify` ────────────────────────────────────────────
7
+ // `sigstore` 全家桶把「签名 + 上传」和「验证」焊在一起:`@sigstore/sign` 带
8
+ // `make-fetch-happen`(要出网连 Fulcio / Rekor),`@sigstore/tuf` 带 `tuf-js`
9
+ // (要出网刷 TUF 根)。CLI 只需要**验证**那半边,两个联网包一个都不引。
10
+ //
11
+ // 🔴 `--offline` 是全局 flag,规范承诺「离线时也必须重验签名,缓存只省网络、
12
+ // 不省任何一次密码学校验」。联网验签会让这条承诺直接破产,所以制品与 bundle
13
+ // 一起分发、验签全程离线:证书链 + SCT + Rekor 包含证明都在 bundle 里带齐。
14
+ //
15
+ // ── trusted root 从哪来 ─────────────────────────────────────────────────────
16
+ // §8.1:Sigstore TUF trust root **随 CLI 内置**。本模块因此**不自己去取根**,
17
+ // 由调用方把 protobuf `TrustedRoot` 的 JSON 传进来。没有根就验不了 —— 这是
18
+ // fail-closed,不是缺陷。
19
+ //
20
+ // 🔴 **信任根是本模块唯一的信任输入,而本模块不认证它。**
21
+ // 这里做不到也不该做:TUF 根的真伪要靠「随 CLI 内置 + npm provenance」
22
+ // 那一跳(§8.2 明确承认第一跳的信任锚是 npm registry + TLS)。
23
+ // 推论,接线的人必须守住:
24
+ // · 只喂**编译进包里**的根,或已按 TUF 流程验过签的根;
25
+ // · **绝不**把网络上取来的、或缓存目录里读到的根直接传进来 ——
26
+ // 对手能自带 CA/CT/Rekor,那样整套验证一次性失守,而且全程「验签通过」;
27
+ // · 记 `root_version` 与过期时间,根过期/轮换走 Sigstore 标准流程。
28
+ import { Verifier, toTrustMaterial, toSignedEntity } from '@sigstore/verify';
29
+ import { bundleFromJSON } from '@sigstore/bundle';
30
+ import { TrustedRoot, HashAlgorithm } from '@sigstore/protobuf-specs';
31
+ import { IntegrityError, sha256Of } from './trust.mjs';
32
+ import { readFileSync } from 'node:fs';
33
+ import { parseStrict } from './canonical-json.mjs';
34
+
35
+ /**
36
+ * 🔴 **绝不使用 `@sigstore/verify` 的 `policy` 参数做身份判定。**
37
+ *
38
+ * `dist/policy.js` 的实现是 `signerIdentity.match(policyIdentity)` ——
39
+ * 把 policy 字符串当成**正则**,而且**不锚定**。我们的身份串里全是 `.` 和 `/`:
40
+ *
41
+ * https://github.com/geoly-ai/skills-hub/.github/workflows/release.yml@refs/heads/main
42
+ *
43
+ * 作为正则,`.` 匹配任意字符,`/` 无意义,且不锚定 → 下面这些**都会 match 成功**:
44
+ *
45
+ * …/skills-hub/Xgithub/workflows/release.yml@refs/heads/main (`.` 当通配)
46
+ * …/skills-hub/.github/workflows/release.yml@refs/heads/main-EVIL (后缀糊弄)
47
+ * https://evil.example/?x=https://github.com/…/main (前缀糊弄)
48
+ *
49
+ * 所以身份判定**只走本模块自己的 `===`**:从 `Verifier.verify()` 返回的
50
+ * `Signer.identity` 上取 SAN 与 issuer 扩展,逐字符精确比对。
51
+ * 上层 `verifySigned()` 还会再独立比一遍(§8 要求「精确比对,不做前缀匹配、
52
+ * 不做通配」)——两道都是 `===`,故意冗余。
53
+ */
54
+ const NO_POLICY = undefined;
55
+
56
+ /** 只接受 SHA2-256 的 messageDigest:算法降级是攻击面,不是兼容性 */
57
+ const REQUIRED_HASH = HashAlgorithm.SHA2_256;
58
+
59
+ function fail(violation, msg) {
60
+ throw new IntegrityError(violation, msg);
61
+ }
62
+
63
+ /**
64
+ * 把库的错误摊平成一行。`@sigstore/verify` 的证书链错误把真正的原因塞在
65
+ * `cause` 里(外层只有一句 `Failed to verify certificate chain`),不跟下去
66
+ * 的话「证书过期」和「CA 不认识」在日志里长得一模一样。
67
+ */
68
+ function describe(e, depth = 0) {
69
+ if (!e || depth > 4) return String(e);
70
+ const code = e.code ? `${e.code}: ` : '';
71
+ const head = safe(`${code}${e.message ?? e}`);
72
+ return e.cause ? `${head} ← ${describe(e.cause, depth + 1)}` : head;
73
+ }
74
+
75
+ /**
76
+ * 🔴 错误信息里会带上**对手控制的**字符串(证书 SAN、issuer、tlog 里的字段)。
77
+ * 原样打到终端就是一个注入面:ANSI 转义能改写已经打出来的行、换行能伪造出
78
+ * 一条看起来像我们自己打的日志(「验签通过」)。这里统一去掉控制字符并截断。
79
+ */
80
+ function safe(s, max = 200) {
81
+ const t = String(s).replace(/[\p{Cc}\p{Cf}]/gu, '�');
82
+ return t.length > max ? `${t.slice(0, max)}…(已截断 ${t.length - max} 字)` : t;
83
+ }
84
+
85
+ /**
86
+ * 把 TUF trusted root(protobuf `TrustedRoot` 的 JSON 形式)转成 TrustMaterial。
87
+ * @param {object|string|Buffer} root
88
+ */
89
+ /**
90
+ * 🔴 加载**编译进包**的 Sigstore 公共实例信任根。
91
+ *
92
+ * 这是整个验签体系唯一的信任输入,所以这个函数刻意做得没有任何活动部件:
93
+ *
94
+ * - 路径由 `import.meta.url` 推出,**不接受参数**;
95
+ * - **不读环境变量、不读命令行、不读缓存、不出网**。
96
+ *
97
+ * 为什么这么死:验签器本身**不认证信任根**,它只负责「按这个根去验」。
98
+ * 一旦根能被外部指定,对手自带 CA + CT log + Rekor 就能让整套验证一次性失守,
99
+ * 而且全程显示「验签通过」——这是最坏的一种失败:**看起来是成功的**。
100
+ *
101
+ * ⚠️ `02-registry.md` §8.2 自己承认「内置 + npm provenance」不是自洽闭环:
102
+ * 根的真伪最终依赖发包渠道。那是**已知的**信任边界,见 docs/m1/01-residual-risks.md。
103
+ */
104
+ export function loadBuiltinTrustedRoot() {
105
+ const p = new URL('./trust-roots/sigstore-public-good.json', import.meta.url);
106
+ // 用 parseStrict:重复 key 在这里意味着两份互相矛盾的信任材料,必须报错而不是取最后一个
107
+ return parseStrict(readFileSync(p, 'utf8'));
108
+ }
109
+
110
+ export function trustMaterialFrom(root) {
111
+ if (root === undefined || root === null) {
112
+ fail('E_TRUST_ROOT', 'Sigstore trusted root 未提供:没有信任根就验不了签(§8.1,不存在跳过)');
113
+ }
114
+ let obj = root;
115
+ if (Buffer.isBuffer(root) || typeof root === 'string') {
116
+ try {
117
+ obj = JSON.parse(Buffer.isBuffer(root) ? root.toString('utf8') : root);
118
+ } catch (e) {
119
+ fail('E_TRUST_ROOT', `Sigstore trusted root 不是合法 JSON:${e.message}`);
120
+ }
121
+ }
122
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
123
+ fail('E_TRUST_ROOT', 'Sigstore trusted root 必须是对象');
124
+ }
125
+ let parsed;
126
+ try {
127
+ parsed = TrustedRoot.fromJSON(obj);
128
+ } catch (e) {
129
+ fail('E_TRUST_ROOT', `Sigstore trusted root 解析失败:${e.message}`);
130
+ }
131
+ // 空的信任集合会让 Verifier 在「没有任何可信 CA」的情况下走到别的报错分支,
132
+ // 诊断信息很难懂。这里提前挡掉,并且明确它是 fail-closed。
133
+ if (!parsed.certificateAuthorities?.length) {
134
+ fail('E_TRUST_ROOT', 'Sigstore trusted root 里没有任何 certificateAuthorities');
135
+ }
136
+ if (!parsed.tlogs?.length) {
137
+ fail('E_TRUST_ROOT', 'Sigstore trusted root 里没有任何 tlogs(Rekor)');
138
+ }
139
+ // ctlogs 缺失会一路拖到「SCT 验不过」才报,诊断信息指错方向。提前挡。
140
+ if (!parsed.ctlogs?.length) {
141
+ fail('E_TRUST_ROOT', 'Sigstore trusted root 里没有任何 ctlogs(证书透明日志):SCT 无从验起');
142
+ }
143
+ try {
144
+ return toTrustMaterial(parsed);
145
+ } catch (e) {
146
+ fail('E_TRUST_ROOT', `Sigstore trusted root 无法构造 TrustMaterial:${e.message}`);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 造一个可以喂给 `verifySigned({ verifier })` 的真验签器。
152
+ *
153
+ * @param {object} o
154
+ * @param {object|string|Buffer} o.trustedRoot protobuf TrustedRoot 的 JSON(§8.1 内置根)
155
+ * @param {number} [o.tlogThreshold=1] 至少要几条 Rekor 条目
156
+ * @param {number} [o.ctlogThreshold=1] 至少要几条 SCT
157
+ *
158
+ * 🔴 **没有「放宽」的旋钮。** 两个阈值只允许往严了调(`<1` 直接拒),
159
+ * 包含证明是**无条件**要求的。任何一个能关掉检查的参数,接到 CLI 上就是
160
+ * 一个 `--no-verify`;这里从一开始就不留。
161
+ * @returns {(a:{bytes:Buffer,bundle:*,expectIdentity:string,expectIssuer:string})=>
162
+ * {ok:true,identity:string,issuer:string,sha256:string}}
163
+ */
164
+ export function createSigstoreVerifier({
165
+ trustedRoot,
166
+ tlogThreshold = 1,
167
+ ctlogThreshold = 1,
168
+ } = {}) {
169
+ for (const [k, v] of Object.entries({ tlogThreshold, ctlogThreshold })) {
170
+ if (!Number.isInteger(v) || v < 1) {
171
+ // 阈值 0 等于「不要求透明日志 / 不要求 SCT」—— 那是一个逃生口,禁掉。
172
+ fail('E_VERIFIER_CONFIG', `${k} 必须是 ≥1 的整数,得到 ${v}(阈值 0 等于关掉检查)`);
173
+ }
174
+ }
175
+
176
+ // 根在造 verifier 时就解析好:坏根要在启动时炸,而不是在第一次装东西时炸。
177
+ const material = trustMaterialFrom(trustedRoot);
178
+ const verifier = new Verifier(material, {
179
+ tlogThreshold,
180
+ ctlogThreshold,
181
+ timestampThreshold: 1,
182
+ });
183
+
184
+ return function sigstoreVerifier({ bytes, bundle, expectIdentity, expectIssuer }) {
185
+ if (!Buffer.isBuffer(bytes)) fail('E_VERIFIER_INPUT', '待验字节必须是 Buffer');
186
+ if (typeof expectIdentity !== 'string' || expectIdentity === '') {
187
+ fail('E_VERIFIER_INPUT', 'expectIdentity 必须是非空字符串');
188
+ }
189
+ if (typeof expectIssuer !== 'string' || expectIssuer === '') {
190
+ fail('E_VERIFIER_INPUT', 'expectIssuer 必须是非空字符串');
191
+ }
192
+
193
+ const b = parseBundle(bundle);
194
+ assertBundleSize(b);
195
+ assertMessageSignature(b, bytes);
196
+ assertHasCertificate(b);
197
+ assertInclusionProofs(b);
198
+ assertVerifiableTime(b);
199
+ assertTLogBodyShape(b);
200
+
201
+ let signer;
202
+ try {
203
+ // 🔴 不传 policy(见 NO_POLICY 的注释)。库负责密码学,身份归我们判。
204
+ signer = verifier.verify(toSignedEntity(b, bytes), NO_POLICY);
205
+ } catch (e) {
206
+ // 🔴 不吞、不降级、不重试。库说不过就是不过。
207
+ fail('E_SIGSTORE_VERIFY', `Sigstore 验签失败(${describe(e)})`);
208
+ }
209
+
210
+ const identity = signer?.identity?.subjectAlternativeName;
211
+ const issuer = signer?.identity?.extensions?.issuer;
212
+ if (typeof identity !== 'string' || identity === '') {
213
+ fail('E_NO_SAN', '叶子证书没有 SubjectAlternativeName:拿不到签名身份');
214
+ }
215
+ if (typeof issuer !== 'string' || issuer === '') {
216
+ fail('E_NO_ISSUER', '叶子证书没有 Fulcio issuer 扩展:拿不到 OIDC issuer');
217
+ }
218
+ // 🔴 `===`,不是 `startsWith` / `includes` / 正则(§8)
219
+ if (identity !== expectIdentity) {
220
+ fail('E_IDENTITY_MISMATCH',
221
+ `签名身份是 ${safe(identity)},期望 ${safe(expectIdentity)}(精确比对,两个身份不可互换)`);
222
+ }
223
+ if (issuer !== expectIssuer) {
224
+ fail('E_ISSUER_MISMATCH', `OIDC issuer 是 ${safe(issuer)},期望 ${safe(expectIssuer)}`);
225
+ }
226
+
227
+ return { ok: true, identity, issuer, sha256: sha256Of(bytes) };
228
+ };
229
+ }
230
+
231
+ // ── 各项前置检查 ────────────────────────────────────────────────────────────
232
+
233
+ function parseBundle(bundle) {
234
+ if (bundle === null || typeof bundle !== 'object' || Array.isArray(bundle)) {
235
+ fail('E_BUNDLE_MALFORMED', 'bundle 必须是对象');
236
+ }
237
+ // 🔴 protobuf 的 oneof 在 JSON 里是平铺的,而生成的 parser 遇到**两个都在**
238
+ // 时按固定优先级挑一个、不报错。于是对手可以同时塞 `messageSignature` 与
239
+ // `dsseEnvelope`:我们按一个分支判、别的工具按另一个分支判,同一份 bundle
240
+ // 在两边有两种含义(类型混淆)。歧义即拒。
241
+ assertNoOneofAmbiguity(bundle, ['messageSignature', 'dsseEnvelope'], 'bundle 的内容');
242
+ assertNoOneofAmbiguity(bundle.verificationMaterial ?? {},
243
+ ['publicKey', 'certificate', 'x509CertificateChain'], 'bundle 的 verificationMaterial');
244
+ try {
245
+ return bundleFromJSON(bundle);
246
+ } catch (e) {
247
+ fail('E_BUNDLE_MALFORMED', `bundle 解析失败:${safe(e?.message ?? e)}`);
248
+ }
249
+ }
250
+
251
+ function assertNoOneofAmbiguity(obj, keys, where) {
252
+ const present = keys.filter((k) => obj?.[k] !== undefined && obj?.[k] !== null);
253
+ if (present.length > 1) {
254
+ fail('E_BUNDLE_ONEOF_AMBIGUOUS',
255
+ `${where}同时给了 ${safe(present.join(' 和 '))}:oneof 有歧义,不同实现会解读成不同东西`);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * 🔴 只接受 `messageSignature`,拒绝 `dsseEnvelope`。
261
+ *
262
+ * snapshot / timestamp 签的是**原始 JSON 字节**。`messageSignature` 的
263
+ * `verifySignature()` 是对我们传进去的 artifact 字节验的 —— 签名与我们手上的
264
+ * 字节**真的绑定**。而 `dsseEnvelope` 的签名是对 envelope 自己的 PAE 验的,
265
+ * 跟我们手上的 `bytes` **一点关系都没有**:拿一个身份正确、签名有效的 DSSE
266
+ * bundle 配任意字节,库这一层照样过。
267
+ *
268
+ * (attestation 确实是 DSSE,但 02-registry.md §1.1 写死了它**只服务取证、
269
+ * 安装链路不读**;`src/attestation.mjs` 单独处理,不走这个 verifier。)
270
+ */
271
+ function assertMessageSignature(b, bytes) {
272
+ const c = b.content;
273
+ if (c?.$case !== 'messageSignature') {
274
+ fail('E_BUNDLE_NOT_MESSAGE_SIGNATURE',
275
+ `bundle 的内容类型是 ${safe(c?.$case ?? '(缺失)')},只接受 messageSignature:` +
276
+ 'DSSE envelope 的签名与待验字节没有绑定关系');
277
+ }
278
+ const md = c.messageSignature.messageDigest;
279
+ if (md?.algorithm !== REQUIRED_HASH) {
280
+ fail('E_BUNDLE_DIGEST_ALGO', `messageDigest.algorithm 必须是 SHA2-256,得到 ${md?.algorithm}`);
281
+ }
282
+ // 库内部确实也会比对(`verifyTLogBody` 的 `compareSignedDigest`,以及
283
+ // `verifySignature(artifact)`),但那两处都在**验签之后**、且依赖 tlog 条目
284
+ // 存在。这里在进库之前先用我们自己算的摘要卡一道:便宜、诊断清楚,
285
+ // 而且不依赖库的内部实现细节继续保持现状。
286
+ const want = Buffer.from(sha256Of(bytes).slice('sha256:'.length), 'hex');
287
+ if (!Buffer.isBuffer(md.digest) || !want.equals(Buffer.from(md.digest))) {
288
+ fail('E_BUNDLE_DIGEST_MISMATCH',
289
+ `bundle 里的 messageDigest 不是待验字节的 sha256(bundle=${safe(Buffer.from(md.digest ?? []).toString('hex'), 64)})`);
290
+ }
291
+ }
292
+
293
+ /**
294
+ * 拒绝裸公钥 bundle。`publicKey` 形态没有证书,也就没有 SAN 与 issuer 扩展 ——
295
+ * §8 的身份判定根本无从谈起,而 `Verifier` 对这种 bundle 会返回
296
+ * `identity === undefined`。不挡掉的话,「身份判定」会退化成「有没有 identity」,
297
+ * 那正是把验签器变成恒真的路子。
298
+ */
299
+ function assertHasCertificate(b) {
300
+ const vm = b.verificationMaterial?.content;
301
+ if (vm?.$case !== 'certificate' && vm?.$case !== 'x509CertificateChain') {
302
+ fail('E_BUNDLE_NO_CERT',
303
+ `bundle 的 verificationMaterial 是 ${safe(vm?.$case ?? '(缺失)')}:` +
304
+ '裸公钥没有 SAN / issuer 扩展,无法做 §8 的身份判定');
305
+ }
306
+ }
307
+
308
+ /**
309
+ * 🔴 要求真正的**包含证明**(checkpoint 签名 + RFC6962 Merkle 路径),
310
+ * 不接受只有 inclusion promise(SET)的 bundle。
311
+ *
312
+ * 两者的安全性不一样:SET 只证明「Rekor 当时承诺会收录」,包含证明才证明
313
+ * 「它确实在那棵已签名的树里」。07-threat-model.md 把「透明日志事后可发现」
314
+ * 当成对手 C(维护者账号被接管)的唯一兜底 —— 承诺兑现不了的话这条兜底就是空的。
315
+ *
316
+ * ⚠️ **不能靠 `mediaType` 来间接实现这条。** `bundleFromJSON` 只在 v0.3
317
+ * 媒体类型下强制包含证明,而 `mediaType` 是 bundle 里的一个字段、**由对手控制**:
318
+ * 声明成 `…;version=0.1` 就能走到只校验 inclusion promise 的分支。
319
+ * 所以这里对**已解析的结构**自己查一遍,与声明的版本无关。
320
+ */
321
+ function assertInclusionProofs(b) {
322
+ const entries = b.verificationMaterial?.tlogEntries ?? [];
323
+ if (entries.length === 0) {
324
+ fail('E_NO_TLOG_ENTRY', 'bundle 里没有 Rekor 透明日志条目');
325
+ }
326
+ entries.forEach((e, i) => {
327
+ const p = e.inclusionProof;
328
+ if (!p) {
329
+ fail('E_NO_INCLUSION_PROOF',
330
+ `tlogEntries[${i}] 只有 inclusion promise,没有包含证明:` +
331
+ '承诺不等于收录,透明日志的事后可发现性会落空');
332
+ }
333
+ if (!p.checkpoint?.envelope) {
334
+ fail('E_NO_INCLUSION_PROOF', `tlogEntries[${i}] 的包含证明缺 checkpoint`);
335
+ }
336
+ assertProofSelfConsistent(e, p, `tlogEntries[${i}]`);
337
+ });
338
+ }
339
+
340
+ /**
341
+ * 包含证明里有一组**冗余字段**:`rootHash` / `treeSize` / `logIndex` 在
342
+ * 已签名的 checkpoint 里各有一份。`verifyMerkleInclusion()` 只读 checkpoint
343
+ * 里那一份(那才是被签名的权威值),**完全不看** proof 自己带的这几个。
344
+ *
345
+ * 于是一个把 `inclusionProof.rootHash` 改成任意值的 bundle 照样能验过。
346
+ * 密码学上没问题,但形态是自相矛盾的 —— 下游要是拿这几个字段去展示或入库,
347
+ * 记下来的就是攻击者写的数。这里要求它们与 checkpoint 一致,矛盾即拒。
348
+ *
349
+ * ⚠️ 这是**一致性**检查,不是安全检查:此时 checkpoint 还没验签。
350
+ * 真正的权威仍然是库验过签之后的 checkpoint。
351
+ */
352
+ export function assertProofSelfConsistent(entry, p, where) {
353
+ // checkpoint note 的前三行:origin / logSize / base64(rootHash)
354
+ const note = String(p.checkpoint.envelope).split('\n\n')[0];
355
+ const lines = note.split('\n');
356
+ if (lines.length < 3) {
357
+ fail('E_PROOF_INCONSISTENT', `${where} 的 checkpoint 头部行数不足`);
358
+ }
359
+ const [, logSize, rootB64] = lines;
360
+ const proofRoot = Buffer.from(p.rootHash ?? []).toString('base64');
361
+ if (proofRoot !== rootB64) {
362
+ fail('E_PROOF_INCONSISTENT',
363
+ `${where}.inclusionProof.rootHash 与已签名 checkpoint 里的根不一致:` +
364
+ '冗余字段自相矛盾(Merkle 验证只认 checkpoint,这份会被下游误读)');
365
+ }
366
+ if (String(p.treeSize) !== logSize) {
367
+ fail('E_PROOF_INCONSISTENT',
368
+ `${where}.inclusionProof.treeSize=${safe(String(p.treeSize), 32)} 与 checkpoint 的 logSize=${safe(logSize, 32)} 不一致`);
369
+ }
370
+ // 🔴 **不要求 `proof.logIndex === entry.logIndex`** —— 它们语义不同:
371
+ // · `entry.logIndex` = 条目在日志里的**全局**索引(跨分片单调)
372
+ // · `proof.logIndex` = 条目在**当前这棵树**里的索引
373
+ // Rekor 分片之后两者本来就差一个偏移量。早先这里判相等,
374
+ // **会拒掉每一份合法 bundle** —— dry-run 的 canary 当场抓到(2026-08-28):
375
+ // 真实值 2620957627 vs 2499053365,差约 1.2 亿。
376
+ //
377
+ // ⚠️ 这条检查是「冗余字段自相矛盾」防线的一部分,不能直接删掉了事。
378
+ // 真正成立的不变量是**树内索引必须落在这棵树里**:
379
+ if (!(p.logIndex >= 0n && p.logIndex < BigInt(logSize))) {
380
+ fail('E_PROOF_INCONSISTENT',
381
+ `${where}.inclusionProof.logIndex=${safe(String(p.logIndex), 32)} 不在 [0, treeSize=${safe(logSize, 32)}) 内`);
382
+ }
383
+ }
384
+
385
+ /**
386
+ * 🔴 bundle 整份都是**对手控制**的,而库对 tlog 条目与 SCT 的去重是 O(n²)、
387
+ * 且去重之前每一条都要先做一次真的密码学验证。塞几千条进来就是一个可远程
388
+ * 触发的 CPU/内存打点。这里先按上限卡死 —— 真实 bundle 只有个位数条目。
389
+ *
390
+ * 这不影响正确性(超限只会更早失败),只关掉可用性打点。
391
+ */
392
+ const MAX_TLOG_ENTRIES = 8;
393
+ const MAX_TLOG_BODY_BYTES = 1 << 20; // 1 MiB
394
+
395
+ function assertBundleSize(b) {
396
+ const entries = b.verificationMaterial?.tlogEntries ?? [];
397
+ if (entries.length > MAX_TLOG_ENTRIES) {
398
+ fail('E_BUNDLE_TOO_LARGE',
399
+ `bundle 带了 ${entries.length} 条 tlog 条目,上限 ${MAX_TLOG_ENTRIES}(去重是 O(n²),这是拒绝服务面)`);
400
+ }
401
+ const tsa = b.verificationMaterial?.timestampVerificationData?.rfc3161Timestamps ?? [];
402
+ if (tsa.length > MAX_TLOG_ENTRIES) {
403
+ fail('E_BUNDLE_TOO_LARGE', `bundle 带了 ${tsa.length} 个 RFC3161 时间戳,上限 ${MAX_TLOG_ENTRIES}`);
404
+ }
405
+ entries.forEach((e, i) => {
406
+ const n = e.canonicalizedBody?.length ?? 0;
407
+ if (n > MAX_TLOG_BODY_BYTES) {
408
+ fail('E_BUNDLE_TOO_LARGE', `tlogEntries[${i}].canonicalizedBody 有 ${n} 字节,上限 ${MAX_TLOG_BODY_BYTES}`);
409
+ }
410
+ });
411
+ }
412
+
413
+ /**
414
+ * 🔴 证书链的有效期是**对着可信时间戳**校验的,而 `@sigstore/verify` 只认两种
415
+ * 可信时间来源:Rekor 条目的 **inclusion promise(SET)**,或 RFC3161 的
416
+ * **TSA 时间戳**。**inclusion proof 本身不提供时间** —— `getTLogTimestamp()`
417
+ * 在没有 `inclusionPromise` 时直接返回 undefined。
418
+ *
419
+ * 于是「只有包含证明、没有承诺、也没有 TSA」的 bundle 会以
420
+ * `TIMESTAMP_ERROR: expected 1 timestamps, got 0` 失败 —— 是 fail-closed,
421
+ * 但报错指向「时间戳不够」,看的人会以为是时钟问题。这里提前查一遍,
422
+ * 把它变成一条说得清的错。
423
+ *
424
+ * (实测 2026-08-26:npm / GitHub Actions 由 sigstore-js 产出的 v0.2 与 v0.3
425
+ * bundle **两者都带**,所以这条不会挡住今天的生产制品。Rekor v2 会去掉 SET、
426
+ * 改用 TSA,那条路这里也认。)
427
+ */
428
+ function assertVerifiableTime(b) {
429
+ const entries = b.verificationMaterial?.tlogEntries ?? [];
430
+ const tsaCount = b.verificationMaterial?.timestampVerificationData?.rfc3161Timestamps?.length ?? 0;
431
+ const promises = entries.filter((e) => e.inclusionPromise).length;
432
+ if (promises + tsaCount === 0) {
433
+ fail('E_NO_TRUSTED_TIME',
434
+ 'bundle 里没有任何可信时间来源(Rekor inclusion promise / RFC3161 TSA):' +
435
+ '证书有效期无从校验。包含证明本身不提供时间');
436
+ }
437
+ }
438
+
439
+ /**
440
+ * 🔴 库的 `verifyHashedRekordTLogBody()` 逐字节比对摘要,却**不看**
441
+ * `spec.data.hash.algorithm`。我们要求 SHA-256,那就自己查这个字段 ——
442
+ * 只查 bundle 的 `messageDigest.algorithm` 是不够的,那是另一处字段。
443
+ *
444
+ * 同时把 tlog 条目的种类钉死在 `hashedrekord`:一个 messageSignature 的 bundle
445
+ * 配着 `dsse` / `intoto` 的 tlog 条目本身就是形状不对。
446
+ */
447
+ function assertTLogBodyShape(b) {
448
+ const entries = b.verificationMaterial?.tlogEntries ?? [];
449
+ entries.forEach((e, i) => {
450
+ const where = `tlogEntries[${i}]`;
451
+ const kind = e.kindVersion?.kind;
452
+ const version = e.kindVersion?.version;
453
+ if (kind !== 'hashedrekord') {
454
+ fail('E_TLOG_KIND', `${where}.kindVersion.kind 是 ${safe(kind)},messageSignature 的 bundle 只接受 hashedrekord`);
455
+ }
456
+ if (version !== '0.0.1') {
457
+ // 未知版本的 spec 形状我们没审过,也就无从确认它的摘要算法字段。
458
+ // 认不出就拒,不猜。
459
+ //
460
+ // 🔴 **已知的运维阻断点**:`@sigstore/verify` 已经支持 Rekor v2 的
461
+ // `hashedRekordV002`,但那种条目会在进库之前被这里挡下。
462
+ // Rekor v2 一旦成为签发侧的默认,本条会让**全量安装失败**。
463
+ // 切换前必须先审 v2 的 spec 形状、把它加进白名单、并补上对应测试 ——
464
+ // 这是一个需要提前排期的动作,不是等报错了再改。
465
+ fail('E_TLOG_BODY_VERSION',
466
+ `${where}.kindVersion.version 是 ${safe(version)}:本实现只审过 hashedrekord 0.0.1`);
467
+ }
468
+ let body;
469
+ try {
470
+ body = JSON.parse(Buffer.from(e.canonicalizedBody).toString('utf8'));
471
+ } catch {
472
+ fail('E_TLOG_BODY_MALFORMED', `${where}.canonicalizedBody 不是合法 JSON`);
473
+ }
474
+ const algo = body?.spec?.data?.hash?.algorithm;
475
+ if (algo !== 'sha256') {
476
+ fail('E_TLOG_BODY_DIGEST_ALGO',
477
+ `${where} 的 tlog body 声明摘要算法是 ${safe(JSON.stringify(algo))},只接受 sha256`);
478
+ }
479
+ });
480
+ }