@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.
- package/README.md +62 -0
- package/core/bytes_codec.mjs +51 -0
- package/core/canonical_json.mjs +21 -0
- package/core/composite_key.mjs +91 -0
- package/core/constants.mjs +53 -0
- package/core/entity_id.mjs +53 -0
- package/core/entity_id_parse.mjs +40 -0
- package/core/evfs_logical_path.mjs +15 -0
- package/core/hexIds.mjs +73 -0
- package/core/hlc.mjs +114 -0
- package/core/random_id.mjs +9 -0
- package/crypto/channel.mjs +101 -0
- package/crypto/checkpoint_sign.mjs +67 -0
- package/crypto/crypto.mjs +158 -0
- package/crypto/key.mjs +431 -0
- package/dag/canonicalize_row.mjs +65 -0
- package/dag/event_query.mjs +15 -0
- package/dag/index.mjs +335 -0
- package/dag/storage.mjs +333 -0
- package/dag/strip_extensions.mjs +18 -0
- package/dag/tunables.json +6 -0
- package/discovery/bt.mjs +260 -0
- package/discovery/index.mjs +134 -0
- package/discovery/mdns.mjs +138 -0
- package/discovery/nostr.mjs +273 -0
- package/entity/files/acl.mjs +38 -0
- package/entity/files/evfs.mjs +189 -0
- package/entity/files/evfs_ref.mjs +37 -0
- package/entity/files/manifest_acl_registry.mjs +93 -0
- package/entity/files/replica_host_cache.mjs +46 -0
- package/entity/files/url.mjs +19 -0
- package/entity/hosting_registry.mjs +47 -0
- package/entity/localized_core.mjs +141 -0
- package/entity/logical_entity.mjs +25 -0
- package/entity/logical_entity_id_registry.mjs +40 -0
- package/entity/node_hash.mjs +15 -0
- package/entity/presentation_registry.mjs +44 -0
- package/entity/profile.mjs +256 -0
- package/entity/replica.mjs +20 -0
- package/entity/session_snapshot_registry.mjs +38 -0
- package/federation/chunk_fetch_pending.mjs +128 -0
- package/federation/chunk_fetch_scheduler.mjs +97 -0
- package/federation/dag_order_cache.mjs +124 -0
- package/federation/dedupe_slot.mjs +33 -0
- package/federation/message_rate_limit.mjs +23 -0
- package/federation/operator_key_chain.mjs +200 -0
- package/federation/topo_order_memo.mjs +44 -0
- package/federation/volatile_streams.mjs +52 -0
- package/federation/want_ids.mjs +158 -0
- package/files/assemble.mjs +220 -0
- package/files/assemble_stream.mjs +129 -0
- package/files/chunk_fetch.mjs +113 -0
- package/files/chunk_fetch_verify.mjs +23 -0
- package/files/chunk_provider_registry.mjs +70 -0
- package/files/chunk_responder.mjs +79 -0
- package/files/chunk_store.mjs +87 -0
- package/files/manifest.mjs +99 -0
- package/files/transfer_key.mjs +87 -0
- package/files/transfer_key_registry.mjs +108 -0
- package/governance/branch.mjs +230 -0
- package/governance/join_pow.mjs +165 -0
- package/governance/owner_succession_ballot.mjs +57 -0
- package/governance/tunables.json +7 -0
- package/index.mjs +39 -0
- package/link/channel_mux.mjs +222 -0
- package/link/frame.mjs +246 -0
- package/link/handshake.mjs +175 -0
- package/link/link.mjs +617 -0
- package/link/rtc.mjs +109 -0
- package/link/sdp_fingerprint.mjs +25 -0
- package/mailbox/consumer_registry.mjs +71 -0
- package/mailbox/deliver_or_store.mjs +190 -0
- package/mailbox/importance.mjs +72 -0
- package/mailbox/parse.mjs +73 -0
- package/mailbox/prune.mjs +106 -0
- package/mailbox/rate.mjs +86 -0
- package/mailbox/settings.mjs +53 -0
- package/mailbox/store.mjs +256 -0
- package/mailbox/tunables.json +12 -0
- package/mailbox/wire.mjs +40 -0
- package/node/denylist.mjs +309 -0
- package/node/entity_store.mjs +214 -0
- package/node/identity.mjs +99 -0
- package/node/instance.mjs +115 -0
- package/node/network.mjs +306 -0
- package/node/personal_block.mjs +286 -0
- package/node/reputation_store.mjs +326 -0
- package/node/retention_policy.mjs +82 -0
- package/node/signaling_config.mjs +32 -0
- package/node/storage.mjs +30 -0
- package/node/storage_plugins.mjs +71 -0
- package/node/user_paths.mjs +10 -0
- package/overlay/index.mjs +191 -0
- package/package.json +73 -0
- package/permissions/bitmask.mjs +35 -0
- package/permissions/evaluator.mjs +90 -0
- package/permissions/index.mjs +12 -0
- package/permissions/layered.mjs +39 -0
- package/registries/action.mjs +94 -0
- package/registries/event_type.mjs +75 -0
- package/registries/inbound.mjs +90 -0
- package/registries/p2p_viewer.mjs +35 -0
- package/registries/part_path.mjs +35 -0
- package/registries/room_provider.mjs +47 -0
- package/reputation/engine.mjs +458 -0
- package/reputation/math.mjs +73 -0
- package/reputation/pick_score.mjs +11 -0
- package/reputation/relay_dedupe.mjs +22 -0
- package/reputation/tunables.json +36 -0
- package/rooms/scoped_link.mjs +185 -0
- package/schemas/discovery.mjs +110 -0
- package/schemas/federation_pull.mjs +102 -0
- package/schemas/mailbox.mjs +37 -0
- package/schemas/remote_event.mjs +17 -0
- package/timeline/append_core.mjs +54 -0
- package/timeline/materialize_runner.mjs +26 -0
- package/timeline/prune.mjs +39 -0
- package/timeline/retention.mjs +54 -0
- package/timeline/retention_runner.mjs +35 -0
- package/timeline/verify_remote.mjs +25 -0
- package/transport/group_link_set.mjs +324 -0
- package/transport/ice_servers.mjs +72 -0
- package/transport/link_registry.mjs +749 -0
- package/transport/peer_identity_maps.mjs +94 -0
- package/transport/peer_pool.mjs +355 -0
- package/transport/remote_user_room.mjs +109 -0
- package/transport/room_scopes.mjs +2 -0
- package/transport/room_wire_action.mjs +19 -0
- package/transport/rtc_connection_budget.mjs +180 -0
- package/transport/rtc_mdns_filter.mjs +118 -0
- package/transport/stale_peer_log.mjs +50 -0
- package/transport/user_room.mjs +263 -0
- package/trust_graph/build.mjs +99 -0
- package/trust_graph/cache.mjs +44 -0
- package/trust_graph/engine.mjs +184 -0
- package/trust_graph/registry.mjs +38 -0
- package/trust_graph/resolve.mjs +105 -0
- package/trust_graph/send.mjs +88 -0
- package/trust_graph/tunables.json +13 -0
- package/utils/async_mutex.mjs +69 -0
- package/utils/debug_log.mjs +16 -0
- package/utils/duration.mjs +28 -0
- package/utils/json_io.mjs +57 -0
- package/utils/lru.mjs +22 -0
- package/utils/map_pool.mjs +27 -0
- package/wire/group_part.mjs +43 -0
- package/wire/ingress.mjs +58 -0
- package/wire/part_common.mjs +55 -0
- package/wire/part_fanout.mjs +71 -0
- package/wire/part_ingress.mjs +172 -0
- package/wire/part_invoke.mjs +81 -0
- package/wire/rate_bucket.mjs +49 -0
- package/wire/volatile_signature.mjs +76 -0
- 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
|
+
}
|