@steve02081504/fount-p2p 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +62 -0
  2. package/core/bytes_codec.mjs +51 -0
  3. package/core/canonical_json.mjs +21 -0
  4. package/core/composite_key.mjs +91 -0
  5. package/core/constants.mjs +53 -0
  6. package/core/entity_id.mjs +53 -0
  7. package/core/entity_id_parse.mjs +40 -0
  8. package/core/evfs_logical_path.mjs +15 -0
  9. package/core/hexIds.mjs +73 -0
  10. package/core/hlc.mjs +114 -0
  11. package/core/random_id.mjs +9 -0
  12. package/crypto/channel.mjs +101 -0
  13. package/crypto/checkpoint_sign.mjs +67 -0
  14. package/crypto/crypto.mjs +158 -0
  15. package/crypto/key.mjs +431 -0
  16. package/dag/canonicalize_row.mjs +65 -0
  17. package/dag/event_query.mjs +15 -0
  18. package/dag/index.mjs +335 -0
  19. package/dag/storage.mjs +333 -0
  20. package/dag/strip_extensions.mjs +18 -0
  21. package/dag/tunables.json +6 -0
  22. package/discovery/bt.mjs +260 -0
  23. package/discovery/index.mjs +134 -0
  24. package/discovery/mdns.mjs +138 -0
  25. package/discovery/nostr.mjs +273 -0
  26. package/entity/files/acl.mjs +38 -0
  27. package/entity/files/evfs.mjs +189 -0
  28. package/entity/files/evfs_ref.mjs +37 -0
  29. package/entity/files/manifest_acl_registry.mjs +93 -0
  30. package/entity/files/replica_host_cache.mjs +46 -0
  31. package/entity/files/url.mjs +19 -0
  32. package/entity/hosting_registry.mjs +47 -0
  33. package/entity/localized_core.mjs +141 -0
  34. package/entity/logical_entity.mjs +25 -0
  35. package/entity/logical_entity_id_registry.mjs +40 -0
  36. package/entity/node_hash.mjs +15 -0
  37. package/entity/presentation_registry.mjs +44 -0
  38. package/entity/profile.mjs +256 -0
  39. package/entity/replica.mjs +20 -0
  40. package/entity/session_snapshot_registry.mjs +38 -0
  41. package/federation/chunk_fetch_pending.mjs +128 -0
  42. package/federation/chunk_fetch_scheduler.mjs +97 -0
  43. package/federation/dag_order_cache.mjs +124 -0
  44. package/federation/dedupe_slot.mjs +33 -0
  45. package/federation/message_rate_limit.mjs +23 -0
  46. package/federation/operator_key_chain.mjs +200 -0
  47. package/federation/topo_order_memo.mjs +44 -0
  48. package/federation/volatile_streams.mjs +52 -0
  49. package/federation/want_ids.mjs +158 -0
  50. package/files/assemble.mjs +220 -0
  51. package/files/assemble_stream.mjs +129 -0
  52. package/files/chunk_fetch.mjs +113 -0
  53. package/files/chunk_fetch_verify.mjs +23 -0
  54. package/files/chunk_provider_registry.mjs +70 -0
  55. package/files/chunk_responder.mjs +79 -0
  56. package/files/chunk_store.mjs +87 -0
  57. package/files/manifest.mjs +99 -0
  58. package/files/transfer_key.mjs +87 -0
  59. package/files/transfer_key_registry.mjs +108 -0
  60. package/governance/branch.mjs +230 -0
  61. package/governance/join_pow.mjs +165 -0
  62. package/governance/owner_succession_ballot.mjs +57 -0
  63. package/governance/tunables.json +7 -0
  64. package/index.mjs +39 -0
  65. package/link/channel_mux.mjs +222 -0
  66. package/link/frame.mjs +246 -0
  67. package/link/handshake.mjs +175 -0
  68. package/link/link.mjs +617 -0
  69. package/link/rtc.mjs +109 -0
  70. package/link/sdp_fingerprint.mjs +25 -0
  71. package/mailbox/consumer_registry.mjs +71 -0
  72. package/mailbox/deliver_or_store.mjs +190 -0
  73. package/mailbox/importance.mjs +72 -0
  74. package/mailbox/parse.mjs +73 -0
  75. package/mailbox/prune.mjs +106 -0
  76. package/mailbox/rate.mjs +86 -0
  77. package/mailbox/settings.mjs +53 -0
  78. package/mailbox/store.mjs +256 -0
  79. package/mailbox/tunables.json +12 -0
  80. package/mailbox/wire.mjs +40 -0
  81. package/node/denylist.mjs +309 -0
  82. package/node/entity_store.mjs +214 -0
  83. package/node/identity.mjs +99 -0
  84. package/node/instance.mjs +115 -0
  85. package/node/network.mjs +306 -0
  86. package/node/personal_block.mjs +286 -0
  87. package/node/reputation_store.mjs +326 -0
  88. package/node/retention_policy.mjs +82 -0
  89. package/node/signaling_config.mjs +32 -0
  90. package/node/storage.mjs +30 -0
  91. package/node/storage_plugins.mjs +71 -0
  92. package/node/user_paths.mjs +10 -0
  93. package/overlay/index.mjs +191 -0
  94. package/package.json +73 -0
  95. package/permissions/bitmask.mjs +35 -0
  96. package/permissions/evaluator.mjs +90 -0
  97. package/permissions/index.mjs +12 -0
  98. package/permissions/layered.mjs +39 -0
  99. package/registries/action.mjs +94 -0
  100. package/registries/event_type.mjs +75 -0
  101. package/registries/inbound.mjs +90 -0
  102. package/registries/p2p_viewer.mjs +35 -0
  103. package/registries/part_path.mjs +35 -0
  104. package/registries/room_provider.mjs +47 -0
  105. package/reputation/engine.mjs +458 -0
  106. package/reputation/math.mjs +73 -0
  107. package/reputation/pick_score.mjs +11 -0
  108. package/reputation/relay_dedupe.mjs +22 -0
  109. package/reputation/tunables.json +36 -0
  110. package/rooms/scoped_link.mjs +185 -0
  111. package/schemas/discovery.mjs +110 -0
  112. package/schemas/federation_pull.mjs +102 -0
  113. package/schemas/mailbox.mjs +37 -0
  114. package/schemas/remote_event.mjs +17 -0
  115. package/timeline/append_core.mjs +54 -0
  116. package/timeline/materialize_runner.mjs +26 -0
  117. package/timeline/prune.mjs +39 -0
  118. package/timeline/retention.mjs +54 -0
  119. package/timeline/retention_runner.mjs +35 -0
  120. package/timeline/verify_remote.mjs +25 -0
  121. package/transport/group_link_set.mjs +324 -0
  122. package/transport/ice_servers.mjs +72 -0
  123. package/transport/link_registry.mjs +749 -0
  124. package/transport/peer_identity_maps.mjs +94 -0
  125. package/transport/peer_pool.mjs +355 -0
  126. package/transport/remote_user_room.mjs +109 -0
  127. package/transport/room_scopes.mjs +2 -0
  128. package/transport/room_wire_action.mjs +19 -0
  129. package/transport/rtc_connection_budget.mjs +180 -0
  130. package/transport/rtc_mdns_filter.mjs +118 -0
  131. package/transport/stale_peer_log.mjs +50 -0
  132. package/transport/user_room.mjs +263 -0
  133. package/trust_graph/build.mjs +99 -0
  134. package/trust_graph/cache.mjs +44 -0
  135. package/trust_graph/engine.mjs +184 -0
  136. package/trust_graph/registry.mjs +38 -0
  137. package/trust_graph/resolve.mjs +105 -0
  138. package/trust_graph/send.mjs +88 -0
  139. package/trust_graph/tunables.json +13 -0
  140. package/utils/async_mutex.mjs +69 -0
  141. package/utils/debug_log.mjs +16 -0
  142. package/utils/duration.mjs +28 -0
  143. package/utils/json_io.mjs +57 -0
  144. package/utils/lru.mjs +22 -0
  145. package/utils/map_pool.mjs +27 -0
  146. package/wire/group_part.mjs +43 -0
  147. package/wire/ingress.mjs +58 -0
  148. package/wire/part_common.mjs +55 -0
  149. package/wire/part_fanout.mjs +71 -0
  150. package/wire/part_ingress.mjs +172 -0
  151. package/wire/part_invoke.mjs +81 -0
  152. package/wire/rate_bucket.mjs +49 -0
  153. package/wire/volatile_signature.mjs +76 -0
  154. package/wire/wait.mjs +127 -0
package/link/frame.mjs ADDED
@@ -0,0 +1,246 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * 二进制帧协议版本号。
5
+ */
6
+ export const FRAME_VERSION = 1
7
+ /**
8
+ * msgId 字段字节长度(128 位)。
9
+ */
10
+ export const FRAME_MSG_ID_BYTES = 16
11
+ /**
12
+ * 帧头字节长度:version(1) + msgId(16) + seq(4) + total(4)。
13
+ */
14
+ export const FRAME_HEADER_BYTES = 1 + FRAME_MSG_ID_BYTES + 4 + 4
15
+ /**
16
+ * 默认单帧最大 chunk 大小(15 KiB)。
17
+ */
18
+ export const DEFAULT_MAX_FRAME_CHUNK_BYTES = 15 * 1024
19
+ /**
20
+ * 重组后消息最大字节数(8 MiB)。
21
+ */
22
+ export const DEFAULT_MAX_MESSAGE_BYTES = 8 * 1024 * 1024
23
+ /**
24
+ * 同时进行中的分片消息数量上限。
25
+ */
26
+ export const DEFAULT_MAX_PARTIAL_MESSAGES = 32
27
+ /**
28
+ * 分片消息超时时间(毫秒)。
29
+ */
30
+ export const DEFAULT_PARTIAL_TIMEOUT_MS = 30_000
31
+
32
+ /**
33
+ * 将输入规范化为 Uint8Array。
34
+ * @param {unknown} value 原始字节数据
35
+ * @returns {Uint8Array} 字节视图
36
+ */
37
+ function normalizeBytes(value) {
38
+ if (value instanceof Uint8Array) return value
39
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
40
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
41
+ throw new Error('p2p: frame bytes must be Uint8Array-compatible')
42
+ }
43
+
44
+ /**
45
+ * 将 msgId 规范化为 16 字节 Uint8Array。
46
+ * @param {unknown} msgId hex 字符串或 16 字节 Uint8Array
47
+ * @returns {Uint8Array} 16 字节 msgId
48
+ */
49
+ function normalizeMsgIdBytes(msgId) {
50
+ if (msgId instanceof Uint8Array) {
51
+ if (msgId.byteLength !== FRAME_MSG_ID_BYTES)
52
+ throw new Error(`p2p: msgId must be ${FRAME_MSG_ID_BYTES} bytes`)
53
+ return msgId
54
+ }
55
+ const text = String(msgId ?? '').trim().toLowerCase()
56
+ if (!/^[\da-f]{32}$/u.test(text))
57
+ throw new Error('p2p: msgId must be 32 hex characters')
58
+ return Uint8Array.from(text.match(/../g).map(chunk => Number.parseInt(chunk, 16)))
59
+ }
60
+
61
+ /**
62
+ * 将 msgId 字节转为 32 字符小写 hex。
63
+ * @param {Uint8Array} bytes 16 字节 msgId
64
+ * @returns {string} hex 字符串
65
+ */
66
+ export function msgIdBytesToHex(bytes) {
67
+ return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
68
+ }
69
+
70
+ /**
71
+ * 生成随机 msgId hex 字符串。
72
+ * @returns {string} 32 字符 hex msgId
73
+ */
74
+ export function randomMsgIdHex() {
75
+ return msgIdBytesToHex(randomBytes(FRAME_MSG_ID_BYTES))
76
+ }
77
+
78
+ /**
79
+ * 将消息体拆分为带帧头的二进制帧数组。
80
+ * @param {string | Uint8Array} msgId 消息 ID
81
+ * @param {Uint8Array | ArrayBuffer | ArrayBufferView} bytes 消息体字节
82
+ * @param {number} [maxChunkBytes=DEFAULT_MAX_FRAME_CHUNK_BYTES] 单帧最大 chunk 大小
83
+ * @returns {Uint8Array[]} 帧数组
84
+ */
85
+ export function encodeFrames(msgId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHUNK_BYTES) {
86
+ const body = normalizeBytes(bytes)
87
+ const idBytes = normalizeMsgIdBytes(msgId)
88
+ const chunkBytes = Math.max(256, Math.min(DEFAULT_MAX_MESSAGE_BYTES, Number(maxChunkBytes) || DEFAULT_MAX_FRAME_CHUNK_BYTES))
89
+ const total = Math.max(1, Math.ceil(body.byteLength / chunkBytes))
90
+ /** @type {Uint8Array[]} */
91
+ const frames = []
92
+ for (let seq = 0; seq < total; seq++) {
93
+ const start = seq * chunkBytes
94
+ const end = Math.min(body.byteLength, start + chunkBytes)
95
+ const chunk = body.subarray(start, end)
96
+ const frame = new Uint8Array(FRAME_HEADER_BYTES + chunk.byteLength)
97
+ frame[0] = FRAME_VERSION
98
+ frame.set(idBytes, 1)
99
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength)
100
+ view.setUint32(1 + FRAME_MSG_ID_BYTES, seq, false)
101
+ view.setUint32(1 + FRAME_MSG_ID_BYTES + 4, total, false)
102
+ frame.set(chunk, FRAME_HEADER_BYTES)
103
+ frames.push(frame)
104
+ }
105
+ return frames
106
+ }
107
+
108
+ /**
109
+ * 解析单帧二进制数据。
110
+ * @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame 原始帧字节
111
+ * @returns {{ version: number, msgId: string, seq: number, total: number, chunk: Uint8Array }} 帧字段
112
+ */
113
+ export function decodeFrame(frame) {
114
+ const bytes = normalizeBytes(frame)
115
+ if (bytes.byteLength < FRAME_HEADER_BYTES)
116
+ throw new Error('p2p: frame too short')
117
+ const version = bytes[0]
118
+ if (version !== FRAME_VERSION)
119
+ throw new Error(`p2p: unsupported frame version ${version}`)
120
+ const msgId = msgIdBytesToHex(bytes.subarray(1, 1 + FRAME_MSG_ID_BYTES))
121
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
122
+ const seq = view.getUint32(1 + FRAME_MSG_ID_BYTES, false)
123
+ const total = view.getUint32(1 + FRAME_MSG_ID_BYTES + 4, false)
124
+ if (!total || seq >= total)
125
+ throw new Error('p2p: invalid frame sequence')
126
+ return {
127
+ version,
128
+ msgId,
129
+ seq,
130
+ total,
131
+ chunk: bytes.subarray(FRAME_HEADER_BYTES),
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 按序拼接多个 chunk 为完整消息体。
137
+ * @param {Uint8Array[]} chunks 已排序的 chunk 数组
138
+ * @returns {Uint8Array} 拼接后的消息体
139
+ */
140
+ function concatChunks(chunks) {
141
+ const totalBytes = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
142
+ const out = new Uint8Array(totalBytes)
143
+ let offset = 0
144
+ for (const chunk of chunks) {
145
+ out.set(chunk, offset)
146
+ offset += chunk.byteLength
147
+ }
148
+ return out
149
+ }
150
+
151
+ /**
152
+ * 创建分片消息重组器。
153
+ * @param {{ maxMessageBytes?: number, maxPartials?: number, partialTimeoutMs?: number }} [opts] 大小、并发分片数与超时配置
154
+ * @returns {{ push: (frame: Uint8Array | ArrayBuffer | ArrayBufferView, now?: number) => Uint8Array | null, prune: (now?: number) => string[], clear: () => void, size: () => number }} 重组器 API
155
+ */
156
+ export function createReassembler(opts = {}) {
157
+ const maxMessageBytes = Math.max(1024, Number(opts.maxMessageBytes) || DEFAULT_MAX_MESSAGE_BYTES)
158
+ const maxPartials = Math.max(1, Number(opts.maxPartials) || DEFAULT_MAX_PARTIAL_MESSAGES)
159
+ const partialTimeoutMs = Math.max(1000, Number(opts.partialTimeoutMs) || DEFAULT_PARTIAL_TIMEOUT_MS)
160
+ /** @type {Map<string, { total: number, chunks: Uint8Array[], seen: boolean[], bytes: number, firstSeenAt: number, lastSeenAt: number }>} */
161
+ const partials = new Map()
162
+
163
+ /**
164
+ * 丢弃指定 msgId 的分片状态。
165
+ * @param {string} msgId 消息 ID
166
+ * @returns {void}
167
+ */
168
+ function drop(msgId) {
169
+ partials.delete(msgId)
170
+ }
171
+
172
+ return {
173
+ /**
174
+ * 喂入一帧,收齐全部分片时返回完整消息体。
175
+ * @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame 原始帧字节
176
+ * @param {number} [now=Date.now()] 当前时间戳(毫秒)
177
+ * @returns {Uint8Array | null} 完整消息体,未收齐时返回 null
178
+ */
179
+ push(frame, now = Date.now()) {
180
+ const parsed = decodeFrame(frame)
181
+ if (!partials.has(parsed.msgId) && partials.size >= maxPartials)
182
+ throw new Error('p2p: too many partial messages')
183
+ let partial = partials.get(parsed.msgId)
184
+ if (!partial) {
185
+ partial = {
186
+ total: parsed.total,
187
+ chunks: new Array(parsed.total),
188
+ seen: new Array(parsed.total).fill(false),
189
+ bytes: 0,
190
+ firstSeenAt: now,
191
+ lastSeenAt: now,
192
+ }
193
+ partials.set(parsed.msgId, partial)
194
+ }
195
+ if (partial.total !== parsed.total) {
196
+ drop(parsed.msgId)
197
+ throw new Error('p2p: frame total mismatch')
198
+ }
199
+ partial.lastSeenAt = now
200
+ if (!partial.seen[parsed.seq]) {
201
+ partial.chunks[parsed.seq] = parsed.chunk
202
+ partial.seen[parsed.seq] = true
203
+ partial.bytes += parsed.chunk.byteLength
204
+ if (partial.bytes > maxMessageBytes) {
205
+ drop(parsed.msgId)
206
+ throw new Error('p2p: reassembled message exceeds limit')
207
+ }
208
+ }
209
+ if (partial.seen.every(Boolean)) {
210
+ const out = concatChunks(partial.chunks)
211
+ drop(parsed.msgId)
212
+ return out
213
+ }
214
+ return null
215
+ },
216
+ /**
217
+ * 清理超时的分片消息。
218
+ * @param {number} [now=Date.now()] 当前时间戳(毫秒)
219
+ * @returns {string[]} 被丢弃的 msgId 列表
220
+ */
221
+ prune(now = Date.now()) {
222
+ /** @type {string[]} */
223
+ const expired = []
224
+ for (const [msgId, partial] of partials.entries())
225
+ if (now - partial.lastSeenAt > partialTimeoutMs) {
226
+ expired.push(msgId)
227
+ partials.delete(msgId)
228
+ }
229
+ return expired
230
+ },
231
+ /**
232
+ * 清空所有进行中的分片状态。
233
+ * @returns {void}
234
+ */
235
+ clear() {
236
+ partials.clear()
237
+ },
238
+ /**
239
+ * 返回当前进行中的分片消息数量。
240
+ * @returns {number} 分片 msgId 数量
241
+ */
242
+ size() {
243
+ return partials.size
244
+ },
245
+ }
246
+ }
@@ -0,0 +1,175 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { randomBytes } from 'node:crypto'
3
+
4
+ import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
5
+ import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
6
+ import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
7
+
8
+ import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
9
+
10
+ /**
11
+ * Link 握手签名域标识符。
12
+ */
13
+ export const LINK_HANDSHAKE_DOMAIN = 'fount-link-v1'
14
+
15
+ /**
16
+ * 构造 link auth 待签名字节串。
17
+ * @param {string} peerNonce 对端 hello 中的 nonce(64 位 hex)
18
+ * @param {string} localFingerprint 本地 DTLS fingerprint
19
+ * @param {string} localNodeHash 本地节点 nodeHash(64 位 hex)
20
+ * @returns {Uint8Array} 待签名消息字节
21
+ */
22
+ export function buildAuthMessage(peerNonce, localFingerprint, localNodeHash) {
23
+ const nonce = normalizeHex64(peerNonce)
24
+ const fingerprint = normalizeDtlsFingerprint(localFingerprint)
25
+ const nodeHash = normalizeHex64(localNodeHash)
26
+ if (!/^[\da-f]{64}$/u.test(nonce))
27
+ throw new Error('p2p: auth nonce must be 64 hex characters')
28
+ if (!fingerprint)
29
+ throw new Error('p2p: DTLS fingerprint missing or invalid')
30
+ if (!isHex64(nodeHash))
31
+ throw new Error('p2p: nodeHash must be 64 hex characters')
32
+ return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${fingerprint}\0${nodeHash}`, 'utf8')
33
+ }
34
+
35
+ /**
36
+ * 构造 link hello 握手包。
37
+ * @param {{ nodeHash?: string, nodePubKey?: string, nonce?: string }} [opts] 可选身份字段,省略则从本地节点种子推导
38
+ * @returns {{ v: 1, nodeHash: string, nodePubKey: string, nonce: string }} hello 对象
39
+ */
40
+ export function buildHello(opts = {}) {
41
+ let publicKey = null
42
+ if (!opts.nodeHash || !opts.nodePubKey) {
43
+ const derived = keyPairFromSeed(Buffer.from(ensureNodeSeed(), 'hex'))
44
+ publicKey = derived.publicKey
45
+ }
46
+ const nodeHash = normalizeHex64(opts.nodeHash || getNodeHash())
47
+ const nodePubKey = normalizeHex64(opts.nodePubKey || Buffer.from(publicKey).toString('hex'))
48
+ const nonce = normalizeHex64(opts.nonce || randomBytes(32).toString('hex'))
49
+ if (!isHex64(nodeHash) || !isHex64(nodePubKey) || !isHex64(nonce))
50
+ throw new Error('p2p: invalid hello fields')
51
+ if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
52
+ throw new Error('p2p: hello nodePubKey does not match nodeHash')
53
+ return { v: 1, nodeHash, nodePubKey, nonce }
54
+ }
55
+
56
+ /**
57
+ * 对 link auth 消息签名。
58
+ * @param {string} peerNonce 对端 hello 中的 nonce
59
+ * @param {string} localFingerprint 本地 DTLS fingerprint
60
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string }} [opts] 签名密钥与 nodeHash 覆盖
61
+ * @returns {Promise<{ sig: string }>} hex 签名
62
+ */
63
+ export async function buildAuth(peerNonce, localFingerprint, opts = {}) {
64
+ const seed = opts.secretKey
65
+ ? Buffer.from(opts.secretKey)
66
+ : Buffer.from(ensureNodeSeed(), 'hex')
67
+ const { publicKey, secretKey } = keyPairFromSeed(seed)
68
+ const nodeHash = normalizeHex64(opts.nodeHash || pubKeyHash(publicKey))
69
+ if (nodeHash !== pubKeyHash(publicKey))
70
+ throw new Error('p2p: auth nodeHash does not match secretKey')
71
+ const message = buildAuthMessage(peerNonce, localFingerprint, nodeHash)
72
+ const signature = await sign(message, secretKey)
73
+ return { sig: Buffer.from(signature).toString('hex') }
74
+ }
75
+
76
+ /**
77
+ * 解析并校验 hello 对象,无效时返回 null。
78
+ * @param {unknown} hello 原始 hello 载荷
79
+ * @returns {{ v: 1, nodeHash: string, nodePubKey: string, nonce: string } | null} 规范化 hello 或 null
80
+ */
81
+ export function parseHello(hello) {
82
+ const nodeHash = normalizeHex64(hello?.nodeHash)
83
+ const nodePubKey = normalizeHex64(hello?.nodePubKey)
84
+ const nonce = normalizeHex64(hello?.nonce)
85
+ if (Number(hello?.v) !== 1) return null
86
+ if (!isHex64(nodeHash) || !isHex64(nodePubKey) || !isHex64(nonce)) return null
87
+ try {
88
+ if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash) return null
89
+ }
90
+ catch {
91
+ return null
92
+ }
93
+ return { v: 1, nodeHash, nodePubKey, nonce }
94
+ }
95
+
96
+ /**
97
+ * 验证对端 auth 签名,成功返回对端 nodeHash。
98
+ * @param {unknown} hello 对端 hello
99
+ * @param {unknown} auth 对端 auth(含 sig)
100
+ * @param {string} expectedNonce 本地 hello 发出的 nonce
101
+ * @param {string} remoteFingerprintFromSdp 从 SDP 提取的远端 DTLS fingerprint
102
+ * @returns {Promise<string | null>} 验证通过的 nodeHash,失败返回 null
103
+ */
104
+ export async function verifyAuth(hello, auth, expectedNonce, remoteFingerprintFromSdp) {
105
+ const parsedHello = parseHello(hello)
106
+ if (!parsedHello) return null
107
+ const signatureHex = String(auth?.sig ?? '').trim().toLowerCase()
108
+ const fingerprint = normalizeDtlsFingerprint(remoteFingerprintFromSdp)
109
+ if (!/^[\da-f]{128}$/u.test(signatureHex) || !fingerprint) return null
110
+ const normalizedNonce = normalizeHex64(expectedNonce)
111
+ if (!isHex64(normalizedNonce)) return null
112
+ const message = buildAuthMessage(normalizedNonce, fingerprint, parsedHello.nodeHash)
113
+ const ok = await verify(
114
+ Buffer.from(signatureHex, 'hex'),
115
+ message,
116
+ Buffer.from(parsedHello.nodePubKey, 'hex'),
117
+ )
118
+ return ok ? parsedHello.nodeHash : null
119
+ }
120
+
121
+ /**
122
+ * 构造 discovery advert 待签名字节串。
123
+ * @param {string} topic 广播主题
124
+ * @param {number} ts 时间戳(毫秒)
125
+ * @param {string} nodeHash 节点 nodeHash
126
+ * @returns {Uint8Array} 待签名消息字节
127
+ */
128
+ export function buildAdvertMessage(topic, ts, nodeHash) {
129
+ return Buffer.from(`fount-advert-v1\0${String(topic)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`, 'utf8')
130
+ }
131
+
132
+ /**
133
+ * 构造带签名的 discovery advert。
134
+ * @param {string} topic 广播主题
135
+ * @param {number} [ts=Date.now()] 时间戳(毫秒)
136
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string } | null} [opts] 签名身份,省略则用本地节点
137
+ * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string }>} 签名 advert
138
+ */
139
+ export async function buildSignedAdvert(topic, ts = Date.now(), opts = null) {
140
+ const seed = opts?.secretKey
141
+ ? Buffer.from(opts.secretKey)
142
+ : Buffer.from(ensureNodeSeed(), 'hex')
143
+ const { publicKey, secretKey } = keyPairFromSeed(seed)
144
+ const nodeHash = normalizeHex64(opts?.nodeHash || pubKeyHash(publicKey))
145
+ const nodePubKey = normalizeHex64(opts?.nodePubKey || Buffer.from(publicKey).toString('hex'))
146
+ if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
147
+ throw new Error('p2p: advert nodePubKey does not match nodeHash')
148
+ const message = buildAdvertMessage(topic, ts, nodeHash)
149
+ const sig = await sign(message, secretKey)
150
+ return {
151
+ nodeHash,
152
+ nodePubKey,
153
+ ts,
154
+ sig: Buffer.from(sig).toString('hex'),
155
+ }
156
+ }
157
+
158
+ /**
159
+ * 验证 discovery advert 签名与时间戳,成功返回发布者 nodeHash。
160
+ * @param {string} topic 期望的广播主题
161
+ * @param {unknown} advert 原始 advert 载荷
162
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
163
+ * @param {number} [maxSkewMs=10 * 60_000] 允许的最大时钟偏差(毫秒)
164
+ * @returns {Promise<string | null>} 验证通过的 nodeHash,失败返回 null
165
+ */
166
+ export async function verifySignedAdvert(topic, advert, now = Date.now(), maxSkewMs = 10 * 60_000) {
167
+ const parsedHello = parseHello({ v: 1, nodeHash: advert?.nodeHash, nodePubKey: advert?.nodePubKey, nonce: '0'.repeat(64) })
168
+ if (!parsedHello) return null
169
+ const ts = Number(advert?.ts)
170
+ const sig = String(advert?.sig ?? '').trim().toLowerCase()
171
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !/^[\da-f]{128}$/u.test(sig)) return null
172
+ const message = buildAdvertMessage(topic, ts, parsedHello.nodeHash)
173
+ const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
174
+ return ok ? parsedHello.nodeHash : null
175
+ }