@twinsearth/w2m-dsh-plugin 1.9.0 → 1.9.3

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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * R1–R4 红线守卫(可独立测试;不接线、不影响现有功能)。
3
+ *
4
+ * R1 控制面只允许类型化元数据:字段白名单 + 禁止数据面字段 + 禁止大块 base64/二进制。
5
+ * R2 数据面只允许直连通道:p2p / ssh / git,且 endpoint 不得指向 relay。
6
+ * R3 单条控制消息 ≤ 16 KiB(原始 UTF-8 字节),拒绝而不拆包。
7
+ * R4 审计只记元数据,并维护哈希链;记录中出现内容字段即拒绝。
8
+ *
9
+ * PRIVATE 设计模块:目前无生产调用方;接入 tools.mjs 时再打开 orphan/reachability 门。
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+
13
+ export const MAX_CONTROL_BYTES = 16 * 1024;
14
+ export const CONTROL_VERSION = 1;
15
+ export const CONTROL_TYPES = Object.freeze(['joint.plan', 'joint.dispatch', 'joint.merge', 'joint.verify']);
16
+ export const DATA_PLANES = Object.freeze(['p2p', 'ssh', 'git']);
17
+
18
+ /** 控制信封允许的顶层键。没有自由透传袋。 */
19
+ export const CONTROL_ALLOWED_KEYS = Object.freeze(['v', 'type', 'body']);
20
+
21
+ /** 计划/分发/合并/验证 body 的字段白名单(并集)。 */
22
+ export const BODY_ALLOWED_KEYS = Object.freeze([
23
+ 'title', 'summary', 'branch', 'base_commit', 'steps', 'merge',
24
+ 'allow_scope_overlap', 'include_offline', 'plan_sha256', 'step_ids',
25
+ 'manifest_sha256', 'executor_machine_id', 'head_commit', 'verifier_machine_id',
26
+ 'command_argv', 'command_hash', 'cwd_rel', 'timeout_ms',
27
+ 'step_id', 'step_ids', 'machine_id', 'write_scope', 'depends_on',
28
+ ]);
29
+
30
+ /** 这些字段一律视为数据面内容,出现在控制消息里即拒绝(R1)。 */
31
+ export const FORBIDDEN_DATA_KEYS = Object.freeze([
32
+ 'data', 'file', 'files', 'content', 'contents', 'patch', 'patch_text', 'diff',
33
+ 'blob', 'blob_sha256', 'bytes', 'buffer', 'base64', 'tarball', 'artifact',
34
+ 'artifacts', 'stdout', 'stderr', 'script', 'env', 'token', 'secret', 'password',
35
+ ]);
36
+
37
+ const FORBIDDEN_KEY_SET = new Set(FORBIDDEN_DATA_KEYS);
38
+ const BASE64_BLOCK = /(?:[A-Za-z0-9+/]{256,}={0,2})/;
39
+ const HEX_BLOCK = /(?:[0-9a-fA-F]{512,})/;
40
+ // eslint-disable-next-line no-control-regex
41
+ const CONTROL_CHAR = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/;
42
+
43
+ function utf8Bytes(value) {
44
+ return Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
45
+ }
46
+
47
+ function walkKeys(value, path, hits) {
48
+ if (value === null || typeof value !== 'object') return;
49
+ if (Array.isArray(value)) {
50
+ value.forEach((entry, index) => walkKeys(entry, path + '[' + index + ']', hits));
51
+ return;
52
+ }
53
+ for (const [key, child] of Object.entries(value)) {
54
+ const keyPath = path === '' ? key : path + '.' + key;
55
+ if (FORBIDDEN_KEY_SET.has(key.toLowerCase())) hits.push(keyPath);
56
+ walkKeys(child, keyPath, hits);
57
+ }
58
+ }
59
+
60
+ function findLargeBlock(value, path, hits) {
61
+ if (typeof value === 'string') {
62
+ if (BASE64_BLOCK.test(value)) hits.push(path + ' (base64-like block)');
63
+ if (HEX_BLOCK.test(value)) hits.push(path + ' (hex block)');
64
+ if (CONTROL_CHAR.test(value)) hits.push(path + ' (control characters)');
65
+ return;
66
+ }
67
+ if (value === null || typeof value !== 'object') return;
68
+ if (Array.isArray(value)) {
69
+ value.forEach((entry, index) => findLargeBlock(entry, path + '[' + index + ']', hits));
70
+ return;
71
+ }
72
+ for (const [key, child] of Object.entries(value)) findLargeBlock(child, path === '' ? key : path + '.' + key, hits);
73
+ }
74
+
75
+ /**
76
+ * 校验一条控制消息。
77
+ * @returns {{ok: true} | {ok: false, code: string, detail: object}}
78
+ */
79
+ export function inspectControlMessage(envelope) {
80
+ if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) {
81
+ return { ok: false, code: 'CONTROL_NOT_OBJECT', detail: {} };
82
+ }
83
+ const bytes = utf8Bytes(envelope);
84
+ if (bytes > MAX_CONTROL_BYTES) {
85
+ return { ok: false, code: 'CONTROL_TOO_LARGE', detail: { bytes, limit_bytes: MAX_CONTROL_BYTES } };
86
+ }
87
+ const unknown = Object.keys(envelope).filter((key) => !CONTROL_ALLOWED_KEYS.includes(key));
88
+ if (unknown.length > 0) {
89
+ return { ok: false, code: 'FIELD_UNKNOWN', detail: { unknown, allowed: CONTROL_ALLOWED_KEYS.slice() } };
90
+ }
91
+ if (envelope.v !== CONTROL_VERSION) {
92
+ return { ok: false, code: 'CONTROL_VERSION', detail: { expected: CONTROL_VERSION, got: envelope.v ?? null } };
93
+ }
94
+ if (!CONTROL_TYPES.includes(envelope.type)) {
95
+ return { ok: false, code: 'CONTROL_TYPE', detail: { allowed: CONTROL_TYPES.slice(), got: envelope.type ?? null } };
96
+ }
97
+ const forbidden = [];
98
+ walkKeys(envelope, '', forbidden);
99
+ if (forbidden.length > 0) {
100
+ return { ok: false, code: 'DATA_PLANE_FORBIDDEN', detail: { paths: forbidden.slice(0, 8) } };
101
+ }
102
+ if (envelope.body !== undefined) {
103
+ if (envelope.body === null || typeof envelope.body !== 'object' || Array.isArray(envelope.body)) {
104
+ return { ok: false, code: 'CONTROL_BODY_INVALID', detail: {} };
105
+ }
106
+ const bodyUnknown = Object.keys(envelope.body).filter((key) => !BODY_ALLOWED_KEYS.includes(key));
107
+ if (bodyUnknown.length > 0) {
108
+ return { ok: false, code: 'FIELD_UNKNOWN', detail: { unknown: bodyUnknown, allowed: BODY_ALLOWED_KEYS.slice() } };
109
+ }
110
+ }
111
+ const blocks = [];
112
+ findLargeBlock(envelope, '', blocks);
113
+ if (blocks.length > 0) {
114
+ return { ok: false, code: 'CONTROL_CONTENT_SUSPECT', detail: { paths: blocks.slice(0, 8) } };
115
+ }
116
+ return { ok: true };
117
+ }
118
+
119
+ /**
120
+ * R2:数据面只允许直连通道,且目标不得是 relay。
121
+ * @returns {{ok: true} | {ok: false, code: string, detail: object}}
122
+ */
123
+ export function assertPlane({ plane, endpoint, relayBase }) {
124
+ if (!DATA_PLANES.includes(plane)) {
125
+ return { ok: false, code: 'PLANE_FORBIDDEN', detail: { allowed: DATA_PLANES.slice(), got: plane ?? null } };
126
+ }
127
+ if (typeof endpoint !== 'string' || endpoint.trim() === '') {
128
+ return { ok: false, code: 'PLANE_ENDPOINT_INVALID', detail: {} };
129
+ }
130
+ let target;
131
+ try {
132
+ target = new URL(endpoint);
133
+ } catch {
134
+ return { ok: false, code: 'PLANE_ENDPOINT_INVALID', detail: { endpoint } };
135
+ }
136
+ if (typeof relayBase === 'string' && relayBase !== '') {
137
+ try {
138
+ const relay = new URL(relayBase);
139
+ if (target.host === relay.host) {
140
+ return { ok: false, code: 'PLANE_POINTS_AT_RELAY', detail: { endpoint, relay: relay.host } };
141
+ }
142
+ } catch {
143
+ return { ok: false, code: 'PLANE_ENDPOINT_INVALID', detail: { relayBase } };
144
+ }
145
+ }
146
+ return { ok: true };
147
+ }
148
+
149
+ const AUDIT_FORBIDDEN_KEYS = new Set(['content', 'data', 'file', 'files', 'patch', 'stdout', 'stderr', 'diff', 'blob', 'bytes']);
150
+
151
+ function stableJson(value) {
152
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
153
+ if (Array.isArray(value)) return '[' + value.map(stableJson).join(',') + ']';
154
+ return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stableJson(value[key])).join(',') + '}';
155
+ }
156
+
157
+ function sha256Hex(text) {
158
+ return createHash('sha256').update(text, 'utf8').digest('hex');
159
+ }
160
+
161
+ /**
162
+ * R4:只记元数据 + 哈希链。
163
+ * @param {{now?: () => number}} [options]
164
+ * @returns {{append: Function, records: Function, verify: Function, head: Function}}
165
+ */
166
+ export function createAudit(options = {}) {
167
+ const now = options.now ?? Date.now;
168
+ const records = [];
169
+ let prev = null;
170
+
171
+ function append(event) {
172
+ if (event === null || typeof event !== 'object' || Array.isArray(event)) {
173
+ return { ok: false, code: 'AUDIT_EVENT_INVALID' };
174
+ }
175
+ const bad = Object.keys(event).filter((key) => AUDIT_FORBIDDEN_KEYS.has(key.toLowerCase()));
176
+ if (bad.length > 0) return { ok: false, code: 'AUDIT_CONTENT_FORBIDDEN', detail: { keys: bad } };
177
+ const body = {
178
+ v: 1,
179
+ seq: records.length + 1,
180
+ at: event.at ?? new Date(now()).toISOString(),
181
+ event: typeof event.event === 'string' && event.event !== '' ? event.event : 'joint.event',
182
+ plan_sha256: event.plan_sha256 ?? null,
183
+ step_id: event.step_id ?? null,
184
+ task_id: event.task_id ?? null,
185
+ machine_id: event.machine_id ?? null,
186
+ outcome: event.outcome ?? null,
187
+ reason_code: event.reason_code ?? null,
188
+ control_bytes: Number.isInteger(event.control_bytes) ? event.control_bytes : null,
189
+ control_sha256: typeof event.control_sha256 === 'string' ? event.control_sha256 : null,
190
+ prev_sha256: prev,
191
+ };
192
+ const record_sha256 = sha256Hex(stableJson(body));
193
+ const record = { ...body, record_sha256 };
194
+ records.push(record);
195
+ prev = record_sha256;
196
+ return { ok: true, record };
197
+ }
198
+
199
+ function verify() {
200
+ let expectedPrev = null;
201
+ for (const record of records) {
202
+ const { record_sha256: stored, ...body } = record;
203
+ if (body.prev_sha256 !== expectedPrev) return { ok: false, code: 'AUDIT_CHAIN_BROKEN', detail: { seq: record.seq } };
204
+ if (sha256Hex(stableJson(body)) !== stored) return { ok: false, code: 'AUDIT_ENTRY_TAMPERED', detail: { seq: record.seq } };
205
+ expectedPrev = stored;
206
+ }
207
+ return { ok: true, count: records.length, head: expectedPrev };
208
+ }
209
+
210
+ return {
211
+ append,
212
+ records: () => records.map((record) => ({ ...record })),
213
+ verify,
214
+ head: () => prev,
215
+ };
216
+ }
@@ -0,0 +1,141 @@
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
+ }