@steve02081504/fount-p2p 0.0.41 → 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
@@ -24,6 +24,8 @@ Detail docs: [transports](docs/transports.md) · [mesh](docs/mesh.md) · [signal
24
24
  | `crypto/crypto.mjs` | Node + browser | `@noble/hashes` + `@noble/curves` only — never `node:crypto` |
25
25
  | `crypto/key.mjs` / `crypto/channel.mjs`, disk I/O, LAN/BT, `ws`, CLI / `startNode` | Node (+ Deno bridge) | Do not load the whole package via esm.sh in the browser |
26
26
 
27
+ **Browser pages reusing package code:** pages import browser-safe package modules (`core/*`, `crypto/crypto.mjs`, `discovery/nostr/constants|_math|_verify|_monitor`) from esm.sh; their bare `@noble/*` deps resolve via an injected importmap. The frontend static test server (`test/frontend/census_page.test.mjs`) serves the whole package root (offline), injects an importmap into served HTML (`https://esm.sh/@steve02081504/fount-p2p/` → local package root, `@noble/` → `https://esm.sh/@noble/`), and runs the page live against a fake relay (`test/helpers/fake_relay.mjs` with `store`), injecting its `ws://` URL via `?relay=` — no demo mode. Page code stays a thin shell — pass display fn + optional relays; logic lives in the package (`discovery/nostr/census_monitor.mjs`).
28
+
27
29
  Deno / native / BT: [runtime.md](docs/runtime.md).
28
30
 
29
31
  ## Conventions
@@ -33,6 +35,7 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
33
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.
34
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.
35
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.
36
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.
37
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.
38
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
@@ -15,45 +15,33 @@
15
15
  */
16
16
  import { Buffer } from 'node:buffer'
17
17
 
18
- import { isHex64, isSignatureHex128 } from '../../core/hexIds.mjs'
19
- import { keyPairFromSeed, pubKeyHash, sign, verify } from '../../crypto/crypto.mjs'
18
+ import { isHex64 } from '../../core/hexIds.mjs'
19
+ import { keyPairFromSeed, pubKeyHash, sign } from '../../crypto/crypto.mjs'
20
20
  import { ensureNodeSeed, getNodeHash } from '../../node/identity.mjs'
21
21
  import { getP2PFeatures, isNodeInitialized } from '../../node/instance.mjs'
22
22
  import { nodeDebug } from '../../node/log.mjs'
23
23
 
24
24
  import {
25
- CENSUS_MIN_P,
25
+ CENSUS_TAG_FOUNT,
26
+ CENSUS_TAG_X,
27
+ NOSTR_CENSUS_KIND,
28
+ } from './constants.mjs'
29
+ import {
26
30
  CENSUS_TARGET_EVENTS,
27
31
  estimatePopulation,
28
32
  nextInclusionProbability,
29
33
  } from './census_math.mjs'
34
+ import { CENSUS_TTL_MS, buildCensusMessage, verifyCensusBytes } from './census_verify.mjs'
30
35
  import { resolveRelayConnectTarget } from './relays.mjs'
31
36
 
32
- /** Nostr census 事件 kind(每节点每窗口至多一条,按 nodeHash 去重)。 */
33
- export const NOSTR_CENSUS_KIND = 30789
34
-
35
- /** census 订阅/发布标签:`t=fount` + `x=census`(subscribeNostrKind 以 rendezvousKey/tagX 匹配)。 */
36
- const CENSUS_TAG_FOUNT = 'fount'
37
- const CENSUS_TAG_X = 'census'
37
+ /** census 订阅标签数组(发布用)。 */
38
38
  const CENSUS_TAGS = [['t', CENSUS_TAG_FOUNT], ['x', CENSUS_TAG_X]]
39
39
 
40
- /** 事件/窗口存活时间(与 advert TTL 一致)。 */
41
- const CENSUS_TTL_MS = 10 * 60_000
42
40
  /** 发布/统计周期。 */
43
41
  const CENSUS_INTERVAL_MS = 10 * 60_000
44
42
  /** 冷启动初始包含概率。 */
45
43
  const CENSUS_INITIAL_P = 0.5
46
44
 
47
- /**
48
- * @param {number} ts 时间戳(毫秒)
49
- * @param {string} nodeHash 64 hex 节点 hash
50
- * @param {number} p 包含概率
51
- * @returns {Buffer} 待签名消息
52
- */
53
- function buildCensusMessage(ts, nodeHash, p) {
54
- return Buffer.from(`fount-census\0${ts}\0${nodeHash}\0${p}`, 'utf8')
55
- }
56
-
57
45
  /**
58
46
  * 用指定 seed 身份构建签名 census 包(nodeHash 由 seed 派生)。
59
47
  * 供工具与测试构造任意身份的 peer 包。
@@ -75,47 +63,6 @@ export async function buildCensusPacketFromSeed(seedHex, { p, ts = Date.now() })
75
63
  }
76
64
  }
77
65
 
78
- /**
79
- * 校验 census 包(Untrusted ingress):canonicalize + 验签 + 时间窗 + p 范围。
80
- * @param {unknown} packet 原始 census 包
81
- * @param {number} [now=Date.now()] 当前时间(毫秒)
82
- * @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
83
- * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过返回 nodeHash/p/ts,否则 null
84
- */
85
- export async function verifyCensusPacket(packet, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
86
- const nodeHash = isHex64(packet?.nodeHash)
87
- const nodePubKey = isHex64(packet?.nodePubKey)
88
- const sig = isSignatureHex128(packet?.sig)
89
- const ts = Number(packet?.ts)
90
- const p = Number(packet?.p)
91
- if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
92
- if (Math.abs(now - ts) > ttlMs) return null
93
- if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
94
- try {
95
- if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash) return null
96
- }
97
- catch {
98
- return null
99
- }
100
- return await verify(Buffer.from(sig, 'hex'), buildCensusMessage(ts, nodeHash, p), Buffer.from(nodePubKey, 'hex')) ? { nodeHash, p, ts } : null
101
- }
102
-
103
- /**
104
- * 解 base64 content 字节并校验 census 包。
105
- * @param {Uint8Array} bytes content 解码字节
106
- * @param {number} [now=Date.now()] 当前时间(毫秒)
107
- * @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
108
- * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过结果或 null
109
- */
110
- export async function verifyCensusBytes(bytes, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
111
- try {
112
- return await verifyCensusPacket(JSON.parse(Buffer.from(bytes).toString('utf8')), now, ttlMs)
113
- }
114
- catch {
115
- return null
116
- }
117
- }
118
-
119
66
  /** 窗口事件:nodeHash → { p, at }(模块级,与 visibleByHash 同模式)。 */
120
67
  const censusEvents = new Map()
121
68
 
@@ -0,0 +1,403 @@
1
+ /**
2
+ * 浏览器端人口统计监控器(无 Node 依赖;WebSocket + Ed25519 验签)。
3
+ *
4
+ * 供 pages 等纯前端消费:`createPopulationMonitor({ onUpdate, relays, signal })`。
5
+ * 开始监听即自动:
6
+ * 1. 连接全部默认(或传入)relay,订阅 census 事件(kind 30789,t=fount / x=census);
7
+ * 2. 经 NIP-66(kind 30166)发现更多 relay 并加入监听(断开自动指数退避重连);
8
+ * 3. 每轮刷新取人口估计最大的 relay 作为显示源,把
9
+ * `{ estimate, sampleSize, eventsInWindow, relayUrl, relays }` 快照传给 onUpdate。
10
+ */
11
+ import { base64ToBytes } from '../../core/bytes_codec.mjs'
12
+
13
+ import {
14
+ CENSUS_TAG_FOUNT,
15
+ CENSUS_TAG_X,
16
+ DEFAULT_RELAY_URLS,
17
+ NIP66_BOOTSTRAP_RELAYS,
18
+ NOSTR_CENSUS_KIND,
19
+ } from './constants.mjs'
20
+ import { estimatePopulation } from './census_math.mjs'
21
+ import { CENSUS_TTL_MS, verifyCensusBytes } from './census_verify.mjs'
22
+
23
+ /** NIP-66 relay 注册 kind。 */
24
+ const NIP66_KIND = 30166
25
+ /** NIP-66 发现周期。 */
26
+ const NIP66_REFRESH_MS = 30 * 60_000
27
+ /** 单次 NIP-66 REQ 上限。 */
28
+ const NIP66_REQ_LIMIT = 300
29
+ /** 监控 relay 数上限(防 socket 爆炸)。 */
30
+ const MAX_MONITOR_RELAYS = 32
31
+ /** relay 帧 content 最大长度(超出直接丢弃,避免先 Base64 解码再被拒)。 */
32
+ const MAX_FRAME_BYTES = 16 * 1024
33
+ /** 并发验签任务上限;达上限时多余帧直接丢弃。 */
34
+ const MAX_INFLIGHT_VERIFICATIONS = 8
35
+ /** 单次连接超时。 */
36
+ const CONNECT_TIMEOUT_MS = 10_000
37
+ /** 断线重连指数退避:初始延迟与上限。 */
38
+ const RECONNECT_BASE_MS = 1_000
39
+ const RECONNECT_MAX_MS = 30_000
40
+ /** 事件入库后刷新防抖。 */
41
+ const REFRESH_DEBOUNCE_MS = 300
42
+ /** 无事件时周期刷新默认间隔(也用于 TTL 逐出)。 */
43
+ const DEFAULT_REFRESH_MS = 5_000
44
+
45
+ /**
46
+ * 规范化 Nostr relay URL(浏览器端轻量版):仅 ws/wss,小写 host,去默认端口与尾部斜杠。
47
+ * @param {unknown} raw 原始 URL
48
+ * @returns {string | null} 规范化字符串,无效返回 null
49
+ */
50
+ function normalizeRelayUrl(raw) {
51
+ const value = String(raw || '').trim()
52
+ if (!value) return null
53
+ let url
54
+ try { url = new URL(value) } catch { return null }
55
+ if (url.protocol !== 'wss:' && url.protocol !== 'ws:') return null
56
+ if (!url.hostname) return null
57
+ url.hostname = url.hostname.toLowerCase()
58
+ if (url.port && ((url.protocol === 'wss:' && url.port === '443') || (url.protocol === 'ws:' && url.port === '80')))
59
+ url.port = ''
60
+ const path = url.pathname.replace(/\/+$/, '') || '/'
61
+ url.pathname = path
62
+ return url.toString().replace(/\/$/, '')
63
+ }
64
+
65
+ /**
66
+ * @typedef {{
67
+ * url: string,
68
+ * ws: WebSocket | null,
69
+ * subId: string,
70
+ * events: Map<string, { p: number, at: number }>,
71
+ * inflight: number,
72
+ * reconnectDelayMs: number,
73
+ * reconnectTimer: ReturnType<typeof setTimeout> | null,
74
+ * }} RelaySession
75
+ */
76
+
77
+ /**
78
+ * 创建人口统计监控器:立即连接并监听,返回 `{ stop }` 控制句柄。
79
+ * @param {{
80
+ * onUpdate: (snapshot: { estimate: number, sampleSize: number, eventsInWindow: number, relayUrl: string, relays: number }) => void,
81
+ * relays?: string[],
82
+ * signal?: AbortSignal,
83
+ * refreshMs?: number,
84
+ * discover?: boolean,
85
+ * nip66Bootstrap?: string[],
86
+ * }} options 选项
87
+ * @returns {{ stop: () => void }} 停止函数
88
+ */
89
+ export function createPopulationMonitor(options = {}) {
90
+ const {
91
+ onUpdate,
92
+ relays,
93
+ signal,
94
+ refreshMs = DEFAULT_REFRESH_MS,
95
+ discover = true,
96
+ nip66Bootstrap = NIP66_BOOTSTRAP_RELAYS,
97
+ } = options
98
+ if (typeof onUpdate !== 'function')
99
+ throw new Error('p2p: population monitor requires onUpdate callback')
100
+ /** @type {string[]} */
101
+ const relayUrls = []
102
+ for (const raw of relays && relays.length ? relays : DEFAULT_RELAY_URLS) {
103
+ const url = normalizeRelayUrl(raw)
104
+ if (url && !relayUrls.includes(url)) relayUrls.push(url)
105
+ }
106
+ if (!relayUrls.length)
107
+ throw new Error('p2p: population monitor requires at least one relay url')
108
+
109
+ let stopped = false
110
+ /** @type {Map<string, RelaySession>} */
111
+ const sessions = new Map()
112
+ /** @type {ReturnType<typeof setInterval> | null} */
113
+ let refreshTimer = null
114
+ /** @type {ReturnType<typeof setTimeout> | null} */
115
+ let refreshDebounce = null
116
+ /** @type {ReturnType<typeof setInterval> | null} */
117
+ let discoveryTimer = null
118
+
119
+ /**
120
+ * @param {string} url relay URL
121
+ * @returns {RelaySession} 新会话
122
+ */
123
+ function createSession(url) {
124
+ return {
125
+ url,
126
+ ws: null,
127
+ subId: '',
128
+ events: new Map(),
129
+ inflight: 0,
130
+ reconnectDelayMs: RECONNECT_BASE_MS,
131
+ reconnectTimer: null,
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 连接会话(含指数退避重连调度)。
137
+ * @param {RelaySession} session 会话
138
+ * @returns {void}
139
+ */
140
+ function connectSession(session) {
141
+ if (stopped || session.ws) return
142
+ const ws = new WebSocket(session.url)
143
+ session.ws = ws
144
+ const connectTimer = setTimeout(() => { try { ws.close() } catch { /* ignore */ } }, CONNECT_TIMEOUT_MS)
145
+ /**
146
+ * WebSocket 已连接:订阅 census 事件。
147
+ * @returns {void}
148
+ */
149
+ const onOpen = () => {
150
+ if (session.ws !== ws) return
151
+ clearTimeout(connectTimer)
152
+ session.reconnectDelayMs = RECONNECT_BASE_MS
153
+ session.subId = 'census-' + Math.random().toString(36).slice(2)
154
+ ws.send(JSON.stringify(['REQ', session.subId, {
155
+ kinds: [NOSTR_CENSUS_KIND],
156
+ '#t': [CENSUS_TAG_FOUNT],
157
+ '#x': [CENSUS_TAG_X],
158
+ }]))
159
+ }
160
+ /**
161
+ * @param {MessageEvent} event WebSocket 消息事件
162
+ * @returns {void}
163
+ */
164
+ const onMessage = event => {
165
+ if (session.ws !== ws) return
166
+ handleRelayMessage(session, event.data)
167
+ }
168
+ /**
169
+ * 连接关闭:清会话并调度重连。
170
+ * @returns {void}
171
+ */
172
+ const onClose = () => {
173
+ clearTimeout(connectTimer)
174
+ if (session.ws !== ws) return
175
+ session.ws = null
176
+ scheduleReconnect(session)
177
+ }
178
+ ws.addEventListener('open', onOpen)
179
+ ws.addEventListener('message', onMessage)
180
+ ws.addEventListener('close', onClose)
181
+ ws.addEventListener('error', () => { /* close follows error */ })
182
+ }
183
+
184
+ /**
185
+ * 指数退避调度重连(去重保护)。
186
+ * @param {RelaySession} session 会话
187
+ * @returns {void}
188
+ */
189
+ function scheduleReconnect(session) {
190
+ if (stopped || session.reconnectTimer) return
191
+ session.reconnectTimer = setTimeout(() => {
192
+ session.reconnectTimer = null
193
+ connectSession(session)
194
+ }, session.reconnectDelayMs)
195
+ session.reconnectDelayMs = Math.min(session.reconnectDelayMs * 2, RECONNECT_MAX_MS)
196
+ }
197
+
198
+ /**
199
+ * 处理单条 relay 消息:验签 census 帧并入库。
200
+ * @param {RelaySession} session 会话
201
+ * @param {unknown} data 消息数据
202
+ * @returns {void}
203
+ */
204
+ function handleRelayMessage(session, data) {
205
+ if (session.inflight >= MAX_INFLIGHT_VERIFICATIONS) return
206
+ if (typeof data !== 'string' || data.length > MAX_FRAME_BYTES) return
207
+ let parsed
208
+ try { parsed = JSON.parse(data) } catch { return }
209
+ if (parsed?.[0] !== 'EVENT' || parsed[1] !== session.subId) return
210
+ if (parsed[2]?.kind !== NOSTR_CENSUS_KIND) return
211
+ const content = parsed[2]?.content
212
+ if (typeof content !== 'string' || content.length > MAX_FRAME_BYTES) return
213
+ session.inflight++
214
+ void (async () => {
215
+ try {
216
+ const verified = await verifyCensusBytes(base64ToBytes(content))
217
+ if (!verified) return
218
+ const existing = session.events.get(verified.nodeHash)
219
+ if (existing && verified.ts < existing.at) return
220
+ session.events.set(verified.nodeHash, { p: verified.p, at: verified.ts })
221
+ scheduleRefresh()
222
+ }
223
+ catch { /* ignore malformed */ }
224
+ finally { session.inflight-- }
225
+ })()
226
+ }
227
+
228
+ /**
229
+ * 会话快照(逐出过期事件后 HT 估计)。
230
+ * @param {RelaySession} session 会话
231
+ * @returns {{ estimate: number, sampleSize: number, eventsInWindow: number }} 快照
232
+ */
233
+ function sessionSnapshot(session) {
234
+ const now = Date.now()
235
+ const events = []
236
+ for (const [hash, event] of session.events)
237
+ if (now - event.at > CENSUS_TTL_MS) session.events.delete(hash)
238
+ else events.push(event)
239
+ const { estimate, sampleSize } = estimatePopulation(events)
240
+ return { estimate, sampleSize, eventsInWindow: events.length }
241
+ }
242
+
243
+ /**
244
+ * 取人口估计最大的 relay 作为显示源,回调 onUpdate。
245
+ * @returns {void}
246
+ */
247
+ function refresh() {
248
+ let best = null
249
+ for (const session of sessions.values()) {
250
+ const snapshot = sessionSnapshot(session)
251
+ if (!best || snapshot.estimate > best.snapshot.estimate)
252
+ best = { url: session.url, snapshot }
253
+ }
254
+ const chosen = best || { url: relayUrls[0], snapshot: { estimate: 0, sampleSize: 0, eventsInWindow: 0 } }
255
+ onUpdate({
256
+ ...chosen.snapshot,
257
+ relayUrl: chosen.url,
258
+ relays: sessions.size,
259
+ })
260
+ }
261
+
262
+ /**
263
+ * 防抖调度刷新。
264
+ * @returns {void}
265
+ */
266
+ function scheduleRefresh() {
267
+ if (refreshDebounce || stopped) return
268
+ refreshDebounce = setTimeout(() => {
269
+ refreshDebounce = null
270
+ refresh()
271
+ }, REFRESH_DEBOUNCE_MS)
272
+ }
273
+
274
+ /**
275
+ * 单轮 NIP-66 发现:从引导集 + 现有会话拉 kind 30166 候选,加入监听(有界)。
276
+ * @returns {Promise<void>}
277
+ */
278
+ async function runDiscovery() {
279
+ /** @type {string[]} */
280
+ const candidates = []
281
+ /** @type {string[]} */
282
+ const sources = []
283
+ for (const url of [...nip66Bootstrap, ...sessions.keys()])
284
+ if (!sources.includes(url)) sources.push(url)
285
+ await Promise.allSettled(sources.map(url => collectNip66Candidates(url, candidate => {
286
+ if (!candidates.includes(candidate)) candidates.push(candidate)
287
+ })))
288
+ for (const url of candidates) {
289
+ if (stopped || sessions.size >= MAX_MONITOR_RELAYS) break
290
+ if (sessions.has(url)) continue
291
+ const session = createSession(url)
292
+ sessions.set(url, session)
293
+ connectSession(session)
294
+ }
295
+ }
296
+
297
+ /**
298
+ * 从单个中继收集 NIP-66 候选(d tag,wss)。
299
+ * @param {string} relayUrl 发现源中继
300
+ * @param {(candidate: string) => void} onCandidate 候选回调
301
+ * @returns {Promise<void>}
302
+ */
303
+ function collectNip66Candidates(relayUrl, onCandidate) {
304
+ return new Promise(resolve => {
305
+ let ws
306
+ let settled = false
307
+ /** @type {ReturnType<typeof setTimeout> | null} */
308
+ let quietTimer = null
309
+ /**
310
+ * @returns {void}
311
+ */
312
+ const finish = () => {
313
+ if (settled) return
314
+ settled = true
315
+ clearTimeout(timer)
316
+ if (quietTimer) clearTimeout(quietTimer)
317
+ try { ws?.close() } catch { /* ignore */ }
318
+ resolve()
319
+ }
320
+ /** 收到候选后 500ms 无新候选即结束(兼容不响应 EOSE 的中继)。 */
321
+ const armQuiet = () => {
322
+ if (quietTimer) clearTimeout(quietTimer)
323
+ quietTimer = setTimeout(finish, 500)
324
+ }
325
+ const timer = setTimeout(finish, CONNECT_TIMEOUT_MS)
326
+ try { ws = new WebSocket(relayUrl) } catch { finish(); return }
327
+ /**
328
+ * @param {MessageEvent} event WebSocket 消息事件
329
+ * @returns {void}
330
+ */
331
+ const onMessage = event => {
332
+ if (typeof event.data !== 'string') return
333
+ let parsed
334
+ try { parsed = JSON.parse(event.data) } catch { return }
335
+ if (parsed?.[0] === 'EOSE') { finish(); return }
336
+ if (parsed?.[0] !== 'EVENT' || parsed[2]?.kind !== NIP66_KIND) return
337
+ const d = (parsed[2]?.tags || []).find(tag => tag?.[0] === 'd')?.[1]
338
+ const url = normalizeRelayUrl(d)
339
+ if (url) {
340
+ onCandidate(url)
341
+ armQuiet()
342
+ }
343
+ }
344
+ ws.addEventListener('open', () => {
345
+ const subId = 'nip66-' + Math.random().toString(36).slice(2)
346
+ ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP66_KIND, 10166], limit: NIP66_REQ_LIMIT }]))
347
+ })
348
+ ws.addEventListener('message', onMessage)
349
+ ws.addEventListener('close', finish)
350
+ ws.addEventListener('error', () => { /* close follows error */ })
351
+ })
352
+ }
353
+
354
+ /**
355
+ * 启动周期 NIP-66 发现。
356
+ * @returns {void}
357
+ */
358
+ function startDiscovery() {
359
+ void runDiscovery().catch(() => { })
360
+ discoveryTimer = setInterval(() => { void runDiscovery().catch(() => { }) }, NIP66_REFRESH_MS)
361
+ discoveryTimer.unref?.()
362
+ }
363
+
364
+ /**
365
+ * 停止监控:关闭全部连接与定时器。
366
+ * @returns {void}
367
+ */
368
+ function stop() {
369
+ if (stopped) return
370
+ stopped = true
371
+ signal?.removeEventListener('abort', stop)
372
+ if (refreshTimer) clearInterval(refreshTimer)
373
+ refreshTimer = null
374
+ if (refreshDebounce) clearTimeout(refreshDebounce)
375
+ refreshDebounce = null
376
+ if (discoveryTimer) clearInterval(discoveryTimer)
377
+ discoveryTimer = null
378
+ for (const session of sessions.values()) {
379
+ if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
380
+ session.reconnectTimer = null
381
+ try { session.ws?.close() } catch { /* ignore */ }
382
+ session.ws = null
383
+ }
384
+ sessions.clear()
385
+ }
386
+
387
+ for (const url of relayUrls) {
388
+ const session = createSession(url)
389
+ sessions.set(url, session)
390
+ connectSession(session)
391
+ }
392
+ if (discover) startDiscovery()
393
+ refresh()
394
+ if (refreshMs > 0) {
395
+ refreshTimer = setInterval(refresh, refreshMs)
396
+ refreshTimer.unref?.()
397
+ }
398
+ if (signal) {
399
+ if (signal.aborted) { stop(); return { stop } }
400
+ signal.addEventListener('abort', stop, { once: true })
401
+ }
402
+ return { stop }
403
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * census 包验签(Untrusted ingress;Node 与浏览器共用,无 Node 依赖)。
3
+ *
4
+ * 签名体:{ nodeHash, nodePubKey, ts, p, sig },消息 `fount-census\0ts\0nodeHash\0p`,
5
+ * 用节点身份(Ed25519)签名,nodeHash = sha256(nodePubKey)。
6
+ */
7
+ import { hexToBytes } from '../../core/bytes_codec.mjs'
8
+ import { isHex64, isSignatureHex128 } from '../../core/hexIds.mjs'
9
+ import { pubKeyHash, verify } from '../../crypto/crypto.mjs'
10
+
11
+ import { CENSUS_MIN_P } from './census_math.mjs'
12
+
13
+ /** 事件/窗口存活时间(与 advert TTL 一致)。 */
14
+ export const CENSUS_TTL_MS = 10 * 60_000
15
+
16
+ /**
17
+ * @param {number} ts 时间戳(毫秒)
18
+ * @param {string} nodeHash 64 hex 节点 hash
19
+ * @param {number} p 包含概率
20
+ * @returns {Uint8Array} 待签名消息
21
+ */
22
+ export function buildCensusMessage(ts, nodeHash, p) {
23
+ return new TextEncoder().encode(`fount-census\0${ts}\0${nodeHash}\0${p}`)
24
+ }
25
+
26
+ /**
27
+ * 校验 census 包(Untrusted ingress):canonicalize + 验签 + 时间窗 + p 范围。
28
+ * @param {unknown} packet 原始 census 包
29
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
30
+ * @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
31
+ * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过返回 nodeHash/p/ts,否则 null
32
+ */
33
+ export async function verifyCensusPacket(packet, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
34
+ const nodeHash = isHex64(packet?.nodeHash)
35
+ const nodePubKey = isHex64(packet?.nodePubKey)
36
+ const sig = isSignatureHex128(packet?.sig)
37
+ const ts = Number(packet?.ts)
38
+ const p = Number(packet?.p)
39
+ if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
40
+ if (Math.abs(now - ts) > ttlMs) return null
41
+ if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
42
+ try {
43
+ if (pubKeyHash(hexToBytes(nodePubKey)) !== nodeHash) return null
44
+ }
45
+ catch {
46
+ return null
47
+ }
48
+ return await verify(hexToBytes(sig), buildCensusMessage(ts, nodeHash, p), hexToBytes(nodePubKey)) ? { nodeHash, p, ts } : null
49
+ }
50
+
51
+ /**
52
+ * 解 base64 content 字节并校验 census 包。
53
+ * @param {Uint8Array} bytes content 解码字节
54
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
55
+ * @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
56
+ * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过结果或 null
57
+ */
58
+ export async function verifyCensusBytes(bytes, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
59
+ try {
60
+ return await verifyCensusPacket(JSON.parse(new TextDecoder().decode(bytes)), now, ttlMs)
61
+ }
62
+ catch {
63
+ return null
64
+ }
65
+ }
@@ -41,3 +41,20 @@ export const NIP66_BOOTSTRAP_RELAYS = [
41
41
  'wss://relaypag.es',
42
42
  'wss://monitorlizard.nostr1.com',
43
43
  ]
44
+
45
+ /** 默认公共中继(source='public',永不淘汰;浏览器端人口统计监控亦以此为默认监听集)。 */
46
+ export const DEFAULT_RELAY_URLS = [
47
+ 'wss://relay.nostr.com',
48
+ 'wss://nos.lol',
49
+ 'wss://nostr.bitcoiner.social',
50
+ 'wss://nostr.mom',
51
+ 'wss://relay.snort.social',
52
+ 'wss://relay.primal.net',
53
+ ]
54
+
55
+ /** Nostr census 事件 kind(每节点每窗口至多一条,按 nodeHash 去重)。 */
56
+ export const NOSTR_CENSUS_KIND = 30789
57
+
58
+ /** census 订阅/发布标签:`t=fount` + `x=census`(subscribeNostrKind 以 rendezvousKey/tagX 匹配)。 */
59
+ export const CENSUS_TAG_FOUNT = 'fount'
60
+ export const CENSUS_TAG_X = 'census'
@@ -18,9 +18,9 @@ import {
18
18
  } from '../internal/signal_crypto.mjs'
19
19
  import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
20
20
 
21
+ import { DEFAULT_RELAY_URLS } from './constants.mjs'
21
22
  import { createNostrCensus } from './census.mjs'
22
23
  import {
23
- DEFAULT_RELAY_URLS,
24
24
  getListenRelays,
25
25
  getPeerRoute,
26
26
  getPoolByUrl,
@@ -15,6 +15,7 @@ import {
15
15
  } from '../../node/storage.mjs'
16
16
 
17
17
  import {
18
+ DEFAULT_RELAY_URLS,
18
19
  DEFAULT_RTT_MS,
19
20
  FAILURE_WEIGHT,
20
21
  LAST_GOOD_RELAYS_MAX,
@@ -29,13 +30,6 @@ import {
29
30
  } from './constants.mjs'
30
31
  import { connectRelay, dedupeRelayUrls, pinnedLookup } from './session.mjs'
31
32
 
32
- /** 默认公共中继(source='public',永不淘汰)。 */
33
- export const DEFAULT_RELAY_URLS = [
34
- 'wss://relay.damus.io',
35
- 'wss://nos.lol',
36
- 'wss://relay.primal.net',
37
- ]
38
-
39
33
  /** 持久化写盘节流延迟。 */
40
34
  const FLUSH_DELAY_MS = 2_000
41
35
  /** probe 单次连接超时。 */