@twinsearth/w2m-dsh-plugin 2.0.3 → 2.0.5
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +187 -462
- package/bin/w2m-joint.mjs +69 -7
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.4/RELEASE-2040.json +22 -0
- package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
- package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
- package/docs/adr/0001-collaboration-crdt.md +59 -0
- package/lib/client.js +2754 -2599
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +97 -36
- package/src/agent/artifact-delivery.mjs +89 -0
- package/src/agent/caps.mjs +16 -2
- package/src/agent/sinks/git-sink.mjs +78 -0
- package/src/agent/sinks/ssh-sink.mjs +83 -0
- package/src/agent/sse-cursor.mjs +16 -0
- package/src/agent/sse-watchdog.mjs +71 -0
- package/src/plugin/dataplane-sink-route.mjs +113 -0
- package/src/plugin/fleet-chat-route.mjs +11 -0
- package/src/plugin/gpw.mjs +99 -15
- package/src/plugin/joint-dev/dispatch.mjs +5 -1
- package/src/plugin/joint-dev/merge-approval.mjs +115 -0
- package/src/plugin/joint-dev/progress.mjs +107 -0
- package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
- package/src/plugin/project-stream-route.mjs +58 -0
- package/src/plugin/tools-joint.mjs +6 -1
- package/src/plugin/tools.mjs +25 -5
- package/src/relay/fleet-messages.mjs +48 -24
- package/src/relay/project-room.mjs +47 -2
- package/src/relay/server.mjs +11 -5
- package/src/relay/state.mjs +63 -3
- package/src/util/chat-crypto.mjs +70 -1
- package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
- package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
- package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
- package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
- package/docs/COLLAB/TASK-MODEL.md +0 -281
- package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
- package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
- package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
- package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
- package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
- package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
- package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
- package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
- package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
- package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
- package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
- package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
- package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
- package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
- package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
- package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
- package/lib/client.js.bak2 +0 -640
- package/src/plugin/dataflow.mjs +0 -72
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
- package/src/plugin/tofuture/report.mjs +0 -44
- package/src/plugin/tools.mjs.bak-c7 +0 -7029
- package/src/relay/tofuture-whitelist.mjs +0 -46
- package/src/util/data-plane.mjs +0 -180
- package/src/util/sealed-object.mjs +0 -141
- package/src/util/tofuture-hmac.mjs +0 -30
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Relay-side ToFuture whitelist (S2): rows are HMACs only, never identifiers.
|
|
3
|
-
*
|
|
4
|
-
* The admin provisions `<stateDir>/tofuture-whitelist.json` offline:
|
|
5
|
-
* { "v": 1, "rows": [ { "kind": "mac", "hmac": "<64hex>", "salt_id": "<16hex>" }, ... ] }
|
|
6
|
-
*
|
|
7
|
-
* `compare(entries)` is intentionally silent: it returns only whether ANY entry matched, never
|
|
8
|
-
* which row, which kind, or how many; callers must not send that boolean to the client.
|
|
9
|
-
*/
|
|
10
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
12
|
-
|
|
13
|
-
const HEX64 = /^[0-9a-f]{64}$/;
|
|
14
|
-
const HEX16 = /^[0-9a-f]{16}$/;
|
|
15
|
-
|
|
16
|
-
export function loadWhitelist(stateDir) {
|
|
17
|
-
if (typeof stateDir !== 'string' || stateDir === '') return { ok: true, rows: [], empty: true };
|
|
18
|
-
const path = join(stateDir, 'tofuture-whitelist.json');
|
|
19
|
-
if (!existsSync(path)) return { ok: true, rows: [], empty: true, path };
|
|
20
|
-
let parsed;
|
|
21
|
-
try { parsed = JSON.parse(readFileSync(path, 'utf8')); } catch { return { ok: false, code: 'TOFUTURE_WHITELIST_INVALID', path }; }
|
|
22
|
-
const rows = Array.isArray(parsed?.rows) ? parsed.rows : [];
|
|
23
|
-
const clean = [];
|
|
24
|
-
for (const row of rows) {
|
|
25
|
-
if (row === null || typeof row !== 'object') continue;
|
|
26
|
-
if (!HEX64.test(String(row.hmac ?? ''))) continue;
|
|
27
|
-
if (!HEX16.test(String(row.salt_id ?? ''))) continue;
|
|
28
|
-
// Only the HMAC and its salt id are kept; any extra field (a leaked identifier) is dropped.
|
|
29
|
-
clean.push({ kind: typeof row.kind === 'string' ? row.kind.slice(0, 8) : 'any', hmac: String(row.hmac), salt_id: String(row.salt_id) });
|
|
30
|
-
}
|
|
31
|
-
return { ok: true, rows: clean, empty: clean.length === 0, path };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** @returns {boolean} true when at least one entry matches a whitelisted HMAC. */
|
|
35
|
-
export function whitelistMatches(whitelist, entries) {
|
|
36
|
-
if (whitelist === null || whitelist.ok !== true || Array.isArray(whitelist.rows) !== true) return false;
|
|
37
|
-
if (!Array.isArray(entries) || entries.length === 0) return false;
|
|
38
|
-
const byHmac = new Set(whitelist.rows.map((row) => row.hmac + '|' + row.salt_id));
|
|
39
|
-
for (const entry of entries) {
|
|
40
|
-
if (entry === null || typeof entry !== 'object') continue;
|
|
41
|
-
if (!HEX64.test(String(entry.hmac ?? ''))) continue;
|
|
42
|
-
if (!HEX16.test(String(entry.salt_id ?? ''))) continue;
|
|
43
|
-
if (byHmac.has(String(entry.hmac) + '|' + String(entry.salt_id))) return true;
|
|
44
|
-
}
|
|
45
|
-
return false;
|
|
46
|
-
}
|
package/src/util/data-plane.mjs
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* S0 — 数据面端口(DataPlaneSink)。P0 止血的地基。
|
|
3
|
-
*
|
|
4
|
-
* 红线(2026-10-27 冻结):
|
|
5
|
-
* · 用户数据只允许去用户自己选择的第三方(GitHub / 局域网 Git / SSH 云盘等);
|
|
6
|
-
* · 我们的 Relay 只承载类型化元数据(指令/状态/hash/size/count/Ref ≤4KiB);
|
|
7
|
-
* · 任何 push/fetch 目标解析后指向 relay → 一律拒绝;
|
|
8
|
-
* · 无 sink 时统一返回 ARTIFACT_NOT_DELIVERED,绝不回退 relay。
|
|
9
|
-
*
|
|
10
|
-
* 本模块无 I/O:只做 Ref 校验、sink 目标判定与端口分发。真实适配器(git/ssh/dir)
|
|
11
|
-
* 与 NullSink 由调用方注入,这样单测不碰网络与磁盘。
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export const REF_MAX_BYTES = 4096;
|
|
15
|
-
export const REF_FIELDS = Object.freeze([
|
|
16
|
-
'v', 'sink', 'locator', 'kind', 'task_id', 'machine_id',
|
|
17
|
-
'plain', 'cipher', 'enc', 'zip', 'sig',
|
|
18
|
-
]);
|
|
19
|
-
export const REF_KINDS = Object.freeze(['stdout', 'stderr', 'patch', 'memory', 'artifact', 'reduce-input']);
|
|
20
|
-
export const SINK_KINDS = Object.freeze(['github', 'lan-git', 'ssh', 'dir']);
|
|
21
|
-
export const REF_HEX = /^[0-9a-f]{64}$/;
|
|
22
|
-
|
|
23
|
-
const DATA_FIELDS = new Set([
|
|
24
|
-
'data', 'content', 'contents', 'patch', 'patch_text', 'stdout', 'stderr',
|
|
25
|
-
'text', 'blob', 'base64', 'body', 'raw',
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
function utf8Bytes(value) {
|
|
29
|
-
return Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function parseHost(endpoint) {
|
|
33
|
-
if (typeof endpoint !== 'string' || endpoint.trim() === '') return null;
|
|
34
|
-
const scpLike = /^[^@\s]+@([^:\s]+):/.exec(endpoint.trim());
|
|
35
|
-
if (scpLike !== null) return { host: scpLike[1].toLowerCase(), port: '', href: endpoint, scheme: 'ssh' };
|
|
36
|
-
try {
|
|
37
|
-
const url = new URL(endpoint);
|
|
38
|
-
return { host: url.hostname.toLowerCase(), port: url.port, href: url.href, scheme: url.protocol.replace(':', '') };
|
|
39
|
-
} catch {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function isPrivateOrLocal(host) {
|
|
45
|
-
return /^10\./.test(host)
|
|
46
|
-
|| /^192\.168\./.test(host)
|
|
47
|
-
|| /^172\.(1[6-9]|2\d|3[01])\./.test(host)
|
|
48
|
-
|| host === 'localhost'
|
|
49
|
-
|| host.endsWith('.local')
|
|
50
|
-
|| /^\[fd[0-9a-f]{2}:/i.test(host)
|
|
51
|
-
|| /^\[fe80:/i.test(host);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* 判断一个数据面目标是否获准(只做字符串/URL 级判定;DNS 解析后校验由适配器负责)。
|
|
56
|
-
* @returns {{ok: true, sink: string} | {ok: false, code: string, detail: object}}
|
|
57
|
-
*/
|
|
58
|
-
export function assertDataPlaneTarget({ endpoint, relayBase }) {
|
|
59
|
-
const target = parseHost(endpoint);
|
|
60
|
-
if (target === null) return { ok: false, code: 'DATA_PLANE_ENDPOINT_INVALID', detail: {} };
|
|
61
|
-
if (typeof relayBase === 'string' && relayBase !== '') {
|
|
62
|
-
const relay = parseHost(relayBase);
|
|
63
|
-
if (relay !== null && target.host === relay.host) {
|
|
64
|
-
return { ok: false, code: 'DATA_PLANE_POINTS_AT_RELAY', detail: { endpoint, relay: relay.host } };
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
if (target.host === 'github.com' || target.host.endsWith('.github.com')) {
|
|
68
|
-
return { ok: true, sink: 'github' };
|
|
69
|
-
}
|
|
70
|
-
if (target.scheme === 'ssh' || target.scheme === 'git') {
|
|
71
|
-
return { ok: true, sink: isPrivateOrLocal(target.host) ? 'lan-git' : 'ssh' };
|
|
72
|
-
}
|
|
73
|
-
if (target.scheme === 'http' || target.scheme === 'https') {
|
|
74
|
-
if (isPrivateOrLocal(target.host)) return { ok: true, sink: 'lan-git' };
|
|
75
|
-
return { ok: false, code: 'DATA_PLANE_SCHEME_FORBIDDEN', detail: { scheme: target.scheme, host: target.host } };
|
|
76
|
-
}
|
|
77
|
-
return { ok: false, code: 'DATA_PLANE_SCHEME_FORBIDDEN', detail: { scheme: target.scheme } };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function scanForContent(value, path, hits) {
|
|
81
|
-
if (value === null || typeof value !== 'object') return;
|
|
82
|
-
if (Array.isArray(value)) {
|
|
83
|
-
value.forEach((entry, index) => scanForContent(entry, path + '[' + index + ']', hits));
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
for (const [key, child] of Object.entries(value)) {
|
|
87
|
-
const keyPath = path === '' ? key : path + '.' + key;
|
|
88
|
-
if (DATA_FIELDS.has(key.toLowerCase())) hits.push(keyPath);
|
|
89
|
-
scanForContent(child, keyPath, hits);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* 校验进入控制面的 Ref:纯元数据、≤4KiB、无内容字段、哈希形状正确。
|
|
95
|
-
* @returns {{ok: true} | {ok: false, code: string, detail: object}}
|
|
96
|
-
*/
|
|
97
|
-
export function inspectRef(ref) {
|
|
98
|
-
if (ref === null || typeof ref !== 'object' || Array.isArray(ref)) {
|
|
99
|
-
return { ok: false, code: 'REF_NOT_OBJECT', detail: {} };
|
|
100
|
-
}
|
|
101
|
-
const bytes = utf8Bytes(ref);
|
|
102
|
-
if (bytes > REF_MAX_BYTES) {
|
|
103
|
-
return { ok: false, code: 'REF_TOO_LARGE', detail: { bytes, limit_bytes: REF_MAX_BYTES } };
|
|
104
|
-
}
|
|
105
|
-
const hits = [];
|
|
106
|
-
scanForContent(ref, '', hits);
|
|
107
|
-
if (hits.length > 0) return { ok: false, code: 'DATA_PLANE_FORBIDDEN', detail: { paths: hits.slice(0, 8) } };
|
|
108
|
-
const unknown = Object.keys(ref).filter((key) => !REF_FIELDS.includes(key));
|
|
109
|
-
if (unknown.length > 0) {
|
|
110
|
-
return { ok: false, code: 'REF_FIELD_UNKNOWN', detail: { unknown, allowed: REF_FIELDS.slice() } };
|
|
111
|
-
}
|
|
112
|
-
if (ref.v !== 1) return { ok: false, code: 'REF_VERSION', detail: { expected: 1, got: ref.v ?? null } };
|
|
113
|
-
if (!SINK_KINDS.includes(ref.sink)) {
|
|
114
|
-
return { ok: false, code: 'REF_SINK_UNKNOWN', detail: { allowed: SINK_KINDS.slice(), got: ref.sink ?? null } };
|
|
115
|
-
}
|
|
116
|
-
if (!REF_KINDS.includes(ref.kind)) {
|
|
117
|
-
return { ok: false, code: 'REF_KIND_UNKNOWN', detail: { allowed: REF_KINDS.slice(), got: ref.kind ?? null } };
|
|
118
|
-
}
|
|
119
|
-
if (typeof ref.locator !== 'string' || ref.locator.trim() === '' || ref.locator.length > 512) {
|
|
120
|
-
return { ok: false, code: 'REF_LOCATOR_INVALID', detail: {} };
|
|
121
|
-
}
|
|
122
|
-
if (ref.plain !== undefined) {
|
|
123
|
-
if (typeof ref.plain !== 'object' || !REF_HEX.test(String(ref.plain.sha256 ?? ''))) {
|
|
124
|
-
return { ok: false, code: 'REF_PLAIN_INVALID', detail: {} };
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (ref.cipher !== undefined) {
|
|
128
|
-
if (typeof ref.cipher !== 'object' || !REF_HEX.test(String(ref.cipher.sha256 ?? ''))) {
|
|
129
|
-
return { ok: false, code: 'REF_CIPHER_INVALID', detail: {} };
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return { ok: true };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* NullSink:永不上传,统一降级码。用于"无数据面"的合规默认值。
|
|
137
|
-
*/
|
|
138
|
-
export function createNullSink() {
|
|
139
|
-
return {
|
|
140
|
-
kind: 'null',
|
|
141
|
-
async pushObject() {
|
|
142
|
-
return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', detail: { reason: 'no data plane sink configured' } };
|
|
143
|
-
},
|
|
144
|
-
async fetchObject() {
|
|
145
|
-
return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', detail: { reason: 'no data plane sink configured' } };
|
|
146
|
-
},
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* 端口分发:调用方注入 sinks(github/lan-git/ssh/dir),未命中一律 NullSink。
|
|
152
|
-
* @param {{sinks?: Record<string, object>, relayBase?: string}} [options]
|
|
153
|
-
*/
|
|
154
|
-
export function createDataPlane(options = {}) {
|
|
155
|
-
const sinks = options.sinks ?? {};
|
|
156
|
-
const relayBase = options.relayBase ?? '';
|
|
157
|
-
function pick(endpoint) {
|
|
158
|
-
const target = assertDataPlaneTarget({ endpoint, relayBase });
|
|
159
|
-
if (target.ok !== true) return { ok: false, result: target };
|
|
160
|
-
const sink = sinks[target.sink];
|
|
161
|
-
if (sink === undefined) return { ok: true, sink: createNullSink() };
|
|
162
|
-
return { ok: true, sink };
|
|
163
|
-
}
|
|
164
|
-
return {
|
|
165
|
-
async pushObject({ endpoint, ref, ...rest }) {
|
|
166
|
-
const refCheck = inspectRef(ref);
|
|
167
|
-
if (refCheck.ok !== true) return refCheck;
|
|
168
|
-
const chosen = pick(endpoint);
|
|
169
|
-
if (chosen.ok !== true) return chosen.result;
|
|
170
|
-
return chosen.sink.pushObject({ ref, ...rest });
|
|
171
|
-
},
|
|
172
|
-
async fetchObject({ endpoint, ref, ...rest }) {
|
|
173
|
-
const refCheck = inspectRef(ref);
|
|
174
|
-
if (refCheck.ok !== true) return refCheck;
|
|
175
|
-
const chosen = pick(endpoint);
|
|
176
|
-
if (chosen.ok !== true) return chosen.result;
|
|
177
|
-
return chosen.sink.fetchObject({ ref, ...rest });
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 数据面密封对象:压缩 → AES-256-GCM → X25519 封装内容密钥 → Ed25519 签名。
|
|
3
|
-
* 顺序固定:compress → encrypt → sign;校验顺序固定:验签 → 解密 → 解压 → sha。
|
|
4
|
-
* relay 永不接触明文、内容密钥或私钥。
|
|
5
|
-
*/
|
|
6
|
-
import {
|
|
7
|
-
createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, diffieHellman,
|
|
8
|
-
generateKeyPairSync, hkdfSync, randomBytes, sign as edSign, verify as edVerify,
|
|
9
|
-
} from 'node:crypto';
|
|
10
|
-
import { createRequire } from 'node:module';
|
|
11
|
-
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
12
|
-
|
|
13
|
-
// zstd landed in Node 22.15/23.8 while engines allow Node 20: feature-detect instead of importing
|
|
14
|
-
// the name, because a named ESM import of a missing export fails at link time on the older runtime.
|
|
15
|
-
const require = createRequire(import.meta.url);
|
|
16
|
-
let zstdCompressSync = null;
|
|
17
|
-
let zstdDecompressSync = null;
|
|
18
|
-
try {
|
|
19
|
-
const zlib = require('node:zlib');
|
|
20
|
-
if (typeof zlib.zstdCompressSync === 'function') {
|
|
21
|
-
zstdCompressSync = zlib.zstdCompressSync;
|
|
22
|
-
zstdDecompressSync = zlib.zstdDecompressSync;
|
|
23
|
-
}
|
|
24
|
-
} catch { /* older Node: gzip only */ }
|
|
25
|
-
|
|
26
|
-
export const MAX_OBJECT_BYTES = 64 * 1024 * 1024;
|
|
27
|
-
export const COMPRESS_THRESHOLD = 4 * 1024;
|
|
28
|
-
export const MAX_RATIO = 100;
|
|
29
|
-
|
|
30
|
-
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
31
|
-
const aadOf = ({ taskId, plainSha256, sink }) => Buffer.from(taskId + '|' + plainSha256 + '|' + sink, 'utf8');
|
|
32
|
-
|
|
33
|
-
function compress(bytes) {
|
|
34
|
-
if (bytes.length <= COMPRESS_THRESHOLD) return { alg: 'none', body: bytes };
|
|
35
|
-
const zstd = typeof zstdCompressSync === 'function';
|
|
36
|
-
try {
|
|
37
|
-
const body = zstd ? zstdCompressSync(bytes) : gzipSync(bytes, { level: 6 });
|
|
38
|
-
if (body.length >= bytes.length * 0.95) return { alg: 'none', body: bytes };
|
|
39
|
-
return { alg: zstd ? 'zstd' : 'gzip', body };
|
|
40
|
-
} catch {
|
|
41
|
-
return { alg: 'none', body: bytes };
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function decompress(alg, body, declaredBytes) {
|
|
46
|
-
if (alg === 'none') return body;
|
|
47
|
-
const out = alg === 'zstd' ? zstdDecompressSync(body) : gunzipSync(body);
|
|
48
|
-
if (out.length > declaredBytes) throw new Error('DECOMPRESSION_LIMIT');
|
|
49
|
-
return out;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** 生成接收方 X25519 密钥对(PEM)。 */
|
|
53
|
-
export function createRecipientKeys() {
|
|
54
|
-
const pair = generateKeyPairSync('x25519');
|
|
55
|
-
return {
|
|
56
|
-
publicKeyPem: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
57
|
-
privateKeyPem: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** 生成签名密钥对(Ed25519 PEM)。 */
|
|
62
|
-
export function createSigningKeys() {
|
|
63
|
-
const pair = generateKeyPairSync('ed25519');
|
|
64
|
-
return {
|
|
65
|
-
publicKeyPem: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
66
|
-
privateKeyPem: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* @param {{bytes: Buffer, taskId: string, sink: string, recipientPublicKeyPem: string, signerPrivateKeyPem: string, maxBytes?: number}} input
|
|
72
|
-
*/
|
|
73
|
-
export function sealObject(input) {
|
|
74
|
-
const { bytes, taskId, sink, recipientPublicKeyPem, signerPrivateKeyPem } = input;
|
|
75
|
-
const maxBytes = Number.isInteger(input.maxBytes) ? input.maxBytes : MAX_OBJECT_BYTES;
|
|
76
|
-
if (!Buffer.isBuffer(bytes)) throw new Error('OBJECT_NOT_BYTES');
|
|
77
|
-
if (bytes.length > maxBytes) throw new Error('OBJECT_TOO_LARGE');
|
|
78
|
-
if (typeof taskId !== 'string' || taskId === '' || typeof sink !== 'string' || sink === '') throw new Error('OBJECT_META_INVALID');
|
|
79
|
-
|
|
80
|
-
const plainSha256 = sha256(bytes);
|
|
81
|
-
const zip = compress(bytes);
|
|
82
|
-
const cek = randomBytes(32);
|
|
83
|
-
const iv = randomBytes(12);
|
|
84
|
-
const aad = aadOf({ taskId, plainSha256, sink });
|
|
85
|
-
const cipher = createCipheriv('aes-256-gcm', cek, iv);
|
|
86
|
-
cipher.setAAD(aad);
|
|
87
|
-
const ciphertext = Buffer.concat([cipher.update(zip.body), cipher.final()]);
|
|
88
|
-
const tag = cipher.getAuthTag();
|
|
89
|
-
|
|
90
|
-
const eph = generateKeyPairSync('x25519');
|
|
91
|
-
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: createPublicKey(recipientPublicKeyPem) });
|
|
92
|
-
const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.from(plainSha256.slice(0, 32), 'hex'), Buffer.from('w2m-sealed-v1', 'utf8'), 32));
|
|
93
|
-
const wrapIv = randomBytes(12);
|
|
94
|
-
const wrapCipher = createCipheriv('aes-256-gcm', kek, wrapIv);
|
|
95
|
-
const wrapped = Buffer.concat([wrapCipher.update(cek), wrapCipher.final()]);
|
|
96
|
-
const wrapTag = wrapCipher.getAuthTag();
|
|
97
|
-
|
|
98
|
-
const signature = edSign(null, Buffer.from(plainSha256 + '|' + bytes.length + '|' + sink + '|' + taskId, 'utf8'), signerPrivateKeyPem);
|
|
99
|
-
return {
|
|
100
|
-
v: 1,
|
|
101
|
-
alg: 'x25519-hkdf-sha256-aes-256-gcm-v1',
|
|
102
|
-
comp: zip.alg,
|
|
103
|
-
sink,
|
|
104
|
-
task_id: taskId,
|
|
105
|
-
plain: { sha256: plainSha256, bytes: bytes.length },
|
|
106
|
-
cipher: { bytes: ciphertext.length + tag.length },
|
|
107
|
-
aad_sha256: sha256(aad),
|
|
108
|
-
epk: eph.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
109
|
-
iv: iv.toString('base64'),
|
|
110
|
-
tag: tag.toString('base64'),
|
|
111
|
-
wrap: { iv: wrapIv.toString('base64'), tag: wrapTag.toString('base64'), value: wrapped.toString('base64') },
|
|
112
|
-
body: ciphertext.toString('base64'),
|
|
113
|
-
sig: signature.toString('base64'),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** 验签 → 解封装 → 解密 → 解压 → sha 复核。 */
|
|
118
|
-
export function openObject(sealed, { recipientPrivateKeyPem, signerPublicKeyPem, maxBytes }) {
|
|
119
|
-
const limit = Number.isInteger(maxBytes) ? maxBytes : MAX_OBJECT_BYTES;
|
|
120
|
-
const expected = sealed?.plain?.sha256;
|
|
121
|
-
if (typeof expected !== 'string') throw new Error('OBJECT_INVALID');
|
|
122
|
-
const ok = edVerify(null, Buffer.from(expected + '|' + sealed.plain.bytes + '|' + sealed.sink + '|' + sealed.task_id, 'utf8'), signerPublicKeyPem, Buffer.from(sealed.sig, 'base64'));
|
|
123
|
-
if (ok !== true) throw new Error('OBJECT_SIGNATURE_INVALID');
|
|
124
|
-
if (sealed.plain.bytes > limit) throw new Error('OBJECT_TOO_LARGE');
|
|
125
|
-
|
|
126
|
-
const aad = aadOf({ taskId: sealed.task_id, plainSha256: expected, sink: sealed.sink });
|
|
127
|
-
const shared = diffieHellman({ privateKey: createPrivateKey(recipientPrivateKeyPem), publicKey: createPublicKey(sealed.epk) });
|
|
128
|
-
const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.from(expected.slice(0, 32), 'hex'), Buffer.from('w2m-sealed-v1', 'utf8'), 32));
|
|
129
|
-
const unwrap = createDecipheriv('aes-256-gcm', kek, Buffer.from(sealed.wrap.iv, 'base64'));
|
|
130
|
-
unwrap.setAuthTag(Buffer.from(sealed.wrap.tag, 'base64'));
|
|
131
|
-
const cek = Buffer.concat([unwrap.update(Buffer.from(sealed.wrap.value, 'base64')), unwrap.final()]);
|
|
132
|
-
|
|
133
|
-
const decipher = createDecipheriv('aes-256-gcm', cek, Buffer.from(sealed.iv, 'base64'));
|
|
134
|
-
decipher.setAAD(aad);
|
|
135
|
-
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
|
|
136
|
-
const zipBody = Buffer.concat([decipher.update(Buffer.from(sealed.body, 'base64')), decipher.final()]);
|
|
137
|
-
const plain = decompress(sealed.comp, zipBody, sealed.plain.bytes);
|
|
138
|
-
if (plain.length !== sealed.plain.bytes) throw new Error('OBJECT_SIZE_MISMATCH');
|
|
139
|
-
if (sha256(plain) !== expected) throw new Error('OBJECT_HASH_MISMATCH');
|
|
140
|
-
return { bytes: plain, sha256: expected };
|
|
141
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ToFuture identity HMAC (S2): the only form in which network identifiers leave the machine.
|
|
3
|
-
*
|
|
4
|
-
* One canonical algorithm for client, whitelist provisioning and relay comparison:
|
|
5
|
-
* salt_id = HMAC(fleet_key, "w2m-ops-salt-v1|" + machine_id) hex[0..16]
|
|
6
|
-
* hmac = HMAC(fleet_key, "w2m-ops-id-v1|" + kind + "|" + value + "|" + salt_id) hex
|
|
7
|
-
*
|
|
8
|
-
* Nothing here returns, stores or logs `value`; callers pass it in memory and keep only `hmac`.
|
|
9
|
-
*/
|
|
10
|
-
import { createHmac } from 'node:crypto';
|
|
11
|
-
|
|
12
|
-
export const ID_KINDS = Object.freeze(['mac', 'ipv4', 'ipv6', 'bssid']);
|
|
13
|
-
|
|
14
|
-
const hmacHex = (key, text) => createHmac('sha256', key).update(text, 'utf8').digest('hex');
|
|
15
|
-
|
|
16
|
-
export function saltIdOf(fleetKey, machineId) {
|
|
17
|
-
return hmacHex(String(fleetKey), 'w2m-ops-salt-v1|' + String(machineId)).slice(0, 16);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function identityHmac(fleetKey, machineId, kind, value) {
|
|
21
|
-
const salt = saltIdOf(fleetKey, machineId);
|
|
22
|
-
return { kind, salt_id: salt, hmac: hmacHex(String(fleetKey), 'w2m-ops-id-v1|' + kind + '|' + String(value) + '|' + salt) };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Stable JSON for the request body (keys sorted) — used by tests and future attestation. */
|
|
26
|
-
export function canonicalJson(value) {
|
|
27
|
-
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
28
|
-
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
|
|
29
|
-
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonicalJson(value[key])).join(',') + '}';
|
|
30
|
-
}
|