fibemate-xdiff 0.0.0-stage → 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.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # fibemate-xdiff
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ML-KEM-768 的确定性跨实现对拍工具:把随机源钉死,逐字节比较 ek / ct / ss,并对 KAT 文件本身做 provenance 与卫生检查。
4
+
5
+ ```bash
6
+ # 直接运行(CLI 命令名仍是 xdiff)
7
+ npx fibemate-xdiff run
8
+
9
+ # 或全局安装
10
+ npm i -g fibemate-xdiff
11
+ xdiff run
12
+ ```
13
+
14
+ > 包名是 `fibemate-xdiff`,可执行文件名是 `xdiff`;二者不同属预期。
15
+
16
+ ---
17
+ xdiff 读完——**这是本会话你(`.workbuddy`)造的第二个工具,且它抓到了真 bug**。逐项拍。
18
+
19
+ ---
20
+
21
+ ## 一、核心机制——✅ 判准,硬核
22
+
23
+ | # | 机制 | 判 |
24
+ | - | --------------------------------------------------------------------------- | -------------------------- |
25
+ | 1 | **确定性随机源注入**(替换 `globalThis.crypto.getRandomValues`) | ✅ |
26
+ | 2 | **随机消耗对齐后才比较字节**("总量相同才可比") | ✅ **"消除'随机抽取顺序不同'这个永久借口"** |
27
+ | 3 | **共享密钥语义自动归类**(`K̄` vs `K = SHA3-256(K̄‖H(ct))`——语义差异记 `info` 不算差异) | ✅ **硬核** |
28
+ | 4 | **密钥派生根因定位**(`standard` / `missingDomainSep` / `reversedOrder` / `unknown`) | ✅ **硬核** |
29
+
30
+ ——✅ **判准,硬核**。**"`[32,32]` 与 `[64]` 抽到的是同一段连续字节。所以能否做字节级比对,看的是总量而不是形状。"**——✅ **判准**。
31
+
32
+ ---
33
+
34
+ ## 二、🔴 抓到的真 bug——✅ 判准,硬核
35
+
36
+ > **cp 的密钥派生用了 `G(d)` 而非 `G(d‖k)`,漏拼域分隔符 `k`(`k=3`)。**
37
+ >
38
+ > - **位置:`fibemate-core/src/kem/ml-kem-768.js:548`,`const seed = sha3_512(d);`**
39
+ > - **对照:主仓 `packages/pqc-kem/src/ml-kem-768.js:225` 写作 `sha3_512(new Uint8Array([...d,K]))`,并注明 `// G(d‖k) — domain separator k`**
40
+ > - **证据:cp 的 ρ 等于 `SHA3-512(d)` 前 32 字节,与 9 个候选输入逐一比对后唯一命中这一项**
41
+
42
+ ——✅ **判准,硬核**。
43
+
44
+ **且一处更硬**——✅:
45
+
46
+ > **"只修这一处不够。在副本上应用修复后,ρ 归位(`keySchedule` 转 `standard`),pk 仍有 1148 字节不同,互操作仍失败。继续把 keygen 的矩阵索引改成标准顺序 `XOF(ρ‖j‖i)` 后,cp 自身 roundtrip 从 24/24 掉到 16/24——说明它的 keygen 与 encaps 是成对自洽的(A 与 AT 互为转置),单点修改会破坏内部一致性。"**
47
+ > **"结论:cp 的偏离是结构性的,需要按 FIPS 203 逐段核对整个密钥生成与封装流程,不是一行补丁。以上改动都在副本上验证,未修改任何源文件。"**
48
+
49
+ ——✅ **判准,硬核**。**"`A` 与 `AT` 互为转置"**——✅ **判准**。
50
+
51
+ ---
52
+
53
+ ## 三、设计取舍——✅ 判准,硬核
54
+
55
+ | # | 取舍 |
56
+ | - | ---------------------------------------------- |
57
+ | 1 | **"红的时候必须是红"**(消耗总量不同不做字节比对;语义差异不算差异;不可用与算错分开) |
58
+ | 2 | **worker 常驻**(8 样本全量约 `2.0s`) |
59
+ | 3 | **跨语言桥接批协议**(旧脚本每操作 `spawn` 一次;桥接改长期进程) |
60
+
61
+ ——✅ **判准**。
62
+
63
+ ---
64
+
65
+ ## 四、⚠️ 但**三处要你收**
66
+
67
+ ### ① 🔴 **"本次对 fibemate 的实测"——"`cp`(`fibemate-core`)"——但"`fibemate-core` 是哪个仓"**
68
+
69
+ ——**你**:
70
+
71
+ > **"三方可用:`cp`(`fibemate-core`)、`main`(主仓 `pqc-kem`)、`noble`(`@noble/post-quantum 0.7.1`)。"**
72
+
73
+ ——⚠️ **"`fibemate-core`"**——**你上几条"8 仓盘点"没提"`fibemate-core`"**——**"它是哪个"**:
74
+
75
+ | 若 | 则 |
76
+ | ------------------------------------------------------- | ------------------- |
77
+ | **"`D:\FIBEMATE\fibemate-core`(你上几条说'本地/服务器有大量历史副本')"** | ⚠️ **"历史副本——非生产线"** |
78
+ | **"另有 `fibemate-core` 仓"** | ⚠️ **"要核"** |
79
+
80
+ ——**核**:
81
+
82
+ ```bash
83
+ find D:\FIBEMATE -maxdepth 2 -type d -name "*fibemate-core*" 2>&1
84
+ cd D:\FIBEMATE\fibemate-core 2>&1 && git remote -v
85
+ ```
86
+
87
+ ——**"`fibemate-core` 是哪个仓"**——**"这决定 `cp` 的 bug 影响面"**。
88
+
89
+ ### ② 🔴 **"`cp` 的 bug"——"`fibemate-core` 是'历史副本'还是'生产线'"**
90
+
91
+ ——**若"`fibemate-core` 是历史副本"** → ⚠️ **"`cp` 的 bug 是历史副本的——非生产线"**;**若"是生产线"** → 🔴 **"生产线有 bug"**——**核**。
92
+
93
+ ### ③ 🔴 **"以上改动都在副本上验证,未修改任何源文件"——但"副本是哪份"**
94
+
95
+ ——**你**:
96
+
97
+ > **"在副本上应用修复后……继续把 keygen 的矩阵索引改成标准顺序 `XOF(ρ‖j‖i)` 后,cp 自身 roundtrip 从 24/24 掉到 16/24……以上改动都在副本上验证,未修改任何源文件。"**
98
+
99
+ ——✅ **判准**——**"副本上验证,未改源文件"**——**但"副本是哪份"**——**"别混"**。
100
+
101
+ ---
102
+
103
+ ## 五、拍板
104
+
105
+ | # | 事项 | 拍板 |
106
+ | - | -------------------------- | --------------------- |
107
+ | 1 | 核心机制 | ✅ **判准,硬核** |
108
+ | 2 | **"抓到的真 bug"** | ✅ **判准,硬核** |
109
+ | 3 | 设计取舍 | ✅ |
110
+ | 4 | **"`fibemate-core` 是哪个仓"** | 🔴 **核** |
111
+ | 5 | **"`cp` 的 bug 影响面"** | 🔴 **核——历史副本 vs 生产线** |
112
+ | 6 | **"副本上验证"** | ✅ |
113
+
114
+ ---
115
+
116
+ ## 六、一句话
117
+
118
+ **xdiff 读完判准,硬核**——**核心机制(确定性随机源注入 / 随机消耗对齐 / 共享密钥语义归类 / 密钥派生根因定位)**;**🔴 抓到的真 bug 判准,硬核**——**`cp` 的 `G(d)` 漏拼 `k`(`fibemate-core/src/kem/ml-kem-768.js:548`)/ `ρ = SHA3-512(d)` 前 32 字节 / "只修这一处不够——`A` 与 `AT` 互为转置,结构性偏离"**;**设计取舍判准**(**"红的时候必须是红" / worker 常驻 / 跨语言桥接批协议**)。**但三处收**:① 🔴 **"`cp`(`fibemate-core`)是哪个仓"**;② 🔴 **"它是历史副本还是生产线——决定 bug 影响面"**;③ ✅ **"副本上验证,未改源文件"**。
119
+
120
+ ---
121
+
122
+ ## 七、你定
123
+
124
+ **A.(推荐)核 `fibemate-core` 是哪个仓 + 生产线/历史副本**
125
+
126
+ ```bash
127
+ find D:\FIBEMATE -maxdepth 2 -type d -name "*fibemate-core*" 2>&1
128
+ cd D:\FIBEMATE\fibemate-core 2>&1 && git remote -v
129
+ git log --oneline -1
130
+ ```
131
+
132
+ **B. 你锁"`fibemate-core` = 历史副本"** → **"bug 影响面小"**
4
133
 
134
+ **C. 先停**
135
+
136
+ 要哪条?**A 最紧**——**"`fibemate-core` 是哪个 + 历史副本 vs 生产线——决定 bug 影响面"**。
137
+
138
+ ---
139
+
140
+ ## 八、构建 oqsbridge(liboqs 桥接)
141
+
142
+ `oqsbridge.exe` 是 liboqs C 参考实现的 CLI 桥接产物,**不进仓**(构建产物,源码 `tools/oqsbridge.c` 顶部含 Build 说明)。编译命令:
143
+
144
+ ```bash
145
+ gcc -O2 -o oqsbridge.exe oqsbridge.c -I <liboqs>/include -L <liboqs>/lib -loqs
146
+ ```
147
+
148
+ 依赖 liboqs(`.c` 注释提及 liboqs 0.16 已移除旧静态符号 `OQS_KEM_ml_kem_768_length_*`,需改用 `OQS_KEM_new()` 动态取长度)。本机当前未装 liboqs,`oqsbridge.exe` 尚未重建;编出完整库后,`xdiff.json` 里 liboqs 实现的 `path` 指向该产物即可用。
149
+
150
+ > 注:本机曾构建的 `.exe` 为「算法全禁用的空壳」(`OQS_KEM_new()` 返回 NULL,实测不可用),已移至 `C:\FIBEMATE\backups\oqsbridge.exe` 留档。
package/bin/xdiff.js ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ 'use strict';
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { loadConfig, DEFAULTS } = require('../src/config');
8
+ const { create: createLogger } = require('../src/logger');
9
+ const engine = require('../src/engine');
10
+ const report = require('../src/report');
11
+ const { UsageError, ConfigError, toEnvelope } = require('../src/errors');
12
+
13
+ const USAGE = `xdiff — 跨实现差分哨兵
14
+
15
+ 用法:
16
+ xdiff run [--config <file>] [--seed <str>] [--samples <n>] [--format text|md|json]
17
+ [--out <file>] [--fail-on error|warn|none] [--quiet] [--require-impl <N>]
18
+ xdiff list [--config <file>] 列出已注册实现及其可用性
19
+ xdiff init [--dir <dir>] 生成 xdiff.json 配置样例
20
+ xdiff explain <findingId> [--file <report>] 打印某条差异的完整详情
21
+
22
+ 判定:
23
+ error 有客观答案的检查未通过(长度、往返、互操作、确定性、隐式拒绝)
24
+ warn 可疑但可能合理(拒绝值未绑定密文等)
25
+ info 观察记录(语义归类、随机消耗指纹、耗时)
26
+ `;
27
+
28
+ function parseFlags(argv) {
29
+ const flags = {};
30
+ const positional = [];
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (!a.startsWith('--')) { positional.push(a); continue; }
34
+ const key = a.slice(2);
35
+ const next = argv[i + 1];
36
+ if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; }
37
+ else flags[key] = true;
38
+ }
39
+ return { flags, positional };
40
+ }
41
+
42
+ async function cmdRun(argv) {
43
+ const { flags } = parseFlags(argv);
44
+ const log = createLogger({ quiet: !!flags.quiet, level: flags.quiet ? 'silent' : 'info' });
45
+ const overrides = {};
46
+ if (flags.seed) overrides.masterSeed = String(flags.seed);
47
+ if (flags.samples) {
48
+ const n = Number(flags.samples);
49
+ if (!Number.isFinite(n) || n < 1) throw new UsageError('--samples 必须是正整数');
50
+ overrides.cases = {};
51
+ for (const k of Object.keys(DEFAULTS.cases)) {
52
+ if (DEFAULTS.cases[k] && typeof DEFAULTS.cases[k] === 'object' && 'samples' in DEFAULTS.cases[k]) {
53
+ overrides.cases[k] = Object.assign({}, DEFAULTS.cases[k], { samples: n });
54
+ }
55
+ }
56
+ }
57
+ const cfg = loadConfig({ cwd: process.cwd(), configPath: flags.config, overrides });
58
+ const rep = await engine.run(cfg, { log });
59
+ const requireImpl = flags['require-impl'] ? Number(flags['require-impl']) : 0;
60
+ if (requireImpl > 0 && Number.isFinite(requireImpl)) {
61
+ const readyCount = rep.implementations.filter(i => i.status === 'ready').length;
62
+ if (readyCount < requireImpl) {
63
+ const missing = rep.implementations.filter(i => i.status !== 'ready').map(i => i.id + '=' + (i.reason || 'unknown')).join(', ');
64
+ log.error('require-impl=' + requireImpl + ' ready=' + readyCount + ' [' + missing + ']');
65
+ return 1;
66
+ }
67
+ }
68
+
69
+ const format = String(flags.format || 'text');
70
+ let body;
71
+ if (format === 'json') body = report.renderJson(rep);
72
+ else if (format === 'md') body = report.renderMarkdown(rep, { top: flags.top ? Number(flags.top) : 30 });
73
+ else body = report.renderText(rep, { top: flags.top ? Number(flags.top) : 20 });
74
+
75
+ if (flags.out) {
76
+ const outAbs = path.resolve(process.cwd(), String(flags.out));
77
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
78
+ fs.writeFileSync(outAbs, body, 'utf8');
79
+ if (!flags.quiet) process.stderr.write('报告已写入 ' + outAbs + '\n');
80
+ } else {
81
+ process.stdout.write(body + '\n');
82
+ }
83
+
84
+ const failOn = String(flags['fail-on'] || cfg.output.failOn || 'error');
85
+ if (failOn === 'none') return 0;
86
+ if (failOn === 'warn' && (rep.counts.error > 0 || rep.counts.warn > 0)) return 1;
87
+ if (rep.counts.error > 0) return 1;
88
+ return 0;
89
+ }
90
+
91
+ async function cmdList(argv) {
92
+ const { flags } = parseFlags(argv);
93
+ const log = createLogger({ quiet: true });
94
+ const cfg = loadConfig({ cwd: process.cwd(), configPath: flags.config });
95
+ const impls = await require('../src/impls/registry').loadAll(cfg, null);
96
+ const L = [];
97
+ L.push('算法 ' + cfg.algorithm + ' 期望尺寸 pk=' + cfg.sizes.pk + ' sk=' + cfg.sizes.sk + ' ct=' + cfg.sizes.ct + ' ss=' + cfg.sizes.ss);
98
+ L.push('');
99
+ for (const im of impls) {
100
+ L.push(pad(im.id, 8) + pad(im.status, 12) + (im.label || ''));
101
+ L.push(' 路径 ' + im.path);
102
+ if (im.reason) L.push(' 原因 ' + im.reason);
103
+ if (im.rngProfileKeygen) L.push(' keygen 随机消耗 ' + JSON.stringify(im.rngProfileKeygen));
104
+ if (im.probeOk) L.push(' 探测 keygen ' + im.readyMs + 'ms');
105
+ }
106
+ process.stdout.write(L.join('\n') + '\n');
107
+ void log;
108
+ return impls.some((i) => i.status === 'ready') ? 0 : 1;
109
+ }
110
+
111
+ async function cmdInit(argv) {
112
+ const { flags } = parseFlags(argv);
113
+ const dir = path.resolve(process.cwd(), String(flags.dir || '.'));
114
+ const target = path.join(dir, 'xdiff.json');
115
+ if (fs.existsSync(target) && flags.force !== true) {
116
+ throw new UsageError('已存在 ' + target + '(加 --force 覆盖)');
117
+ }
118
+ const sample = {
119
+ version: 1,
120
+ algorithm: DEFAULTS.algorithm,
121
+ sizes: DEFAULTS.sizes,
122
+ masterSeed: 'xdiff-default-master-seed-v1',
123
+ implementations: DEFAULTS.implementations,
124
+ cases: DEFAULTS.cases,
125
+ pool: DEFAULTS.pool,
126
+ output: DEFAULTS.output
127
+ };
128
+ fs.mkdirSync(dir, { recursive: true });
129
+ fs.writeFileSync(target, JSON.stringify(sample, null, 2) + '\n', 'utf8');
130
+ process.stdout.write('已生成 ' + target + '\n');
131
+ return 0;
132
+ }
133
+
134
+ async function cmdExplain(argv) {
135
+ const { flags, positional } = parseFlags(argv);
136
+ const id = positional[0];
137
+ if (!id) throw new UsageError('需要提供 finding id');
138
+ const file = String(flags.file || path.join(process.cwd(), '.xdiff', 'report.json'));
139
+ if (!fs.existsSync(file)) throw new UsageError('报告文件不存在: ' + file);
140
+ const rep = JSON.parse(fs.readFileSync(file, 'utf8'));
141
+ const hit = rep.findings.find((f) => f.id === id) || rep.findings.find((f) => f.id.indexOf(id) >= 0);
142
+ if (!hit) throw new UsageError('未找到 finding: ' + id);
143
+ process.stdout.write(JSON.stringify(hit, null, 2) + '\n');
144
+ return 0;
145
+ }
146
+
147
+ function pad(s, n) {
148
+ let out = String(s == null ? '' : s);
149
+ let w = 0;
150
+ for (const ch of out) w += /[\u4e00-\u9fa5]/.test(ch) ? 2 : 1;
151
+ void w;
152
+ return out + ' '.repeat(Math.max(0, n - out.length));
153
+ }
154
+
155
+ async function main(rawArgv) {
156
+ const argv = rawArgv.slice(2);
157
+ const cmd = argv[0] || 'run';
158
+ const rest = argv.slice(1);
159
+ switch (cmd) {
160
+ case 'run': return await cmdRun(rest);
161
+ case 'list': return await cmdList(rest);
162
+ case 'init': return await cmdInit(rest);
163
+ case 'explain': return await cmdExplain(rest);
164
+ case 'help': case '-h': case '--help': process.stdout.write(USAGE); return 0;
165
+ default:
166
+ if (cmd.startsWith('--')) return await cmdRun(argv);
167
+ throw new UsageError('未知命令: ' + cmd + '\n\n' + USAGE);
168
+ }
169
+ }
170
+
171
+ if (require.main === module) {
172
+ main(process.argv).then((code) => {
173
+ process.exitCode = code;
174
+ }).catch((err) => {
175
+ const env = toEnvelope(err);
176
+ process.stderr.write(JSON.stringify(env) + '\n');
177
+ if (err instanceof UsageError) process.stderr.write(USAGE + '\n');
178
+ process.exitCode = err instanceof ConfigError || err instanceof UsageError ? 2 : 1;
179
+ });
180
+ }
181
+
182
+ module.exports = { main };
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "fibemate-xdiff",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Deterministic cross-implementation diff harness for ML-KEM-768: KAT provenance, RNG-consumption alignment, key-schedule root cause.",
5
+ "type": "commonjs",
6
+ "main": "src/engine.js",
7
+ "bin": {
8
+ "xdiff": "./bin/xdiff.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "tools",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node test/run.js",
18
+ "run": "node bin/xdiff.js run",
19
+ "list": "node bin/xdiff.js list",
20
+ "test:mutation": "node test/mutation.js"
21
+ },
22
+ "keywords": [
23
+ "ml-kem",
24
+ "kyber",
25
+ "post-quantum",
26
+ "pqc",
27
+ "kat",
28
+ "differential-testing"
29
+ ],
30
+ "license": "Apache-2.0",
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "dependencies": {},
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Lennonhaha/fibemate-tools.git",
38
+ "directory": "xdiff"
39
+ }
40
+ }
package/src/config.js ADDED
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { ConfigError } = require('./errors');
8
+
9
+ const FILE_NAME = 'xdiff.json';
10
+
11
+ const DEFAULTS = {
12
+ version: 1,
13
+ algorithm: 'ML-KEM-768',
14
+ // 期望尺寸,来自 FIPS 203 对 ML-KEM-768 的规定
15
+ sizes: { pk: 1184, sk: 2400, ct: 1088, ss: 32 },
16
+ // 算法参数,供密钥派生根因定位使用(k 是域分隔符,768 → 3)
17
+ params: { k: 3, eta1: 2, eta2: 2 },
18
+
19
+ masterSeed: 'xdiff-default-master-seed-v1',
20
+
21
+ implementations: [
22
+ {
23
+ id: 'cp',
24
+ label: 'fibemate-core 纯 JS',
25
+ type: 'js-mlkem',
26
+ path: '../../fibemate-core/index.js',
27
+ note: '独立发布的纯 JS 包,Keccak 为 32 位字实现'
28
+ },
29
+ {
30
+ id: 'main',
31
+ label: '主仓 packages/pqc-kem',
32
+ type: 'js-mlkem',
33
+ path: '../../fibemate/packages/pqc-kem/src/ml-kem-768.js',
34
+ note: '主仓副本,453 行,与 cp 已分叉'
35
+ },
36
+ {
37
+ id: 'noble',
38
+ label: '@noble/post-quantum ml_kem768',
39
+ type: 'noble-mlkem',
40
+ path: '../../fibemate/node_modules/@noble/post-quantum',
41
+ note: '第三方独立实现,作为交叉参考'
42
+ },
43
+ {
44
+ id: 'liboqs',
45
+ label: 'liboqs 0.16 (C)',
46
+ type: 'liboqs-bridge',
47
+ path: './tools/oqsbridge.c',
48
+ note: 'pending: needs cross-platform build; only oqsbridge.c source, no compiled binary',
49
+ enabled: false
50
+ }
51
+ ],
52
+
53
+ cases: {
54
+ lengthCompliance: { enabled: true },
55
+ roundtrip: { enabled: true, samples: 8 },
56
+ crossInterop: { enabled: true, samples: 8 },
57
+ determinism: { enabled: true, samples: 4 },
58
+ seedAlignment: { enabled: true },
59
+ keySchedule: { enabled: true },
60
+ kat: { enabled: true, rsp: null, vectors: 16 },
61
+ katFileHygiene: { enabled: true },
62
+ entropy: { enabled: true, samples: 8 },
63
+ implicitRejection: { enabled: true, samples: 4 },
64
+ rngProfile: { enabled: true },
65
+ timing: { enabled: true, samples: 20 }
66
+ },
67
+
68
+ matrix: {
69
+ // 互操作矩阵里允许「已知不同且已接受」的组合,避免噪声
70
+ allowSemanticGroups: true,
71
+ // 已确认的预期差异(例如 draft Kyber 与 FIPS 203 的 ss 语义不同)
72
+ expectedDivergence: []
73
+ },
74
+
75
+ pool: { workers: 0, timeoutMs: 120000 }, // workers: 0 = 自动
76
+
77
+ output: { dir: '.xdiff', failOn: 'error' },
78
+
79
+ cache: { enabled: true }
80
+ };
81
+
82
+ function isPlainObject(v) {
83
+ return Object.prototype.toString.call(v) === '[object Object]';
84
+ }
85
+
86
+ function deepMerge(base, patch) {
87
+ if (!isPlainObject(patch)) return base;
88
+ const out = Array.isArray(base) ? base.slice() : Object.assign({}, base);
89
+ for (const k of Object.keys(patch)) {
90
+ const pv = patch[k];
91
+ if (pv === undefined) continue;
92
+ if (isPlainObject(pv) && isPlainObject(out[k])) out[k] = deepMerge(out[k], pv);
93
+ else out[k] = Array.isArray(pv) ? pv.slice() : pv;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ function findConfigFile(startDir) {
99
+ let cur = path.resolve(startDir);
100
+ for (let i = 0; i < 8; i++) {
101
+ const p = path.join(cur, FILE_NAME);
102
+ if (fs.existsSync(p)) return p;
103
+ const parent = path.dirname(cur);
104
+ if (parent === cur) break;
105
+ cur = parent;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function loadConfig(opts) {
111
+ const o = opts || {};
112
+ const cwd = path.resolve(o.cwd || process.cwd());
113
+ let fileCfg = {};
114
+ const configPath = o.configPath || findConfigFile(cwd);
115
+ if (configPath) {
116
+ let raw;
117
+ try { raw = fs.readFileSync(configPath, 'utf8'); } catch (e) {
118
+ throw new ConfigError('配置文件读取失败', { configPath, message: e.message });
119
+ }
120
+ try { fileCfg = JSON.parse(raw); } catch (e) {
121
+ throw new ConfigError('配置文件不是合法 JSON', { configPath, message: e.message });
122
+ }
123
+ }
124
+
125
+ const envCfg = {
126
+ masterSeed: process.env.XDIFF_SEED || undefined
127
+ };
128
+ const stripped = {};
129
+ for (const k of Object.keys(envCfg)) if (envCfg[k] !== undefined) stripped[k] = envCfg[k];
130
+
131
+ let cfg = deepMerge(DEFAULTS, fileCfg);
132
+ cfg = deepMerge(cfg, stripped);
133
+ cfg = deepMerge(cfg, o.overrides || {});
134
+
135
+ cfg.__cwd = cwd;
136
+ cfg.__configPath = configPath;
137
+
138
+ // Resolve relative implementation paths against config file directory
139
+ if (Array.isArray(cfg.implementations)) {
140
+ const base = cfg.__configPath ? path.dirname(cfg.__configPath) : cfg.__cwd;
141
+ for (const im of cfg.implementations) {
142
+ if (im.path && !path.isAbsolute(im.path)) {
143
+ im.path = path.resolve(base, im.path);
144
+ }
145
+ }
146
+ }
147
+
148
+ validate(cfg);
149
+ return cfg;
150
+ }
151
+
152
+ function validate(cfg) {
153
+ if (!cfg.algorithm) throw new ConfigError('algorithm 不能为空');
154
+ const impls = cfg.implementations;
155
+ if (!Array.isArray(impls) || !impls.length) throw new ConfigError('implementations 至少需要一项');
156
+ const seen = new Set();
157
+ for (const im of impls) {
158
+ if (!im.id) throw new ConfigError('每项 implementation 需要 id');
159
+ if (seen.has(im.id)) throw new ConfigError('implementation id 重复: ' + im.id);
160
+ seen.add(im.id);
161
+ if (!im.type) throw new ConfigError('implementation 需要 type: ' + im.id);
162
+ if (!im.path) throw new ConfigError('implementation 需要 path: ' + im.id);
163
+ if (im.enabled === false) continue;
164
+ if (!fs.existsSync(im.path)) {
165
+ // 路径不存在不算致命:适配器会把它标成 unavailable
166
+ im.__missingPath = true;
167
+ }
168
+ }
169
+ if (!cfg.sizes || typeof cfg.sizes.pk !== 'number') throw new ConfigError('sizes.pk 必须是数字');
170
+ }
171
+
172
+ function cpuCount() {
173
+ try { return os.cpus().length || 4; } catch (_) { return 4; }
174
+ }
175
+
176
+ module.exports = { loadConfig, DEFAULTS, deepMerge, cpuCount, FILE_NAME };
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ 'use strict';
3
+
4
+ /**
5
+ * NIST .rsp 格式的 ML-KEM KAT 向量解析 + 参考文件卫生检查。
6
+ *
7
+ * 向量字段:count / seed(32B) / m(32B) / ek(1184B) / dk(2400B) / c(1088B) / k(32B)。
8
+ * 解析只做结构校验,不猜语义;dk 的区域含义由 hygiene 检查显式标注。
9
+ */
10
+
11
+ const U = require('../util');
12
+
13
+ const FIELD_LEN = { seed: 32, m: 32, ek: 1184, dk: 2400, c: 1088, k: 32 };
14
+
15
+ /** 解析 .rsp 文本 → 向量数组(值保留 Buffer)。 */
16
+ function parseRsp(text) {
17
+ const vectors = [];
18
+ let cur = null;
19
+ const lines = String(text).split(/\r?\n/);
20
+ for (const line of lines) {
21
+ const m = line.match(/^([A-Za-z]+)\s*=\s*([0-9a-fA-F]+)\s*$/);
22
+ if (!m) continue;
23
+ const field = m[1];
24
+ const hex = m[2].toLowerCase();
25
+ if (field === 'count') {
26
+ if (cur) vectors.push(cur);
27
+ cur = { count: parseInt(hex || '0', 10) };
28
+ continue;
29
+ }
30
+ if (!cur) continue;
31
+ if (!(field in FIELD_LEN)) { cur[field] = hex; continue; }
32
+ const buf = Buffer.from(hex, 'hex');
33
+ cur[field] = buf;
34
+ cur[field + 'LenOk'] = buf.length === FIELD_LEN[field];
35
+ }
36
+ if (cur) vectors.push(cur);
37
+ return vectors;
38
+ }
39
+
40
+ /** 结构完整性:每条向量的必备字段与长度。 */
41
+ function checkStructure(vectors) {
42
+ const bad = [];
43
+ for (const v of vectors) {
44
+ for (const f of Object.keys(FIELD_LEN)) {
45
+ if (!v[f]) { bad.push({ count: v.count, field: f, problem: 'missing' }); continue; }
46
+ if (v[f + 'LenOk'] === false) bad.push({ count: v.count, field: f, problem: 'length', got: v[f].length, expect: FIELD_LEN[f] });
47
+ }
48
+ }
49
+ return bad;
50
+ }
51
+
52
+ /**
53
+ * 卫生检查:dk 的分区域分析。
54
+ * FIPS 203 的 compact 布局:d(0:32) ‖ ek(32:1216) ‖ H(ek)(1216:1248) ‖ z(1248:1280)。
55
+ * z 与 1280 之后的尾部都应当由实现确定性写出;发现不可读来源的字节(ASCII 串、
56
+ * 指针模式、非零残留)时如实报告数量——这是信号,不是判决。
57
+ */
58
+ function analyzeHygiene(vectors) {
59
+ const compact = { dMatch: 0, ekMatch: 0, hMatch: 0, of: 0 };
60
+ const zStats = { total: 0, asciiReadable: 0, allZero: 0, unique: 0 };
61
+ const tailStats = { total: 0, allZero: 0, nonZero: 0, asciiReadable: 0 };
62
+ const zSeen = new Set();
63
+
64
+ for (const v of vectors) {
65
+ if (!v.dk || v.dk.length !== 2400 || !v.seed || !v.ek) continue;
66
+ const dk = v.dk;
67
+ compact.of++;
68
+ if (dk.slice(0, 32).equals(v.seed)) compact.dMatch++;
69
+ if (dk.slice(32, 1216).equals(v.ek)) compact.ekMatch++;
70
+ if (dk.slice(1216, 1248).equals(U.sha3_256(v.ek))) compact.hMatch++;
71
+
72
+ const z = dk.slice(1248, 1280);
73
+ zStats.total++;
74
+ zSeen.add(z.toString('hex'));
75
+ if (z.every((b) => b === 0)) zStats.allZero++;
76
+ if (hasAsciiRun(z, 6)) zStats.asciiReadable++;
77
+
78
+ const tail = dk.slice(1280);
79
+ tailStats.total++;
80
+ if (tail.every((b) => b === 0)) tailStats.allZero++;
81
+ else {
82
+ tailStats.nonZero++;
83
+ if (hasAsciiRun(tail, 8)) tailStats.asciiReadable++;
84
+ }
85
+ }
86
+ zStats.unique = zSeen.size;
87
+ return { compact, z: zStats, tail: tailStats };
88
+ }
89
+
90
+ function hasAsciiRun(buf, minLen) {
91
+ let run = 0;
92
+ for (const b of buf) {
93
+ if (b >= 0x20 && b <= 0x7e) { run++; if (run >= minLen) return true; }
94
+ else run = 0;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * 判定 KAT 文件自身的派生路径:ρ 到底等于 G(d‖k)、G(d) 还是 G(k‖d)。
101
+ *
102
+ * 纯函数,不加载任何实现——因此在只检出 fibemate-tools(没有 fibemate /
103
+ * fibemate-core 可加载)的 CI 里也能测。k 为空时返回 unknown 与空 tally。
104
+ *
105
+ * @param {Array<{seed?: Buffer, ek?: Buffer}>} vectors
106
+ * @param {number|null} k FIPS 203 的模块维度(ML-KEM-768 = 3)
107
+ * @returns {{provenance: string, tally: Record<string, number>}}
108
+ */
109
+ function analyzeProvenance(vectors, k) {
110
+ const tally = {};
111
+ if (typeof k !== 'number') return { provenance: 'unknown', tally };
112
+ const kByte = Buffer.from([k]);
113
+ const crypto = require('crypto');
114
+ const sha3_512 = (buf) => crypto.createHash('sha3-512').update(buf).digest();
115
+ for (const v of (vectors || []).slice(0, 8)) {
116
+ if (!v || !v.ek || !v.seed) continue;
117
+ const rho = v.ek.slice(v.ek.length - 32);
118
+ const cands = {
119
+ standard: sha3_512(Buffer.concat([v.seed, kByte])).slice(0, 32),
120
+ missingDomainSep: sha3_512(v.seed).slice(0, 32),
121
+ reversedOrder: sha3_512(Buffer.concat([kByte, v.seed])).slice(0, 32),
122
+ };
123
+ let hit = 'unknown';
124
+ for (const [name, val] of Object.entries(cands)) {
125
+ if (val.equals(rho)) { hit = name; break; }
126
+ }
127
+ tally[hit] = (tally[hit] || 0) + 1;
128
+ }
129
+ const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]);
130
+ return { provenance: ranked.length ? ranked[0][0] : 'unknown', tally };
131
+ }
132
+
133
+ module.exports = { parseRsp, checkStructure, analyzeHygiene, analyzeProvenance, FIELD_LEN };