@steve02081504/fount-p2p 0.0.42 → 0.0.43

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/AGENTS.md CHANGED
@@ -35,6 +35,7 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
35
35
  - **Heterogeneous backends:** normalize at the load boundary (e.g. `link/rtc/ice_local_hostname.mjs` wraps W3C RTC backends); call sites speak one contract.
36
36
  - **File naming:** parent directory is scope — short child names. Tunables default `<dir>/tunables.json` (exception: `schemas/part_query.tunables.json`). Subpath `package.json` exports mirror filenames.
37
37
  - **Import boundary:** `test/integration/p2p_shell_import_guard.test.mjs`.
38
+ - **Security fixes are not backward compatible:** fount guarantees the whole network upgrades to the latest build together. Fix wire formats directly — no version fields, no compat branches.
38
39
  - **No scattered `trim` / `toLowerCase`:** hex IDs must already be lowercase and without a `0x` prefix — a `0x`-prefixed, mixed-case, or whitespace value is rejected by `isHex64`/`isEntityHash128`, never cleaned. Exceptions: JSONL blank lines, SDP fingerprint, CLI/`scripts` parsing.
39
40
  - **No `String(x)` / `x || ''` on typed `string`:** if `@param {string}`, use it directly; `String(...)` / `|| ''` / `?? ''` only at optional / `unknown` / disk / inbound boundaries, or number→string.
40
41
  - **Optional methods:** `if (fn) return await fn(...)` / `if (fn) …` — never `typeof x === 'function'`.
@@ -3,12 +3,29 @@ import os from 'node:os'
3
3
  /** advert / 组播 beacon 携带的 LAN IPv4 上限 */
4
4
  export const MAX_LAN_HOSTS = 4
5
5
 
6
- const IPV4_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/
6
+ /**
7
+ * 私网 / 链路本地 IPv4(RFC1918 + 169.254/16)。
8
+ * advert 自报地址只允许这些:否则攻击者可借受害者的拨号对内网/公网发起 TCP 探测(SSRF)。
9
+ * @param {string} host 候选 IPv4
10
+ * @returns {boolean} 是否允许作为可拨号 LAN hint
11
+ */
12
+ function isPrivateLanIpv4(host) {
13
+ const parts = host.split('.')
14
+ if (parts.length !== 4) return false
15
+ const octets = parts.map(part => Number(part))
16
+ if (octets.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return false
17
+ const [a, b] = octets
18
+ if (a === 10) return true
19
+ if (a === 172 && b >= 16 && b <= 31) return true
20
+ if (a === 192 && b === 168) return true
21
+ if (a === 169 && b === 254) return true
22
+ return false
23
+ }
7
24
 
8
25
  /**
9
26
  * untrusted ingress:清洗 advert body 中的 LAN IPv4 列表。
10
27
  * @param {unknown} input 原始 lanHosts
11
- * @returns {string[]} 去重后的 IPv4 列表
28
+ * @returns {string[]} 去重后的私网/链路本地 IPv4 列表
12
29
  */
13
30
  export function normalizeLanHosts(input) {
14
31
  if (!input) return []
@@ -18,7 +35,7 @@ export function normalizeLanHosts(input) {
18
35
  const out = []
19
36
  for (const item of arr) {
20
37
  const host = String(item || '')
21
- if (!host || !IPV4_RE.test(host) || seen.has(host)) continue
38
+ if (!host || !isPrivateLanIpv4(host) || seen.has(host)) continue
22
39
  seen.add(host)
23
40
  out.push(host)
24
41
  if (out.length >= MAX_LAN_HOSTS) break
package/docs/evfs.md CHANGED
@@ -45,6 +45,7 @@ Outer caller timeouts must **not** abort the in-flight work — background fill
45
45
  - `fetchChunk` accepts the same `fanoutTargets` as `fetchManifest`. With targets it fanouts `fed_chunk_get` **only** to that node set; without targets it falls back to node-scope fanout. Same `username`+chunk hash+mode (`targeted`/`public`) is deduped in-flight.
46
46
  - `readPublicFile` / `readManifestPlaintext` forward `options.fanoutTargets` to both the manifest fetch and the chunk fetch, so a cross-node public read can pull profile/avatar content straight from the owner node instead of depending on the node-scope fanout.
47
47
  - The public (non-targeted) fanout does **not** block the request window on dialing the whole peer pool: already-linked / group-room-reachable peers are sent immediately, and unreachable peers are dialed in the background and re-sent once linked. (`ensureLinkToNode` no longer gates the send.)
48
+ - **Chunk serving is by-design unauthenticated CAS:** `fed_chunk_get` returns a chunk iff the requester already knows its 64-hex content hash (which is infeasible to enumerate), and `verifiedChunkBytes` re-checks that hash on receipt. There is no per-requester ACL. Confidentiality therefore rests on the manifest being ACL-gated and on encryption: `convergent` / `random` chunks are ciphertext, while `plain` chunks are plaintext — non-public files must not use `plain`.
48
49
 
49
50
  ## Non-public (ACL-gated) manifests
50
51
 
@@ -6,6 +6,10 @@ Mesh keep-alive / discovery: [mesh.md](mesh.md). WebRTC glare / handshake: [sign
6
6
 
7
7
  Do **not** introduce version fields, constants, or suffixes (`v`, `version`, `FRAME_VERSION`, `:v1`, …). Changing a shape means changing it; no dual-read / backward-compat paths. Exception: npm `package.json` `version` is for package publish only.
8
8
 
9
+ ## Overlay relay authenticity
10
+
11
+ `overlay/index.mjs` multi-hop relay carries an origin signature: `relay()` signs `(path, body)` with the origin key, intermediate hops forward `nodePubKey`/`sig` untouched, and the terminal node verifies `pubKeyHash(nodePubKey) === path[0]` before treating the body as sent by that node. Unsigned or forged relays are dropped. `route_resp` is only accepted when the signed path's last hop equals the discovery target. Overlay `route_req`/`relay` are token-bucket limited by default (`overlay/tunables.json`, 120/min burst 30); `startInfra` may override the gate.
12
+
9
13
  ## Public contract (shell / L4)
10
14
 
11
15
  This package exposes a **fount network**: talk to `nodeHash` peers with envelopes.
package/docs/wire.md CHANGED
@@ -14,6 +14,12 @@ Day-to-day trust rules: [AGENTS.md](../AGENTS.md). Node-scope presets: [infra.md
14
14
 
15
15
  Part query runtime lives under `federation/part_query/*`; wire attach only in `wire/part/query.mjs`.
16
16
 
17
+ ## Part query response signing / source attribution
18
+
19
+ - Every `part_query_res` is self-signed by the responding node: `parsePartQueryRes` requires `nodePubKey` (64 hex) + `sig` (128 hex), and the runtime verifies `pubKeyHash(nodePubKey) === fromNodeHash` plus the signature over `(requestId, fromNodeHash, rows)` before accepting. Invalid responses are dropped, so a neighbor cannot forge a result or attribute it to another node.
20
+ - `queryNetwork` returns `{ rows, sources }` (not a bare array), where `sources` is a `Map<rowKey, string[]>` of contributing responder nodeHashes. Cached entries keep the same provenance.
21
+ - `options.isSourceBlocked(nodeHash)` filters rows whose source set is non-empty and entirely blocked. Shells wire this to their source-block UI; local rows have an empty source set and are never filtered here.
22
+
17
23
  ## Timed collect
18
24
 
19
25
  APIs with `timeoutMs` (e.g. `collectPartInvokeResponses`) must register the wait **first** and must **not** `await` fanout/send on the return path — a stuck `discoverRoute` / `link.send` otherwise defeats the timeout.
@@ -4,7 +4,7 @@ import partQueryTunables from '../../schemas/part_query.tunables.json' with { ty
4
4
  import { createLruMap } from '../../utils/lru.mjs'
5
5
 
6
6
  /**
7
- * @typedef {{ rows: unknown[], storedAt: number }} PartQueryCacheEntry
7
+ * @typedef {{ rows: unknown[], storedAt: number, sources: Map<string, Set<string>> }} PartQueryCacheEntry
8
8
  */
9
9
 
10
10
  /**
@@ -24,7 +24,8 @@ export function partQueryCacheKey(partpath, kind, query) {
24
24
  * @param {{ maxKeys?: number, ttlMs?: number, maxHits?: number }} [options] 容量 / TTL / 单键 rows 上限
25
25
  * @returns {{
26
26
  * get: (partpath: string, kind: string, query: unknown, now?: number) => unknown[] | null
27
- * set: (partpath: string, kind: string, query: unknown, rows: unknown[], now?: number) => void
27
+ * getWithSources: (partpath: string, kind: string, query: unknown, now?: number) => { rows: unknown[], sources: Map<string, Set<string>> } | null
28
+ * set: (partpath: string, kind: string, query: unknown, rows: unknown[], now?: number, sources?: Map<string, Set<string>>) => void
28
29
  * clear: () => void
29
30
  * readonly size: number
30
31
  * }} 缓存 API
@@ -54,6 +55,18 @@ export function createPartQueryCache(options = {}) {
54
55
  * @returns {unknown[] | null} 未过期 rows
55
56
  */
56
57
  get(partpath, kind, query, now = Date.now()) {
58
+ const entry = this.getWithSources(partpath, kind, query, now)
59
+ return entry ? entry.rows : null
60
+ },
61
+
62
+ /**
63
+ * @param {string} partpath part 路径
64
+ * @param {string} kind 查询标签
65
+ * @param {unknown} query 查询体
66
+ * @param {number} [now=Date.now()] 当前时间
67
+ * @returns {{ rows: unknown[], sources: Map<string, Set<string>> } | null} 未过期 rows 与来源
68
+ */
69
+ getWithSources(partpath, kind, query, now = Date.now()) {
57
70
  const key = partQueryCacheKey(partpath, kind, query)
58
71
  if (!key) return null
59
72
  const entry = map.get(key)
@@ -63,7 +76,7 @@ export function createPartQueryCache(options = {}) {
63
76
  return null
64
77
  }
65
78
  map.touch(key, entry)
66
- return entry.rows.slice()
79
+ return { rows: entry.rows.slice(), sources: entry.sources }
67
80
  },
68
81
 
69
82
  /**
@@ -72,9 +85,10 @@ export function createPartQueryCache(options = {}) {
72
85
  * @param {unknown} query 查询体
73
86
  * @param {unknown[]} rows 聚合 rows(空数组不入库)
74
87
  * @param {number} [now=Date.now()] 当前时间
88
+ * @param {Map<string, Set<string>>} [sources] rowKey→来源集合
75
89
  * @returns {void}
76
90
  */
77
- set(partpath, kind, query, rows, now = Date.now()) {
91
+ set(partpath, kind, query, rows, now = Date.now(), sources = null) {
78
92
  const key = partQueryCacheKey(partpath, kind, query)
79
93
  // 空 miss 不缓存:mesh 晚就绪 / 超时早查询不应被长 TTL 负缓存粘住
80
94
  if (!key || !Array.isArray(rows) || rows.length === 0) return
@@ -82,6 +96,7 @@ export function createPartQueryCache(options = {}) {
82
96
  map.touch(key, {
83
97
  rows: rows.slice(0, maxHits),
84
98
  storedAt: now,
99
+ sources: sources || new Map(),
85
100
  })
86
101
  },
87
102
 
@@ -1,7 +1,10 @@
1
+ import { Buffer } from 'node:buffer'
1
2
  import { randomUUID } from 'node:crypto'
2
3
 
4
+ import { canonicalStringify } from '../../core/canonical_json.mjs'
3
5
  import { compositeKey } from '../../core/composite_key.mjs'
4
- import { getNodeHash } from '../../node/identity.mjs'
6
+ import { keyPairFromSeed, pubKeyHash, sign, verify } from '../../crypto/crypto.mjs'
7
+ import { ensureNodeSeed, getNodeHash } from '../../node/identity.mjs'
5
8
  import { loadReputation } from '../../node/reputation_store.mjs'
6
9
  import { isQuarantinedPure } from '../../reputation/engine.mjs'
7
10
  import {
@@ -23,6 +26,55 @@ import { createPartQueryCache, partQueryCache } from './cache.mjs'
23
26
 
24
27
  /** @typedef {import('../../wire/adapter.mjs').WireAdapter} PartQueryWire */
25
28
 
29
+ const PART_QUERY_DOMAIN = 'fount-part-query'
30
+
31
+ /**
32
+ * 构造 part_query_res 签名材料:仅覆盖 (requestId, fromNodeHash, rows)。
33
+ * 来源节点借此自证,中间节点无法篡改结果或来源。
34
+ * @param {{ requestId: string, fromNodeHash: string, rows: unknown[] }} base 响应基体
35
+ * @returns {Uint8Array} 待签名字节
36
+ */
37
+ function partQuerySignBytes(base) {
38
+ return Buffer.from(`${PART_QUERY_DOMAIN}\0${base.requestId}\0${base.fromNodeHash}\0${canonicalStringify(base.rows)}`, 'utf8')
39
+ }
40
+
41
+ /**
42
+ * 默认响应签名:用本机节点身份签响应基体。
43
+ * @param {{ requestId: string, fromNodeHash: string, rows: unknown[] }} base 响应基体
44
+ * @returns {Promise<{ nodePubKey: string, sig: string }>} 公钥与签名
45
+ */
46
+ async function defaultSignResponse(base) {
47
+ const { publicKey, secretKey } = keyPairFromSeed(Buffer.from(ensureNodeSeed(), 'hex'))
48
+ const signature = await sign(partQuerySignBytes(base), secretKey)
49
+ return { nodePubKey: Buffer.from(publicKey).toString('hex'), sig: Buffer.from(signature).toString('hex') }
50
+ }
51
+
52
+ /**
53
+ * 默认响应验签:nodePubKey 哈希须等于 fromNodeHash,且签名覆盖响应基体。
54
+ * @param {{ requestId: string, fromNodeHash: string, rows: unknown[], nodePubKey: string, sig: string }} response 响应
55
+ * @returns {Promise<boolean>} 是否可信
56
+ */
57
+ async function defaultVerifyResponse(response) {
58
+ if (pubKeyHash(Buffer.from(response.nodePubKey, 'hex')) !== response.fromNodeHash) return false
59
+ return await verify(Buffer.from(response.sig, 'hex'), partQuerySignBytes(response), Buffer.from(response.nodePubKey, 'hex'))
60
+ }
61
+
62
+ /**
63
+ * @param {PartQueryDependencies} dependencies 依赖
64
+ * @returns {(base: { requestId: string, fromNodeHash: string, rows: unknown[] }) => Promise<{ nodePubKey: string, sig: string }>} 签名函数
65
+ */
66
+ function responseSigner(dependencies) {
67
+ return dependencies.signResponse || defaultSignResponse
68
+ }
69
+
70
+ /**
71
+ * @param {PartQueryDependencies} dependencies 依赖
72
+ * @returns {(response: { requestId: string, fromNodeHash: string, rows: unknown[], nodePubKey: string, sig: string }) => Promise<boolean>} 验签函数
73
+ */
74
+ function responseVerifier(dependencies) {
75
+ return dependencies.verifyResponse || defaultVerifyResponse
76
+ }
77
+
26
78
  /**
27
79
  * @typedef {{
28
80
  * replicaUsername?: string
@@ -40,18 +92,24 @@ import { createPartQueryCache, partQueryCache } from './cache.mjs'
40
92
  * takeDedupe: (key: string) => boolean
41
93
  * relayPending: Map<string, RelayPending>
42
94
  * originWaits: Map<string, Map<string, import('../../wire/wait.mjs').WireWaiter[]>>
43
- * originBags: Map<string, { rows: unknown[], maxHits: number, expected: number, received: number, respondedPeers: Set<string>, rowKey?: (row: unknown) => string }>
95
+ * originBags: Map<string, { entries: Array<{ rows: unknown[], sourceNodeHash?: string }>, maxHits: number, expected: number, received: number, respondedPeers: Set<string>, rowKey?: (row: unknown) => string }>
44
96
  * cache: ReturnType<typeof createPartQueryCache>
45
97
  * handlers: Map<string, QueryInboundHandler>
46
98
  * }} PartQueryNodeState
47
99
  */
48
100
 
101
+ /**
102
+ * @typedef {{ rows: unknown[], sourceNodeHash?: string }} QueryRowEntry
103
+ */
104
+
49
105
  /**
50
106
  * @typedef {{
51
107
  * selectNeighbors?: (exclude: Set<string>) => Promise<string[]>
52
108
  * deliver?: (nodeHash: string, action: string, payload: unknown) => Promise<boolean> | boolean
53
109
  * getNodeHash?: () => string
54
110
  * now?: () => number
111
+ * signResponse?: (base: { requestId: string, fromNodeHash: string, rows: unknown[] }) => Promise<{ nodePubKey: string, sig: string }>
112
+ * verifyResponse?: (response: { requestId: string, fromNodeHash: string, rows: unknown[], nodePubKey: string, sig: string }) => Promise<boolean>
55
113
  * state?: PartQueryNodeState
56
114
  * }} PartQueryDependencies
57
115
  */
@@ -62,7 +120,7 @@ import { createPartQueryCache, partQueryCache } from './cache.mjs'
62
120
  * wire: PartQueryWire
63
121
  * request: PartQueryReq
64
122
  * localRows: unknown[]
65
- * remoteRows: unknown[]
123
+ * remoteEntries: QueryRowEntry[]
66
124
  * expected: number
67
125
  * received: number
68
126
  * respondedPeers: Set<string>
@@ -152,6 +210,40 @@ export function mergeQueryRows(lists, maxHits, rowKey) {
152
210
  return out
153
211
  }
154
212
 
213
+ /**
214
+ * 合并带来源的 rows,并记录每个去重行键的来源节点集合。
215
+ * 本地行 `sourceNodeHash` 省略(不可被屏蔽)。
216
+ * @param {QueryRowEntry[]} entries 多路 rows(含来源)
217
+ * @param {number} maxHits 上限
218
+ * @param {(row: unknown) => string} [rowKey] 去重键
219
+ * @returns {{ rows: unknown[], sources: Map<string, Set<string>> }} 合并结果与 rowKey→来源集合
220
+ */
221
+ function mergeRowsWithSources(entries, maxHits, rowKey) {
222
+ const rows = []
223
+ const seen = new Set()
224
+ /** @type {Map<string, Set<string>>} */
225
+ const sources = new Map()
226
+ const keyOf = rowKey || (row => {
227
+ try { return JSON.stringify(row) }
228
+ catch { return `\0${rows.length}` }
229
+ })
230
+ for (const entry of entries) {
231
+ for (const row of entry.rows || []) {
232
+ const key = keyOf(row)
233
+ if (!seen.has(key)) {
234
+ seen.add(key)
235
+ rows.push(row)
236
+ sources.set(key, new Set())
237
+ if (entry.sourceNodeHash) sources.get(key).add(entry.sourceNodeHash)
238
+ if (rows.length >= maxHits) return { rows, sources }
239
+ continue
240
+ }
241
+ if (entry.sourceNodeHash) sources.get(key).add(entry.sourceNodeHash)
242
+ }
243
+ }
244
+ return { rows, sources }
245
+ }
246
+
155
247
  /**
156
248
  * @param {PartQueryNodeState} state 节点状态
157
249
  * @param {QueryInboundContext} queryContext 入站上下文
@@ -206,20 +298,59 @@ async function deliverQuery(nodeHash, action, payload, dependencies) {
206
298
  * @param {() => string} nodeHashOf 本机 hash
207
299
  * @returns {PartQueryRes} 响应载荷
208
300
  */
209
- function buildResponse(request, rows, nodeHashOf) {
301
+ /**
302
+ * @param {PartQueryReq} request 请求
303
+ * @param {unknown[]} rows 行
304
+ * @param {() => string} nodeHashOf 本机 hash
305
+ * @param {PartQueryDependencies} dependencies 依赖
306
+ * @returns {Promise<PartQueryRes>} 已签名的响应
307
+ */
308
+ async function buildResponse(request, rows, nodeHashOf, dependencies) {
210
309
  const capped = clampPartQueryRows(rows, request.budget.maxHits) || []
211
- return {
212
- requestId: request.requestId,
213
- fromNodeHash: nodeHashOf(),
214
- rows: capped,
215
- }
310
+ const base = { requestId: request.requestId, fromNodeHash: nodeHashOf(), rows: capped }
311
+ const { nodePubKey, sig } = await responseSigner(dependencies)(base)
312
+ return { ...base, nodePubKey, sig }
313
+ }
314
+
315
+ /**
316
+ * 按来源屏蔽表过滤 rows:来源集合非空且全部被屏蔽时剔除该行。
317
+ * @param {unknown[]} rows 行
318
+ * @param {Map<string, Set<string>>} sources rowKey→来源集合
319
+ * @param {(row: unknown) => string} [rowKey] 去重键
320
+ * @param {((nodeHash: string) => boolean) | undefined} isSourceBlocked 来源屏蔽谓词
321
+ * @returns {unknown[]} 过滤后的 rows
322
+ */
323
+ function filterRowsBySource(rows, sources, rowKey, isSourceBlocked) {
324
+ if (typeof isSourceBlocked !== 'function') return rows
325
+ return rows.filter(row => {
326
+ let key
327
+ if (rowKey) key = rowKey(row)
328
+ else {
329
+ try { key = JSON.stringify(row) }
330
+ catch { return true }
331
+ }
332
+ const set = sources.get(key)
333
+ if (!set || set.size === 0) return true
334
+ for (const source of set) if (!isSourceBlocked(source)) return true
335
+ return false
336
+ })
337
+ }
338
+
339
+ /**
340
+ * @param {Map<string, Set<string>>} sources rowKey→来源集合
341
+ * @returns {Map<string, string[]>} rowKey→来源数组
342
+ */
343
+ function sourcesToArrays(sources) {
344
+ const out = new Map()
345
+ for (const [key, set] of sources) out.set(key, [...set])
346
+ return out
216
347
  }
217
348
 
218
349
  /**
219
350
  * @param {RelayPending} pending 中继槽
220
- * @returns {void}
351
+ * @returns {Promise<void>}
221
352
  */
222
- function flushRelayPending(pending) {
353
+ async function flushRelayPending(pending) {
223
354
  if (pending.flushed) return
224
355
  pending.flushed = true
225
356
  if (pending.timer) {
@@ -227,12 +358,15 @@ function flushRelayPending(pending) {
227
358
  pending.timer = null
228
359
  }
229
360
  pending.state.relayPending.delete(pending.request.requestId)
230
- const merged = mergeQueryRows([pending.localRows, pending.remoteRows], pending.request.budget.maxHits)
361
+ const merged = mergeRowsWithSources(
362
+ [{ rows: pending.localRows }, ...pending.remoteEntries],
363
+ pending.request.budget.maxHits,
364
+ )
231
365
  const now = pending.dependencies.now || Date.now
232
- pending.state.cache.set(pending.request.partpath, pending.request.kind, pending.request.query, merged, now())
366
+ pending.state.cache.set(pending.request.partpath, pending.request.kind, pending.request.query, merged.rows, now(), merged.sources)
233
367
  const nodeHashOf = pending.dependencies.getNodeHash || getNodeHash
234
368
  try {
235
- pending.wire.send('part_query_res', buildResponse(pending.request, merged, nodeHashOf), pending.upstreamPeerId)
369
+ pending.wire.send('part_query_res', await buildResponse(pending.request, merged.rows, nodeHashOf, pending.dependencies), pending.upstreamPeerId)
236
370
  }
237
371
  catch { /* disconnected */ }
238
372
  }
@@ -251,9 +385,9 @@ export async function processIncomingPartQueryRequest(wireContext, wire, request
251
385
  const now = dependencies.now || Date.now
252
386
  const username = wireContext.replicaUsername || ''
253
387
 
254
- const cached = state.cache.get(request.partpath, request.kind, request.query, now())
388
+ const cached = state.cache.getWithSources(request.partpath, request.kind, request.query, now())
255
389
  if (cached) {
256
- try { wire.send('part_query_res', buildResponse(request, cached, nodeHashOf), peerId) }
390
+ try { wire.send('part_query_res', await buildResponse(request, cached.rows, nodeHashOf, dependencies), peerId) }
257
391
  catch { /* disconnected */ }
258
392
  return
259
393
  }
@@ -266,8 +400,8 @@ export async function processIncomingPartQueryRequest(wireContext, wire, request
266
400
 
267
401
  const nextTtl = request.ttl - 1
268
402
  if (nextTtl <= 0) {
269
- state.cache.set(request.partpath, request.kind, request.query, localRows, now())
270
- try { wire.send('part_query_res', buildResponse(request, localRows, nodeHashOf), peerId) }
403
+ state.cache.set(request.partpath, request.kind, request.query, localRows, now(), new Map())
404
+ try { wire.send('part_query_res', await buildResponse(request, localRows, nodeHashOf, dependencies), peerId) }
271
405
  catch { /* disconnected */ }
272
406
  return
273
407
  }
@@ -282,7 +416,7 @@ export async function processIncomingPartQueryRequest(wireContext, wire, request
282
416
  wire,
283
417
  request,
284
418
  localRows,
285
- remoteRows: [],
419
+ remoteEntries: [],
286
420
  expected: 0,
287
421
  received: 0,
288
422
  respondedPeers: new Set(),
@@ -293,7 +427,7 @@ export async function processIncomingPartQueryRequest(wireContext, wire, request
293
427
  }
294
428
  state.relayPending.set(request.requestId, pending)
295
429
  // 先挂 hop 超时:勿等 select/deliver settle,否则 stuck send 永不 flush upstream(#13 同类)
296
- pending.timer = setTimeout(() => flushRelayPending(pending), resolvePartQueryHopTimeoutMs(request.ttl))
430
+ pending.timer = setTimeout(() => { void flushRelayPending(pending) }, resolvePartQueryHopTimeoutMs(request.ttl))
297
431
 
298
432
  void (async () => {
299
433
  try {
@@ -305,10 +439,10 @@ export async function processIncomingPartQueryRequest(wireContext, wire, request
305
439
  if (pending.flushed) return
306
440
  pending.expected = sent
307
441
  if (sent === 0 || pending.received >= pending.expected)
308
- flushRelayPending(pending)
442
+ void flushRelayPending(pending)
309
443
  }
310
444
  catch {
311
- if (!pending.flushed) flushRelayPending(pending)
445
+ if (!pending.flushed) void flushRelayPending(pending)
312
446
  }
313
447
  })()
314
448
  }
@@ -319,28 +453,26 @@ export async function processIncomingPartQueryRequest(wireContext, wire, request
319
453
  * @param {PartQueryDependencies} [dependencies] 依赖
320
454
  * @returns {void}
321
455
  */
322
- export function handleIncomingPartQueryResponse(response, peerId = '', dependencies = {}) {
456
+ export async function handleIncomingPartQueryResponse(response, peerId = '', dependencies = {}) {
323
457
  const state = resolvePartQueryState(dependencies)
324
- const responderKey = peerId || response.fromNodeHash
458
+ // 响应必须自证来源;验签失败直接丢弃,避免任意邻居伪造/投毒结果与缓存。
459
+ if (!await responseVerifier(dependencies)(response)) return
460
+ const responderKey = response.fromNodeHash
325
461
  const relay = state.relayPending.get(response.requestId)
326
462
  if (relay) {
327
- if (responderKey) {
328
- if (relay.respondedPeers.has(responderKey)) return
329
- relay.respondedPeers.add(responderKey)
330
- }
331
- relay.remoteRows.push(...response.rows)
463
+ if (relay.respondedPeers.has(responderKey)) return
464
+ relay.respondedPeers.add(responderKey)
465
+ relay.remoteEntries.push({ rows: response.rows, sourceNodeHash: responderKey })
332
466
  relay.received += 1
333
- if (relay.expected > 0 && relay.received >= relay.expected) flushRelayPending(relay)
467
+ if (relay.expected > 0 && relay.received >= relay.expected) void flushRelayPending(relay)
334
468
  return
335
469
  }
336
470
 
337
471
  const bag = state.originBags.get(response.requestId)
338
472
  if (!bag) return
339
- if (responderKey) {
340
- if (bag.respondedPeers.has(responderKey)) return
341
- bag.respondedPeers.add(responderKey)
342
- }
343
- bag.rows = mergeQueryRows([bag.rows, response.rows], bag.maxHits, bag.rowKey)
473
+ if (bag.respondedPeers.has(responderKey)) return
474
+ bag.respondedPeers.add(responderKey)
475
+ bag.entries.push({ rows: response.rows, sourceNodeHash: responderKey })
344
476
  bag.received += 1
345
477
  if (bag.expected > 0 && bag.received >= bag.expected)
346
478
  finishMultiWireWaiters(state.originWaits, response.requestId, '')
@@ -358,17 +490,22 @@ export function handleIncomingPartQueryResponse(response, peerId = '', dependenc
358
490
  * timeoutMs?: number
359
491
  * maxHits?: number
360
492
  * rowKey?: (row: unknown) => string
493
+ * isSourceBlocked?: (nodeHash: string) => boolean
361
494
  * budget?: { maxHits?: number }
362
495
  * } & PartQueryDependencies} [options] 选项
363
- * @returns {Promise<unknown[]>} 合并后的 rows
496
+ * @returns {Promise<{ rows: unknown[], sources: Map<string, string[]> }>} 合并 rows 与每行来源节点
364
497
  */
365
498
  export async function queryNetwork(username, partpath, kind, query, options = {}) {
366
499
  const state = resolvePartQueryState(options)
367
500
  const now = options.now || Date.now
368
501
  const nodeHashOf = options.getNodeHash || getNodeHash
369
502
 
370
- const cached = state.cache.get(partpath, kind, query, now())
371
- if (cached) return cached
503
+ const cached = state.cache.getWithSources(partpath, kind, query, now())
504
+ if (cached)
505
+ return {
506
+ rows: filterRowsBySource(cached.rows, cached.sources, options.rowKey, options.isSourceBlocked),
507
+ sources: sourcesToArrays(cached.sources),
508
+ }
372
509
 
373
510
  const ttl = Math.min(
374
511
  Math.max(1, Math.floor(Number(options.ttl) || partQueryTunables.maxTtl)),
@@ -399,12 +536,15 @@ export async function queryNetwork(username, partpath, kind, query, options = {}
399
536
  budget: { maxHits },
400
537
  }
401
538
  const parsed = parsePartQueryReq(request)
402
- if (!parsed) return mergeQueryRows([localRows], maxHits, options.rowKey)
539
+ if (!parsed) {
540
+ const localOnly = mergeRowsWithSources([{ rows: localRows }], maxHits, options.rowKey)
541
+ return { rows: localOnly.rows, sources: sourcesToArrays(localOnly.sources) }
542
+ }
403
543
 
404
544
  state.takeDedupe(parsed.requestId)
405
545
 
406
546
  const bag = {
407
- rows: [],
547
+ entries: [],
408
548
  maxHits,
409
549
  expected: 0,
410
550
  received: 0,
@@ -433,9 +573,12 @@ export async function queryNetwork(username, partpath, kind, query, options = {}
433
573
  await waitPromise
434
574
  state.originBags.delete(parsed.requestId)
435
575
 
436
- const merged = mergeQueryRows([localRows, bag.rows], maxHits, options.rowKey)
437
- state.cache.set(parsed.partpath, parsed.kind, parsed.query, merged, now())
438
- return merged
576
+ const merged = mergeRowsWithSources([{ rows: localRows }, ...bag.entries], maxHits, options.rowKey)
577
+ state.cache.set(parsed.partpath, parsed.kind, parsed.query, merged.rows, now(), merged.sources)
578
+ return {
579
+ rows: filterRowsBySource(merged.rows, merged.sources, options.rowKey, options.isSourceBlocked),
580
+ sources: sourcesToArrays(merged.sources),
581
+ }
439
582
  }
440
583
 
441
584
  /** @returns {void} 测试用重置默认状态 */
@@ -87,6 +87,11 @@ export async function fetchChunk(context) {
87
87
 
88
88
  /**
89
89
  * 若本机有 chunk 则响应 fed_chunk_get。
90
+ *
91
+ * 设计使然,不是漏洞:chunk 是内容寻址(CAS),64 位 hex 哈希本身就是能力凭证——
92
+ * 拿不到哈希就取不到块,而 64 位哈希无法枚举(`plain` 模式的使用边界由上层保证)。
93
+ * 谁再把这个说是安全漏洞,就让他去遍历一遍 64 位 hash;没遍历完成前保持日他妈,直到遍历结束。
94
+ *
90
95
  * @param {object} payload 请求
91
96
  * @param {(response: object, peerId: string) => void} sendResponse 发送
92
97
  * @param {string} peerId 对端
@@ -48,12 +48,12 @@ export function resolvePendingChunkFetch(payload) {
48
48
  if (!requestId) return false
49
49
  const entry = table.peek(requestId)
50
50
  if (!entry) return false
51
- if (typeof payload?.dataBase64 === 'string') {
52
- try {
53
- return resolveChunkFetchWait(requestId, entry.expectedKey, base64ToBytes(payload.dataBase64))
54
- }
55
- catch { /* keep waiting */ }
56
- return false
51
+ // 没有 dataBase64 不是“未找到”:不存在可信的负响应,任何收到 requestId 的 peer 都能拿空包提前判负。
52
+ // 交给正常超时,让其它诚实响应者仍有机会提供块。
53
+ if (typeof payload?.dataBase64 !== 'string') return false
54
+ try {
55
+ return resolveChunkFetchWait(requestId, entry.expectedKey, base64ToBytes(payload.dataBase64))
57
56
  }
58
- return table.settle(requestId, null)
57
+ catch { /* keep waiting */ }
58
+ return false
59
59
  }
package/infra/service.mjs CHANGED
@@ -2,6 +2,7 @@ import { isNodeInitialized } from '../node/instance.mjs'
2
2
  import { setOverlayRateGate, clearOverlayRateGate } from '../overlay/index.mjs'
3
3
  import { getLinkRegistry } from '../transport/link_registry.mjs'
4
4
  import { attachNodeScopeMailbox } from '../transport/node_scope/features.mjs'
5
+ import { consumeToken } from '../utils/token_bucket.mjs'
5
6
 
6
7
  import { attachInfraDebugLog, detachInfraDebugLog } from './debug_log.mjs'
7
8
  import {
@@ -15,32 +16,6 @@ import infraTunables from './tunables.json' with { type: 'json' }
15
16
  /** @type {Map<string, { tokens: number, updatedAt: number }>} */
16
17
  const overlayRateBuckets = new Map()
17
18
 
18
- /**
19
- * Token bucket:桶容量 = burst,补充速率 = perMin/min。
20
- * @param {Map<string, { tokens: number, updatedAt: number }>} buckets - 每 sender 桶状态
21
- * @param {string} sender - 发送方 nodeHash
22
- * @param {number} now - 当前时间戳(ms)
23
- * @param {{ perMin: number, burst: number }} limits - 限速参数
24
- * @returns {boolean} 是否允许本次 overlay 动作
25
- */
26
- export function consumeOverlayRateToken(buckets, sender, now, limits) {
27
- const perMin = Math.max(1, limits.perMin)
28
- const burst = Math.max(1, limits.burst)
29
- const refillPerMs = perMin / 60_000
30
- let bucket = buckets.get(sender)
31
- if (!bucket) bucket = { tokens: burst, updatedAt: now }
32
- const elapsed = Math.max(0, now - bucket.updatedAt)
33
- bucket.tokens = Math.min(burst, bucket.tokens + elapsed * refillPerMs)
34
- bucket.updatedAt = now
35
- if (bucket.tokens < 1) {
36
- buckets.set(sender, bucket)
37
- return false
38
- }
39
- bucket.tokens -= 1
40
- buckets.set(sender, bucket)
41
- return true
42
- }
43
-
44
19
  /** 安装 overlay 速率门 */
45
20
  function installOverlayRateLimit() {
46
21
  const limits = {
@@ -49,7 +24,7 @@ function installOverlayRateLimit() {
49
24
  }
50
25
  setOverlayRateGate((sender, action) => {
51
26
  if (action !== 'route_req' && action !== 'relay') return true
52
- return consumeOverlayRateToken(overlayRateBuckets, sender, Date.now(), limits)
27
+ return consumeToken(overlayRateBuckets, sender, Date.now(), limits)
53
28
  })
54
29
  }
55
30
 
@@ -135,6 +135,10 @@ export async function ingestMailboxPut(wireContext, put, peerId = '') {
135
135
  }
136
136
 
137
137
  /**
138
+ * 设计使然,不是漏洞:mailbox 是存转发(store-and-forward),want 与 put 对称,
139
+ * 取件按收件人 pubKeyHash 匹配而不鉴权请求者;内容机密性由 envelope 层端到端加密负责。
140
+ * 谁再把这个说是安全漏洞,就让他和他妈保持链接,直到他不写地址、不标明来源的信确实送到南极洲。
141
+ *
138
142
  * @param {object} want mailbox_want 载荷
139
143
  * @param {(payload: unknown, peerId: string) => void} sendGive mailbox_give 发送回调
140
144
  * @param {string} peerId 请求方 peer
package/overlay/index.mjs CHANGED
@@ -1,29 +1,52 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
 
3
+ import { canonicalStringify } from '../core/canonical_json.mjs'
3
4
  import { pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
4
5
  import { randomFrameIdHex } from '../link/frame.mjs'
5
6
  import { createLruMap } from '../utils/lru.mjs'
7
+ import { consumeToken } from '../utils/token_bucket.mjs'
8
+
9
+ import overlayTunables from './tunables.json' with { type: 'json' }
6
10
 
7
11
  const ROUTE_DOMAIN = 'fount-route'
12
+ const RELAY_DOMAIN = 'fount-relay'
13
+
14
+ /** 默认 overlay 限速桶;不依赖 infra 启动,`startNode` 单独使用时也生效。 */
15
+ const defaultOverlayRateBuckets = new Map()
16
+ /** @type {{ perMin: number, burst: number }} */
17
+ const DEFAULT_OVERLAY_RATE_LIMITS = {
18
+ perMin: Math.max(1, Number(overlayTunables.overlayRatePerMin) || 120),
19
+ burst: Math.max(1, Number(overlayTunables.overlayRateBurst) || 30),
20
+ }
21
+
22
+ /**
23
+ * @param {string} senderNodeHash 发送方节点
24
+ * @param {string} action overlay 动作
25
+ * @returns {boolean} 是否允许
26
+ */
27
+ function defaultOverlayRateGate(senderNodeHash, action) {
28
+ if (action !== 'route_req' && action !== 'relay') return true
29
+ return consumeToken(defaultOverlayRateBuckets, senderNodeHash, Date.now(), DEFAULT_OVERLAY_RATE_LIMITS)
30
+ }
8
31
 
9
- /** @type {((senderNodeHash: string, action: string) => boolean) | null} */
10
- let overlayRateGate = null
32
+ /** @type {((senderNodeHash: string, action: string) => boolean)} */
33
+ let overlayRateGate = defaultOverlayRateGate
11
34
 
12
35
  /**
13
- * 安装 overlay 入站限速门(返回 false 则丢弃)。
36
+ * 安装 overlay 入站限速门(返回 false 则丢弃)。传 null 恢复默认限速。
14
37
  * @param {((senderNodeHash: string, action: string) => boolean) | null} rateGate 返回 false 则丢弃
15
38
  * @returns {void}
16
39
  */
17
40
  export function setOverlayRateGate(rateGate) {
18
- overlayRateGate = rateGate || null
41
+ overlayRateGate = rateGate || defaultOverlayRateGate
19
42
  }
20
43
 
21
44
  /**
22
- * 清除 overlay 限速门。
45
+ * 清除自定义 overlay 限速门,恢复默认限速。
23
46
  * @returns {void}
24
47
  */
25
48
  export function clearOverlayRateGate() {
26
- overlayRateGate = null
49
+ overlayRateGate = defaultOverlayRateGate
27
50
  }
28
51
 
29
52
  /**
@@ -36,6 +59,17 @@ function routeSignBytes(reqId, path) {
36
59
  return Buffer.from(`${ROUTE_DOMAIN}\0${reqId}\0${path.join(',')}`, 'utf8')
37
60
  }
38
61
 
62
+ /**
63
+ * 构造 relay 源身份签名用的字节序列。覆盖完整 path 与 body:
64
+ * 末端据此确认 path[0] 确为原始发送者,且路径与载荷未被中间节点篡改。
65
+ * @param {string[]} path 完整节点路径
66
+ * @param {unknown} body relay 载荷
67
+ * @returns {Uint8Array} 待签名字节
68
+ */
69
+ function relaySignBytes(path, body) {
70
+ return Buffer.from(`${RELAY_DOMAIN}\0${path.join(',')}\0${canonicalStringify(body)}`, 'utf8')
71
+ }
72
+
39
73
  /**
40
74
  * 创建 overlay 多跳路由与 relay 路由器。
41
75
  * @param {object} registry link registry(含 localIdentity、sendToNodeLink、listLinks、subscribeScope)
@@ -47,7 +81,7 @@ export function createOverlayRouter(registry, ttl = 3) {
47
81
  const selfPubKey = registry.localIdentity.nodePubKey
48
82
  const { secretKey } = registry.localIdentity
49
83
  const seenReqs = createLruMap(4096)
50
- /** @type {Map<string, { resolve: (path: string[]) => void, reject: (error: Error) => void, timer: number }>} */
84
+ /** @type {Map<string, { resolve: (path: string[]) => void, reject: (error: Error) => void, timer: number, target: string }>} */
51
85
  const pendingRoutes = new Map()
52
86
  /** @type {Set<(body: unknown, meta: { path: string[], from: string }) => void>} */
53
87
  const relayListeners = new Set()
@@ -118,6 +152,8 @@ export function createOverlayRouter(registry, ttl = 3) {
118
152
  if (path[0] === selfNodeHash) {
119
153
  const pending = pendingRoutes.get(reqId)
120
154
  if (!pending) return
155
+ // 终点必须就是本次发现的目标,否则任何直连邻居都能回一条以自己结尾的合法路径劫持路由。
156
+ if (path[path.length - 1] !== pending.target) return
121
157
  clearTimeout(pending.timer)
122
158
  pendingRoutes.delete(reqId)
123
159
  pending.resolve(path)
@@ -132,6 +168,17 @@ export function createOverlayRouter(registry, ttl = 3) {
132
168
  const path = payload.path || []
133
169
  const index = Number(payload.idx)
134
170
  if (!path.length || path[index] !== selfNodeHash) return
171
+ // origin 必须对 (path, body) 签名,否则任何直连 peer 都能伪造 path[0] 冒充发送者。
172
+ const originPubKey = payload.nodePubKey
173
+ const sigHex = payload.sig
174
+ if (typeof originPubKey !== 'string' || typeof sigHex !== 'string') return
175
+ if (pubKeyHash(Buffer.from(originPubKey, 'hex')) !== path[0]) return
176
+ const ok = await verify(
177
+ Buffer.from(sigHex, 'hex'),
178
+ relaySignBytes(path, payload.body),
179
+ Buffer.from(originPubKey, 'hex'),
180
+ )
181
+ if (!ok) return
135
182
  if (index === path.length - 1) {
136
183
  for (const listener of relayListeners)
137
184
  listener(payload.body, { path, from: senderNodeHash })
@@ -142,6 +189,8 @@ export function createOverlayRouter(registry, ttl = 3) {
142
189
  path,
143
190
  idx: index + 1,
144
191
  body: payload.body,
192
+ nodePubKey: originPubKey,
193
+ sig: sigHex,
145
194
  })
146
195
  }
147
196
  }
@@ -166,7 +215,7 @@ export function createOverlayRouter(registry, ttl = 3) {
166
215
  pendingRoutes.delete(reqId)
167
216
  reject(new Error(`overlay: route discovery timeout for ${targetNodeHash}`))
168
217
  }, timeoutMs)
169
- pendingRoutes.set(reqId, { resolve, reject, timer })
218
+ pendingRoutes.set(reqId, { resolve, reject, timer, target: targetNodeHash })
170
219
  })
171
220
  for (const { nodeHash } of registry.listLinks())
172
221
  await sendOverlay(nodeHash, {
@@ -187,7 +236,15 @@ export function createOverlayRouter(registry, ttl = 3) {
187
236
  async relay(path, body) {
188
237
  if (path?.[0] !== selfNodeHash || path.length < 2)
189
238
  throw new Error('overlay: invalid relay path')
190
- await sendOverlay(path[1], { action: 'relay', path, idx: 1, body })
239
+ const signature = await sign(relaySignBytes(path, body), secretKey)
240
+ await sendOverlay(path[1], {
241
+ action: 'relay',
242
+ path,
243
+ idx: 1,
244
+ body,
245
+ nodePubKey: selfPubKey,
246
+ sig: Buffer.from(signature).toString('hex'),
247
+ })
191
248
  },
192
249
  /**
193
250
  * 订阅 relay 到达本节点(路径末端)的载荷。
@@ -0,0 +1,4 @@
1
+ {
2
+ "overlayRatePerMin": 120,
3
+ "overlayRateBurst": 30
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.42",
3
+ "version": "0.0.43",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -57,7 +57,7 @@ export function computeRecidivismMultiplier(streak, tunables = reputationTunable
57
57
  * @returns {NonNullable<ReputationFile['byNodeHash'][string]>} 节点行(可变引用)
58
58
  */
59
59
  function ensureRow(data, nodeId) {
60
- if (!data.byNodeHash[nodeId]) data.byNodeHash[nodeId] = { score: 0 }
60
+ if (!Object.hasOwn(data.byNodeHash, nodeId)) data.byNodeHash[nodeId] = { score: 0 }
61
61
  return data.byNodeHash[nodeId]
62
62
  }
63
63
 
@@ -147,7 +147,8 @@ export function incrementBadInviteeCount(data, nodeId, badDelta = 1) {
147
147
  * @returns {ReputationFile} 补齐字段后的同一对象
148
148
  */
149
149
  export function ensureReputationShape(data) {
150
- data.byNodeHash ??= {}
150
+ // 无原型字典:键来自不可信 nodeHash,普通 `{}` 会让 `__proto__` 命中 Object.prototype(原型污染)。
151
+ data.byNodeHash = Object.assign(Object.create(null), data.byNodeHash ?? {})
151
152
  data.wantUnknownHits ??= []
152
153
  data.relayBumpSeen ??= []
153
154
  return data
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
 
3
3
  import { canonicalStringify } from '../core/canonical_json.mjs'
4
- import { isHex64 } from '../core/hexIds.mjs'
4
+ import { isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
5
5
  import { isPlainObject } from '../core/object.mjs'
6
6
  import { parsePartpath } from '../core/partpath.mjs'
7
7
 
@@ -24,6 +24,8 @@ import partQueryTunables from './part_query.tunables.json' with { type: 'json' }
24
24
  * requestId: string
25
25
  * fromNodeHash: string
26
26
  * rows: unknown[]
27
+ * nodePubKey: string
28
+ * sig: string
27
29
  * }} PartQueryRes
28
30
  */
29
31
 
@@ -132,9 +134,14 @@ export function parsePartQueryRes(value, tunables = partQueryTunables) {
132
134
  if (!requestId) return null
133
135
  const fromNodeHash = isHex64(value.fromNodeHash)
134
136
  if (!fromNodeHash) return null
137
+ // 响应必须自证来源:nodePubKey 的哈希即 fromNodeHash,sig 覆盖 (requestId, fromNodeHash, rows)。
138
+ const nodePubKey = isHex64(value.nodePubKey)
139
+ if (!nodePubKey) return null
140
+ const sig = isSignatureHex128(value.sig)
141
+ if (!sig) return null
135
142
  const rows = clampPartQueryRows(value.rows, tunables.maxHits, tunables.maxRowsBytes)
136
143
  if (!rows) return null
137
- return { requestId, fromNodeHash, rows }
144
+ return { requestId, fromNodeHash, rows, nodePubKey, sig }
138
145
  }
139
146
 
140
147
  /**
@@ -5,9 +5,30 @@ import { decryptNodeSignalPacket, sendNodeSignalPacket } from '../discovery/inde
5
5
  import { listLinkProviders } from '../link/providers/index.mjs'
6
6
  import { nodeDebug, shortHash } from '../node/log.mjs'
7
7
 
8
+ import { loadTransportTunables } from './tunables.mjs'
9
+
8
10
  /** accept/dial 挂起期间 ICE 信令 backlog 上限 */
9
11
  const SIGNAL_BACKLOG_MAX = 64
10
12
 
13
+ /** 并发信令会话上限(按插入序淘汰最旧)。 */
14
+ const MAX_SIGNAL_SESSIONS = Math.max(1, Math.floor(Number(loadTransportTunables().maxSignalSessions) || 256))
15
+
16
+ /**
17
+ * 入站 offer 可被攻击者用任意 connId 无上限创建会话;建新会话前淘汰到低于上限。
18
+ * @param {Map<string, { clear: () => void }>} sessions 信令会话表
19
+ * @param {number} maxSize 上限
20
+ * @returns {void}
21
+ */
22
+ export function ensureSignalSessionBudget(sessions, maxSize) {
23
+ const cap = Math.max(1, Math.floor(Number(maxSize) || 1))
24
+ while (sessions.size >= cap) {
25
+ const oldest = sessions.keys().next().value
26
+ if (oldest === undefined) return
27
+ sessions.get(oldest)?.clear?.()
28
+ sessions.delete(oldest)
29
+ }
30
+ }
31
+
11
32
  /**
12
33
  * @param {(message: unknown) => Promise<void>} sendRemote 远端发送回调
13
34
  * @returns {object} 信令会话
@@ -114,7 +135,8 @@ export function createOfferAnswerDial(deps) {
114
135
  */
115
136
  async function buildConnLink({ provider, remoteNodeHash, connId, session, initiator }) {
116
137
  try {
117
- if (initiator) await trimToBudget()
138
+ // 被动 accept 同样受活跃链路预算约束,否则攻击者可无上限占用连接。
139
+ await trimToBudget()
118
140
  const link = await (initiator ? provider.dial : provider.accept)({
119
141
  nodeHash: remoteNodeHash,
120
142
  signal: session,
@@ -165,6 +187,7 @@ export function createOfferAnswerDial(deps) {
165
187
  peer: shortHash(remoteNodeHash),
166
188
  provider: provider.id,
167
189
  })
190
+ ensureSignalSessionBudget(signalSessions, MAX_SIGNAL_SESSIONS)
168
191
  session = createConnSession(remoteNodeHash, connId)
169
192
  signalSessions.set(connId, session)
170
193
  void buildConnLink({ provider, remoteNodeHash, connId, session, initiator: false })
@@ -195,6 +218,7 @@ export function createOfferAnswerDial(deps) {
195
218
  async function dialOfferAnswer(provider, remoteNodeHash) {
196
219
  const connId = randomBytes(16).toString('hex')
197
220
  const session = createConnSession(remoteNodeHash, connId)
221
+ ensureSignalSessionBudget(signalSessions, MAX_SIGNAL_SESSIONS)
198
222
  signalSessions.set(connId, session)
199
223
  return await buildConnLink({ provider, remoteNodeHash, connId, session, initiator: true })
200
224
  }
@@ -7,5 +7,6 @@
7
7
  "meshNLow": 4,
8
8
  "meshKMaxLow": 2,
9
9
  "groupMemberScanIntervalMs": 30000,
10
- "groupMemberScanLimit": 64
10
+ "groupMemberScanLimit": 64,
11
+ "maxSignalSessions": 256
11
12
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 令牌桶限速:桶容量 burst,补充速率 perMin/分钟。
3
+ * @param {Map<string, { tokens: number, updatedAt: number }>} buckets 每键桶状态
4
+ * @param {string} key 桶键(如发送方 nodeHash)
5
+ * @param {number} now 当前时间戳(ms)
6
+ * @param {{ perMin: number, burst: number }} limits 限速参数
7
+ * @returns {boolean} 是否允许本次消费
8
+ */
9
+ export function consumeToken(buckets, key, now, limits) {
10
+ const perMin = Math.max(1, limits.perMin)
11
+ const burst = Math.max(1, limits.burst)
12
+ const refillPerMs = perMin / 60_000
13
+ let bucket = buckets.get(key)
14
+ if (!bucket) bucket = { tokens: burst, updatedAt: now }
15
+ const elapsed = Math.max(0, now - bucket.updatedAt)
16
+ bucket.tokens = Math.min(burst, bucket.tokens + elapsed * refillPerMs)
17
+ bucket.updatedAt = now
18
+ if (bucket.tokens < 1) {
19
+ buckets.set(key, bucket)
20
+ return false
21
+ }
22
+ bucket.tokens -= 1
23
+ buckets.set(key, bucket)
24
+ return true
25
+ }
@@ -61,7 +61,7 @@ export function attachPartQueryWire(wireContext, wire, dependencies = {}) {
61
61
  part_query_res(data, peerId) {
62
62
  const response = parsePartQueryRes(data)
63
63
  if (!response) return
64
- handleIncomingPartQueryResponse(response, peerId, deps)
64
+ void handleIncomingPartQueryResponse(response, peerId, deps)
65
65
  },
66
66
  })
67
67
  }