@steve02081504/fount-p2p 0.0.35 → 0.0.37

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 (67) hide show
  1. package/AGENTS.md +2 -2
  2. package/core/entity_id.mjs +6 -6
  3. package/core/entity_id_parse.mjs +7 -7
  4. package/core/hexIds.mjs +6 -18
  5. package/crypto/checkpoint_sign.mjs +4 -4
  6. package/dag/canonicalize_row.mjs +4 -7
  7. package/discovery/adverts.mjs +17 -10
  8. package/discovery/bt/index.mjs +11 -11
  9. package/discovery/bt/peer_hints.mjs +4 -7
  10. package/discovery/index.mjs +8 -12
  11. package/discovery/internal/signal_crypto.mjs +1 -2
  12. package/discovery/lan.mjs +7 -9
  13. package/discovery/lan_peer_hints.mjs +5 -8
  14. package/discovery/nostr/census.mjs +281 -0
  15. package/discovery/nostr/census_math.mjs +54 -0
  16. package/discovery/nostr/constants.mjs +43 -0
  17. package/discovery/nostr/index.mjs +612 -0
  18. package/discovery/nostr/relays.mjs +1071 -0
  19. package/discovery/nostr/selection.mjs +224 -0
  20. package/discovery/nostr/session.mjs +584 -0
  21. package/discovery/peer_clue.mjs +1 -4
  22. package/docs/nostr_relay_discovery.md +92 -0
  23. package/federation/entity_key_chain.mjs +10 -9
  24. package/federation/message_rate_limit.mjs +1 -3
  25. package/files/manifest/fetch.mjs +4 -6
  26. package/files/manifest/normalize.mjs +2 -2
  27. package/files/manifest/public.mjs +5 -5
  28. package/governance/branch.mjs +1 -2
  29. package/governance/join_pow.mjs +2 -4
  30. package/governance/owner_succession_ballot.mjs +5 -5
  31. package/index.mjs +11 -4
  32. package/infra/priority.mjs +1 -2
  33. package/link/handshake.mjs +153 -41
  34. package/link/pipe.mjs +1 -2
  35. package/link/providers/ble_gatt.mjs +2 -3
  36. package/link/providers/lan_tcp.mjs +2 -3
  37. package/link/providers/link_id_pipe.mjs +3 -3
  38. package/link/providers/{nostr.mjs → nostr/index.mjs} +22 -17
  39. package/mailbox/deliver_or_store.mjs +11 -12
  40. package/mailbox/prune.mjs +3 -4
  41. package/mailbox/rate.mjs +3 -2
  42. package/mailbox/store.mjs +6 -9
  43. package/node/denylist.mjs +25 -28
  44. package/node/feature_config.mjs +30 -0
  45. package/node/identity.mjs +6 -6
  46. package/node/instance.mjs +21 -0
  47. package/node/network.mjs +12 -12
  48. package/node/personal_block.mjs +4 -4
  49. package/node/reputation_store.mjs +13 -14
  50. package/node/reputation_sync.mjs +9 -10
  51. package/node/storage.mjs +23 -0
  52. package/package.json +10 -4
  53. package/pages/app.mjs +172 -0
  54. package/pages/index.html +36 -0
  55. package/reputation/engine.mjs +13 -16
  56. package/schemas/discovery.mjs +5 -6
  57. package/schemas/federation_pull.mjs +7 -7
  58. package/schemas/mailbox.mjs +3 -3
  59. package/schemas/part_query.mjs +5 -5
  60. package/timeline/verify_remote.mjs +7 -7
  61. package/transport/link_registry.mjs +16 -19
  62. package/transport/mesh_keepalive.mjs +8 -11
  63. package/transport/offer_answer.mjs +3 -3
  64. package/transport/peer_health.mjs +12 -15
  65. package/transport/runtime_bootstrap.mjs +39 -3
  66. package/trust_graph/send.mjs +3 -3
  67. package/discovery/nostr.mjs +0 -1074
@@ -5,7 +5,7 @@ import { Buffer } from 'node:buffer'
5
5
 
6
6
  import { canonicalStringify } from '../core/canonical_json.mjs'
7
7
  import { hashFromPubKeyHex } from '../core/entity_id.mjs'
8
- import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
8
+ import { isHex64 } from '../core/hexIds.mjs'
9
9
 
10
10
  /**
11
11
  * entity 密钥撤销签名的域分隔前缀。
@@ -28,9 +28,10 @@ export const ENTITY_KEY_REVOKE_DOMAIN = 'fount-entity-key-revoke'
28
28
  * @returns {EntityKeyHistoryEntry[]} 创世链
29
29
  */
30
30
  export function createGenesisKeyHistory(activePubKeyHex, validFrom = Date.now()) {
31
+ if (!activePubKeyHex) return []
31
32
  return [{
32
33
  generation: 0,
33
- activePubKeyHex: normalizeHex64(activePubKeyHex),
34
+ activePubKeyHex,
34
35
  attestedBy: 'recovery',
35
36
  validFrom,
36
37
  }]
@@ -63,8 +64,8 @@ export function isActiveGenerationRevoked(keyHistory, generation) {
63
64
  * @returns {boolean} sender 是否为未吊销的活跃钥
64
65
  */
65
66
  export function isValidActiveSender(keyHistory, senderPubKeyHash) {
66
- const sender = normalizeHex64(senderPubKeyHash)
67
- if (!isHex64(sender)) return false
67
+ const sender = isHex64(senderPubKeyHash)
68
+ if (!sender) return false
68
69
  for (const entry of keyHistory || []) {
69
70
  if (isActiveGenerationRevoked(keyHistory, entry.generation)) continue
70
71
  if (hashFromPubKeyHex(entry.activePubKeyHex) === sender)
@@ -79,7 +80,7 @@ export function isValidActiveSender(keyHistory, senderPubKeyHash) {
79
80
  * @returns {boolean} 是否为 recovery 钥签名
80
81
  */
81
82
  export function isRecoverySender(recoveryPubKeyHex, senderPubKeyHash) {
82
- return hashFromPubKeyHex(recoveryPubKeyHex) === normalizeHex64(senderPubKeyHash)
83
+ return hashFromPubKeyHex(recoveryPubKeyHex) === senderPubKeyHash
83
84
  }
84
85
 
85
86
  /**
@@ -89,8 +90,8 @@ export function isRecoverySender(recoveryPubKeyHex, senderPubKeyHash) {
89
90
  */
90
91
  export function reduceEntityKeyRotate(state, event) {
91
92
  const generation = Number(event.content?.generation)
92
- const activePubKeyHex = normalizeHex64(event.content?.activePubKeyHex || '')
93
- if (!Number.isFinite(generation) || generation < 0 || !isHex64(activePubKeyHex))
93
+ const activePubKeyHex = isHex64(event.content?.activePubKeyHex || '')
94
+ if (!Number.isFinite(generation) || generation < 0 || !activePubKeyHex)
94
95
  return state
95
96
  state.entityKeyHistory = state.entityKeyHistory || []
96
97
  if (state.entityKeyHistory.some(row => row.generation === generation))
@@ -111,7 +112,7 @@ export function reduceEntityKeyRotate(state, event) {
111
112
  */
112
113
  export function reduceEntityKeyRevoke(state, event) {
113
114
  const newGeneration = Number(event.content?.newGeneration)
114
- const activePubKeyHex = normalizeHex64(event.content?.activePubKeyHex || '')
115
+ const activePubKeyHex = event.content?.activePubKeyHex
115
116
  const revokeGenerations = (event.content?.revokeGenerations || [])
116
117
  .map(Number).filter(Number.isFinite)
117
118
  if (!Number.isFinite(newGeneration) || newGeneration < 0 || !isHex64(activePubKeyHex))
@@ -163,7 +164,7 @@ export function entityKeyRevokeSignBytes(revokeBody) {
163
164
  const body = {
164
165
  revokeGenerations: (revokeBody.revokeGenerations || []).map(Number),
165
166
  newGeneration: Number(revokeBody.newGeneration),
166
- activePubKeyHex: normalizeHex64(revokeBody.activePubKeyHex || ''),
167
+ activePubKeyHex: revokeBody.activePubKeyHex || '',
167
168
  entityHash: String(revokeBody.entityHash || ''),
168
169
  }
169
170
  return Buffer.from(`${ENTITY_KEY_REVOKE_DOMAIN}\0${canonicalStringify(body)}`, 'utf8')
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * 群级消息限速纯函数(无 I/O,供单测与 governance 复用)。
3
3
  */
4
- import { normalizeHex64 } from '../core/hexIds.mjs'
5
-
6
4
  /**
7
5
  * @param {object} event DAG 事件
8
6
  * @returns {string} 限速实体键
@@ -10,7 +8,7 @@ import { normalizeHex64 } from '../core/hexIds.mjs'
10
8
  export function messageRateEntityKey(event) {
11
9
  const charId = String(event?.charId || '')
12
10
  if (charId) return `char:${charId}`
13
- return normalizeHex64(event?.sender)
11
+ return event?.sender || ''
14
12
  }
15
13
 
16
14
  /**
@@ -71,12 +71,10 @@ export async function fetchPublicManifest(context) {
71
71
  if (!shared) return hasLocalPublic ? local : null
72
72
 
73
73
  if (hasLocalPublic) {
74
- if (wantCache)
75
- void shared.then(async result => {
76
- if (result && shouldPreferIncomingPublicManifest(local, result))
77
- await cachePublicManifest(ownerEntityHash, logicalPath, result)
78
- })
79
-
74
+ if (wantCache) void shared.then(async result => {
75
+ if (result && shouldPreferIncomingPublicManifest(local, result))
76
+ await cachePublicManifest(ownerEntityHash, logicalPath, result)
77
+ })
80
78
  return local
81
79
  }
82
80
 
@@ -40,8 +40,8 @@ const TRANSFER_TYPES = new Set(['public', 'file-master-key-wrap', 'vault-wrap',
40
40
  */
41
41
  export function normalizeFileManifest(input) {
42
42
  if (!input || typeof input !== 'object') return null
43
- const ownerEntityHash = String(input.ownerEntityHash || '')
44
- if (!isEntityHash128(ownerEntityHash)) return null
43
+ const ownerEntityHash = isEntityHash128(input.ownerEntityHash)
44
+ if (!ownerEntityHash) return null
45
45
  const logicalPath = String(input.logicalPath || '').replace(/^\/+/, '').replace(/\\/g, '/')
46
46
  if (!logicalPath) return null
47
47
  const ceMode = String(input.ceMode || 'convergent')
@@ -3,7 +3,7 @@ import { Buffer } from 'node:buffer'
3
3
  import { canonicalStringify } from '../../core/canonical_json.mjs'
4
4
  import { hashFromPubKeyHex, parseEntityHash } from '../../core/entity_id.mjs'
5
5
  import { assertSafeEvfsLogicalPath } from '../../core/evfs_logical_path.mjs'
6
- import { isHex64, isSignatureHex128, normalizeHex64 } from '../../core/hexIds.mjs'
6
+ import { isHex64, isSignatureHex128 } from '../../core/hexIds.mjs'
7
7
  import { sign, verify } from '../../crypto/crypto.mjs'
8
8
  import { putFileManifest, saveFileManifest } from '../evfs.mjs'
9
9
 
@@ -39,7 +39,7 @@ export function publicManifestSignBytes(fields) {
39
39
  * @returns {Promise<import('./normalize.mjs').FileManifest>} 带 publicSig 的清单
40
40
  */
41
41
  export async function attachPublicManifestSig(manifest, publishedAt, entitySecretKey, entityPubKeyHex) {
42
- const pubKeyHex = normalizeHex64(entityPubKeyHex)
42
+ const pubKeyHex = entityPubKeyHex
43
43
  const message = publicManifestSignBytes({
44
44
  ownerEntityHash: manifest.ownerEntityHash,
45
45
  logicalPath: manifest.logicalPath,
@@ -73,9 +73,9 @@ export async function verifySignedPublicManifest(input) {
73
73
  const publicSig = input?.meta?.publicSig
74
74
  if (!publicSig || typeof publicSig !== 'object') return null
75
75
  const publishedAt = Number(publicSig.publishedAt) || 0
76
- const pubKeyHex = normalizeHex64(publicSig.pubKeyHex)
77
- const sigHex = String(publicSig.sigHex || '')
78
- if (!isHex64(pubKeyHex) || !isSignatureHex128(sigHex) || publishedAt <= 0) return null
76
+ const pubKeyHex = isHex64(publicSig.pubKeyHex)
77
+ const sigHex = isSignatureHex128(publicSig.sigHex)
78
+ if (!pubKeyHex || !sigHex || publishedAt <= 0) return null
79
79
 
80
80
  const parsed = parseEntityHash(manifest.ownerEntityHash)
81
81
  if (!parsed) return null
@@ -1,4 +1,3 @@
1
- import { normalizeHex64 } from '../core/hexIds.mjs'
2
1
  import { sortedPrevEventIds } from '../dag/index.mjs'
3
2
  import { getGovernanceAuthzTypes } from '../registries/event_type.mjs'
4
3
 
@@ -105,7 +104,7 @@ function governanceAuthzScoreForTip(tipId, byId, reputationBySender) {
105
104
  for (const eventId of ancestorClosureFromTip(tipId, byId)) {
106
105
  const event = byId.get(eventId)
107
106
  if (!event || !getGovernanceAuthzTypes().has(event.type)) continue
108
- const sender = normalizeHex64(event.sender)
107
+ const { sender } = event
109
108
  if (sender) score += Number(reputationBySender[sender] ?? 0)
110
109
  }
111
110
  return score
@@ -3,8 +3,6 @@
3
3
  */
4
4
  import { createHash } from 'node:crypto'
5
5
 
6
- import { normalizeHex64 } from '../core/hexIds.mjs'
7
-
8
6
  import admissionTunables from './tunables.json' with { type: 'json' }
9
7
 
10
8
  /** 默认 epoch 窗口(1 小时) */
@@ -105,8 +103,8 @@ export function verifyJoinPow(powSolution, options) {
105
103
  const anchorRef = String(powSolution.anchorRef ?? '')
106
104
  const { nonce } = powSolution
107
105
  const epoch = Number(powSolution.epoch)
108
- const joinerNodeHash = normalizeHex64(powSolution.joinerNodeHash ?? options.senderNodeHash)
109
- const senderNodeHash = normalizeHex64(options.senderNodeHash)
106
+ const { senderNodeHash } = options
107
+ const joinerNodeHash = powSolution.joinerNodeHash ?? senderNodeHash
110
108
 
111
109
  if (!anchorRef || nonce == null || !Number.isFinite(epoch)) return { ok: false, achievedBits: 0 }
112
110
  if (!joinerNodeHash || joinerNodeHash !== senderNodeHash) return { ok: false, achievedBits: 0 }
@@ -4,7 +4,7 @@
4
4
  import { Buffer } from 'node:buffer'
5
5
 
6
6
  import { canonicalStringify } from '../core/canonical_json.mjs'
7
- import { isHex64, isSignatureHex128, normalizeHex64 } from '../core/hexIds.mjs'
7
+ import { isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
8
8
  import { pubKeyHash, verify } from '../crypto/crypto.mjs'
9
9
 
10
10
  const BALLOT_DOMAIN = 'fount-owner-succession'
@@ -15,7 +15,7 @@ const BALLOT_DOMAIN = 'fount-owner-succession'
15
15
  */
16
16
  export function ownerSuccessionBallotSignBytes(ballot) {
17
17
  const body = {
18
- proposedOwnerPubKeyHash: normalizeHex64(ballot.proposedOwnerPubKeyHash),
18
+ proposedOwnerPubKeyHash: ballot.proposedOwnerPubKeyHash,
19
19
  groupId: ballot.groupId,
20
20
  ballotId: ballot.ballotId,
21
21
  }
@@ -42,9 +42,9 @@ export async function verifyOwnerSuccessionThreshold(ballot, adminPubKeyHashes,
42
42
  let valid = 0
43
43
 
44
44
  for (const entry of ballot.adminSignatures || []) {
45
- const pubKeyHex = normalizeHex64(entry.pubKeyHex)
46
- const signatureHex = entry.signature
47
- if (!isHex64(pubKeyHex) || !isSignatureHex128(signatureHex)) continue
45
+ const pubKeyHex = isHex64(entry.pubKeyHex)
46
+ const signatureHex = isSignatureHex128(entry.signature)
47
+ if (!pubKeyHex || !signatureHex) continue
48
48
 
49
49
  const hash = pubKeyHash(Buffer.from(pubKeyHex, 'hex'))
50
50
  if (!admins.has(hash) || seen.has(hash)) continue
package/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  * Federation P2P 门面:fount 网络引导与房间/发现入口。
3
3
  */
4
4
  import { registerDiscoveryProvider } from './discovery/index.mjs'
5
+ import { getNodePopulationEstimate } from './discovery/nostr/census.mjs'
5
6
  import {
6
7
  isInfraRunning,
7
8
  setInfraPriority,
@@ -13,9 +14,11 @@ import { ensureNodeDefaults, getNodeHash } from './node/identity.mjs'
13
14
  import {
14
15
  closeNode,
15
16
  getNodeDir,
17
+ getP2PFeatures,
16
18
  initNode,
17
19
  isNodeInitialized,
18
20
  setNodeLogger,
21
+ setP2PFeatures,
19
22
  setSignalingRuntimeConfig,
20
23
  } from './node/instance.mjs'
21
24
  import { setConnectivityDebug } from './node/log.mjs'
@@ -73,6 +76,8 @@ export {
73
76
  getLinkRegistry,
74
77
  getNodeDir,
75
78
  getNodeHash,
79
+ getNodePopulationEstimate,
80
+ getP2PFeatures,
76
81
  getPeerHealth,
77
82
  getReputationExportAllowlist,
78
83
  getReputationLocks,
@@ -94,6 +99,7 @@ export {
94
99
  setConnectivityDebug,
95
100
  setInfraPriority,
96
101
  setNodeLogger,
102
+ setP2PFeatures,
97
103
  setReputationExportAllowlist,
98
104
  setReputationTable,
99
105
  setRoutingProfile,
@@ -105,20 +111,21 @@ export {
105
111
  }
106
112
 
107
113
  /**
108
- * @param {{ nodeDir?: string, entityStore?: import('./node/entity_store.mjs').EntityStore, logger?: object | null, signaling?: import('./node/signaling_config.mjs').SignalingRuntimeConfig }} [options] - 首次 init 时的节点选项
114
+ * @param {{ nodeDir?: string, entityStore?: import('./node/entity_store.mjs').EntityStore, logger?: object | null, signaling?: import('./node/signaling_config.mjs').SignalingRuntimeConfig, features?: Record<string, boolean> }} [options] - 首次 init 时的节点选项
109
115
  * @returns {Promise<void>}
110
116
  */
111
117
  export async function startNode(options = {}) {
112
118
  if (!isNodeInitialized()) {
113
- const { nodeDir, entityStore, logger, signaling, ...rest } = options
119
+ const { nodeDir, entityStore, logger, signaling, features, ...rest } = options
114
120
  if (Object.keys(rest).length)
115
121
  throw new Error('p2p: startNode unknown options')
116
122
  initNode({ nodeDir, entityStore })
117
123
  if (logger !== undefined) setNodeLogger(logger)
118
124
  if (signaling !== undefined) setSignalingRuntimeConfig(signaling)
125
+ if (features !== undefined) setP2PFeatures(features)
119
126
  }
120
- else if (options?.nodeDir || options?.entityStore || options?.logger !== undefined || options?.signaling)
121
- throw new Error('p2p: startNode options ignored after initNode — use setNodeLogger / setSignalingRuntimeConfig')
127
+ else if (options?.nodeDir || options?.entityStore || options?.logger !== undefined || options?.signaling || options?.features)
128
+ throw new Error('p2p: startNode options ignored after initNode — use setNodeLogger / setSignalingRuntimeConfig / setP2PFeatures')
122
129
 
123
130
  ensureNodeDefaults()
124
131
  await getLinkRegistry().ensureRuntime()
@@ -1,4 +1,3 @@
1
- import { normalizeHex64 } from '../core/hexIds.mjs'
2
1
  import { loadReputation } from '../node/reputation_store.mjs'
3
2
  import { pickNodeScoreFromReputation } from '../reputation/pick_score.mjs'
4
3
  import { getLinkRegistry } from '../transport/link_registry.mjs'
@@ -38,7 +37,7 @@ export function applyPriorityToRegistry() {
38
37
  registry.setPriorityWeightFunction(nodeHash => {
39
38
  const score = pickNodeScoreFromReputation(
40
39
  loadReputation(),
41
- normalizeHex64(nodeHash) || nodeHash,
40
+ nodeHash,
42
41
  )
43
42
  return Math.floor(score * PRIORITY_BOOST)
44
43
  })
@@ -1,11 +1,14 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
- import { isHex64, isSignatureHex128, normalizeHex64 } from '../core/hexIds.mjs'
4
+ import { isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
5
5
  import { normalizeTcpPort } from '../core/tcp_port.mjs'
6
6
  import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
7
7
  import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
8
+ import { MAX_ADVERT_LISTEN_RELAYS, MAX_ADVERT_RELAY_POOL, MAX_RTT_MS } from '../discovery/nostr/constants.mjs'
9
+ import { normalizeNostrRelayUrl } from '../discovery/nostr/relays.mjs'
8
10
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
11
+ import { nodeDebug } from '../node/log.mjs'
9
12
 
10
13
  import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
11
14
 
@@ -20,9 +23,7 @@ export const LINK_HANDSHAKE_DOMAIN = 'fount-link'
20
23
  * @returns {string | null} 规范化 binding,无效时 null
21
24
  */
22
25
  export function normalizeLinkBinding(value) {
23
- const dtls = normalizeDtlsFingerprint(value)
24
- if (dtls) return dtls
25
- return isHex64(value)
26
+ return normalizeDtlsFingerprint(value) || isHex64(value)
26
27
  }
27
28
 
28
29
  /**
@@ -33,16 +34,14 @@ export function normalizeLinkBinding(value) {
33
34
  * @returns {Uint8Array} 待签名消息字节
34
35
  */
35
36
  export function buildAuthMessage(peerNonce, localBinding, localNodeHash) {
36
- const nonce = normalizeHex64(peerNonce)
37
37
  const binding = normalizeLinkBinding(localBinding)
38
- const nodeHash = normalizeHex64(localNodeHash)
39
- if (!/^[\da-f]{64}$/u.test(nonce))
38
+ if (!/^[\da-f]{64}$/u.test(peerNonce))
40
39
  throw new Error('p2p: auth nonce must be 64 hex characters')
41
40
  if (!binding)
42
41
  throw new Error('p2p: link binding missing or invalid')
43
- if (!isHex64(nodeHash))
42
+ if (!isHex64(localNodeHash))
44
43
  throw new Error('p2p: nodeHash must be 64 hex characters')
45
- return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${binding}\0${nodeHash}`, 'utf8')
44
+ return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${peerNonce}\0${binding}\0${localNodeHash}`, 'utf8')
46
45
  }
47
46
 
48
47
  /**
@@ -56,10 +55,10 @@ export function buildHello(options = {}) {
56
55
  const derived = keyPairFromSeed(Buffer.from(ensureNodeSeed(), 'hex'))
57
56
  publicKey = derived.publicKey
58
57
  }
59
- const nodeHash = normalizeHex64(options.nodeHash || getNodeHash())
60
- const nodePubKey = normalizeHex64(options.nodePubKey || Buffer.from(publicKey).toString('hex'))
61
- const nonce = normalizeHex64(options.nonce || randomBytes(32).toString('hex'))
62
- if (!isHex64(nodeHash) || !isHex64(nodePubKey) || !isHex64(nonce))
58
+ const nodeHash = isHex64(options.nodeHash || getNodeHash())
59
+ const nodePubKey = isHex64(options.nodePubKey || Buffer.from(publicKey).toString('hex'))
60
+ const nonce = isHex64(options.nonce || randomBytes(32).toString('hex'))
61
+ if (!nodeHash || !nodePubKey || !nonce)
63
62
  throw new Error('p2p: invalid hello fields')
64
63
  if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
65
64
  throw new Error('p2p: hello nodePubKey does not match nodeHash')
@@ -78,7 +77,7 @@ export async function buildAuth(peerNonce, localBinding, options = {}) {
78
77
  ? Buffer.from(options.secretKey)
79
78
  : Buffer.from(ensureNodeSeed(), 'hex')
80
79
  const { publicKey, secretKey } = keyPairFromSeed(seed)
81
- const nodeHash = normalizeHex64(options.nodeHash || pubKeyHash(publicKey))
80
+ const nodeHash = options.nodeHash || pubKeyHash(publicKey)
82
81
  if (nodeHash !== pubKeyHash(publicKey))
83
82
  throw new Error('p2p: auth nodeHash does not match secretKey')
84
83
  const message = buildAuthMessage(peerNonce, localBinding, nodeHash)
@@ -92,10 +91,10 @@ export async function buildAuth(peerNonce, localBinding, options = {}) {
92
91
  * @returns {{ nodeHash: string, nodePubKey: string, nonce: string } | null} 规范化 hello 或 null
93
92
  */
94
93
  export function parseHello(hello) {
95
- const nodeHash = normalizeHex64(hello?.nodeHash)
96
- const nodePubKey = normalizeHex64(hello?.nodePubKey)
97
- const nonce = normalizeHex64(hello?.nonce)
98
- if (!isHex64(nodeHash) || !isHex64(nodePubKey) || !isHex64(nonce)) return null
94
+ const nodeHash = isHex64(hello?.nodeHash)
95
+ const nodePubKey = isHex64(hello?.nodePubKey)
96
+ const nonce = isHex64(hello?.nonce)
97
+ if (!nodeHash || !nodePubKey || !nonce) return null
99
98
  try {
100
99
  if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash) return null
101
100
  }
@@ -116,11 +115,11 @@ export function parseHello(hello) {
116
115
  export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
117
116
  const parsedHello = parseHello(hello)
118
117
  if (!parsedHello) return null
119
- const signatureHex = String(auth?.sig ?? '')
118
+ const signatureHex = isSignatureHex128(auth?.sig)
120
119
  const binding = normalizeLinkBinding(remoteBinding)
121
- if (!isSignatureHex128(signatureHex) || !binding) return null
122
- const normalizedNonce = normalizeHex64(expectedNonce)
123
- if (!isHex64(normalizedNonce)) return null
120
+ if (!signatureHex || !binding) return null
121
+ const normalizedNonce = isHex64(expectedNonce)
122
+ if (!normalizedNonce) return null
124
123
  const message = buildAuthMessage(normalizedNonce, binding, parsedHello.nodeHash)
125
124
  const ok = await verify(
126
125
  Buffer.from(signatureHex, 'hex'),
@@ -137,38 +136,148 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
137
136
  * @param {string} nodeHash 节点 nodeHash
138
137
  * @param {number | null} [tcpPort=null] 可选 LAN TCP 监听端口(签入消息)
139
138
  * @param {unknown} [lanHosts=null] 可选 LAN IPv4 列表(签入消息)
139
+ * @param {string | null} [relayBlobHex=null] 可选规范化 relay 字段 hex 段(签入消息)
140
140
  * @returns {Uint8Array} 待签名消息字节
141
141
  */
142
- export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null, lanHosts = null) {
143
- const base = `fount-advert\0${rendezvousKey}\0${ts}\0${normalizeHex64(nodeHash)}`
142
+ export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null, lanHosts = null, relayBlobHex = null) {
143
+ let message = `fount-advert\0${rendezvousKey}\0${ts}\0${nodeHash}`
144
144
  const port = normalizeTcpPort(tcpPort)
145
- let message = port ? `${base}\0${port}` : base
145
+ if (port) message += `\0${port}`
146
146
  const hosts = normalizeLanHosts(lanHosts)
147
147
  if (hosts.length) message += `\0${hosts.join(',')}`
148
+ if (relayBlobHex) message += `\0relays:${relayBlobHex}`
148
149
  return Buffer.from(message, 'utf8')
149
150
  }
150
151
 
151
152
  /**
152
- * 构造带签名的 discovery advert(tcpPort / lanHosts 一并签入消息)。
153
+ * 规范化并裁剪入站(不可信)advert 携带的 relay 字段:无效项丢弃并记审计日志,不抛错。
154
+ * - pool:url 经 normalize 有效且 rttMs∈[0,MAX_RTT_MS],取整、去重、裁剪前 MAX_ADVERT_RELAY_POOL。
155
+ * - listen:url normalize 有效、去重、裁剪前 MAX_ADVERT_LISTEN_RELAYS。
156
+ * - 每个丢弃项记审计日志(含原始值),不静默。
157
+ * @param {unknown} rawPool 原始 pool([{url, rttMs}])
158
+ * @param {unknown} rawListen 原始 listen([url])
159
+ * @returns {{ pool: Array<{ url: string, rtt: number }>, listen: string[] }} 规范化结果
160
+ */
161
+ export function sanitizeAdvertRelayFields(rawPool, rawListen) {
162
+ /** @type {Array<{ url: string, rtt: number }>} */
163
+ const pool = []
164
+ /** @type {Set<string>} */
165
+ const seenPool = new Set()
166
+ if (Array.isArray(rawPool)) for (const item of rawPool) {
167
+ const url = normalizeNostrRelayUrl(item?.url)
168
+ if (!url) {
169
+ nodeDebug('invalidRelayUrl', { url: String(item?.url), reason: 'advert-pool-invalid-url' })
170
+ continue
171
+ }
172
+ const rtt = Number(item?.rttMs ?? item?.rtt)
173
+ if (!Number.isFinite(rtt) || rtt < 0 || rtt > MAX_RTT_MS) {
174
+ nodeDebug('invalidRelayUrl', { url, reason: 'advert-pool-invalid-rtt', rttMs: item?.rttMs ?? item?.rtt })
175
+ continue
176
+ }
177
+ if (seenPool.has(url)) continue
178
+ seenPool.add(url)
179
+ pool.push({ url, rtt: Math.round(rtt) })
180
+ if (pool.length >= MAX_ADVERT_RELAY_POOL) break
181
+ }
182
+
183
+ /** @type {string[]} */
184
+ const listen = []
185
+ /** @type {Set<string>} */
186
+ const seenListen = new Set()
187
+ if (Array.isArray(rawListen)) for (const raw of rawListen) {
188
+ const url = normalizeNostrRelayUrl(raw)
189
+ if (!url) {
190
+ nodeDebug('invalidRelayUrl', { url: String(raw), reason: 'advert-listen-invalid-url' })
191
+ continue
192
+ }
193
+ if (seenListen.has(url)) continue
194
+ seenListen.add(url)
195
+ listen.push(url)
196
+ if (listen.length >= MAX_ADVERT_LISTEN_RELAYS) break
197
+ }
198
+
199
+ return { pool, listen }
200
+ }
201
+
202
+ /**
203
+ * 构建规范化 relay 字段的 canonical blob(pool/listen 各按 url 排序 → `{p,l}` JSON → UTF-8 → hex)。
204
+ * @param {Array<{ url: string, rtt: number }>} pool 规范化 pool
205
+ * @param {string[]} listen 规范化 listen
206
+ * @returns {string} hex 编码 blob
207
+ */
208
+ export function canonicalAdvertRelayBlob(pool, listen) {
209
+ return Buffer.from(JSON.stringify({
210
+ p: [...pool].sort((a, b) => a.url < b.url ? -1 : a.url > b.url ? 1 : 0),
211
+ l: [...listen].sort(),
212
+ }), 'utf8').toString('hex')
213
+ }
214
+
215
+ /**
216
+ * 严格规范化本机(出站)提供的 relay 字段:无效数据立即抛错,不静默丢弃。
217
+ * 本地调用方应提供已规范化字段;入站校验仍走 lenient 的 sanitizeAdvertRelayFields。
218
+ * @param {unknown} rawPool 原始 pool([{url, rttMs}])
219
+ * @param {unknown} rawListen 原始 listen([url])
220
+ * @returns {{ pool: Array<{ url: string, rtt: number }>, listen: string[] }} 规范化结果
221
+ */
222
+ function normalizeLocalRelayFields(rawPool, rawListen) {
223
+ /** @type {Array<{ url: string, rtt: number }>} */
224
+ const pool = []
225
+ /** @type {Set<string>} */
226
+ const seenPool = new Set()
227
+ if (rawPool != null && !Array.isArray(rawPool)) throw new Error('p2p: advert pool invalid type')
228
+ if (Array.isArray(rawPool)) for (const item of rawPool) {
229
+ const url = normalizeNostrRelayUrl(item?.url)
230
+ if (!url) throw new Error('p2p: advert pool invalid url')
231
+ const rtt = Number(item?.rttMs ?? item?.rtt)
232
+ if (!Number.isFinite(rtt) || rtt < 0 || rtt > MAX_RTT_MS)
233
+ throw new Error('p2p: advert pool invalid rtt')
234
+ if (seenPool.has(url)) continue
235
+ seenPool.add(url)
236
+ pool.push({ url, rtt: Math.round(rtt) })
237
+ if (pool.length >= MAX_ADVERT_RELAY_POOL) break
238
+ }
239
+
240
+ /** @type {string[]} */
241
+ const listen = []
242
+ /** @type {Set<string>} */
243
+ const seenListen = new Set()
244
+ if (rawListen != null && !Array.isArray(rawListen)) throw new Error('p2p: advert listen invalid type')
245
+ if (Array.isArray(rawListen)) for (const raw of rawListen) {
246
+ const url = normalizeNostrRelayUrl(raw)
247
+ if (!url) throw new Error('p2p: advert listen invalid url')
248
+ if (seenListen.has(url)) continue
249
+ seenListen.add(url)
250
+ listen.push(url)
251
+ if (listen.length >= MAX_ADVERT_LISTEN_RELAYS) break
252
+ }
253
+
254
+ return { pool, listen }
255
+ }
256
+
257
+ /**
258
+ * 构造带签名的 discovery advert(tcpPort / lanHosts / relay 字段一并签入消息)。
153
259
  * @param {string} rendezvousKey discovery 内部汇合键
154
260
  * @param {number} [ts=Date.now()] 时间戳(毫秒)
155
- * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown } | null} [options] 签名身份;可选 LAN 监听端口与本机 IPv4 列表
156
- * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number, lanHosts?: string[] }>} 签名 advert
261
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown, nostrRelayPool?: unknown, listenNostrRelays?: unknown } | null} [options] 签名身份;可选 LAN 监听端口、本机 IPv4 列表与 relay 字段
262
+ * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number, lanHosts?: string[], nostrRelayPool?: Array<{ url: string, rtt: number }>, listenNostrRelays?: string[] }>} 签名 advert
157
263
  */
158
264
  export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options = null) {
159
265
  const seed = options?.secretKey
160
266
  ? Buffer.from(options.secretKey)
161
267
  : Buffer.from(ensureNodeSeed(), 'hex')
162
268
  const { publicKey, secretKey } = keyPairFromSeed(seed)
163
- const nodeHash = normalizeHex64(options?.nodeHash || pubKeyHash(publicKey))
164
- const nodePubKey = normalizeHex64(options?.nodePubKey || Buffer.from(publicKey).toString('hex'))
269
+ const nodeHash = options?.nodeHash || pubKeyHash(publicKey)
270
+ const nodePubKey = options?.nodePubKey || Buffer.from(publicKey).toString('hex')
165
271
  if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
166
272
  throw new Error('p2p: advert nodePubKey does not match nodeHash')
167
273
  const tcpPort = normalizeTcpPort(options?.tcpPort)
168
274
  if (options?.tcpPort && !tcpPort)
169
275
  throw new Error('p2p: advert tcpPort invalid')
170
276
  const lanHosts = normalizeLanHosts(options?.lanHosts)
171
- const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort, lanHosts)
277
+ // 出站字段要求已规范化,无效数据直接抛错(入站路径才用 lenient sanitize)。
278
+ const normalized = normalizeLocalRelayFields(options?.nostrRelayPool, options?.listenNostrRelays)
279
+ const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort, lanHosts,
280
+ canonicalAdvertRelayBlob(normalized.pool, normalized.listen))
172
281
  const sig = await sign(message, secretKey)
173
282
  const advert = {
174
283
  nodeHash,
@@ -178,28 +287,31 @@ export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options
178
287
  }
179
288
  if (tcpPort) advert.tcpPort = tcpPort
180
289
  if (lanHosts.length) advert.lanHosts = lanHosts
290
+ if (normalized.pool.length) advert.nostrRelayPool = normalized.pool
291
+ if (normalized.listen.length) advert.listenNostrRelays = normalized.listen
181
292
  return advert
182
293
  }
183
294
 
184
295
  /**
185
- * 验证 discovery advert 签名与时间戳,成功返回发布者 nodeHash。
296
+ * 验证 discovery advert 签名与时间戳,成功返回发布者 nodeHash 与规范化后的 relay 字段。
186
297
  * @param {string} rendezvousKey 期望的汇合键
187
298
  * @param {unknown} advert 原始 advert 载荷
188
299
  * @param {number} [now=Date.now()] 当前时间(毫秒)
189
300
  * @param {number} [maxSkewMs=10 * 60_000] 允许的最大时钟偏差(毫秒)
190
- * @returns {Promise<string | null>} 验证通过的 nodeHash,失败返回 null
301
+ * @returns {Promise<{ nodeHash: string, relayPool: Array<{ url: string, rtt: number }>, listenRelays: string[] } | null>} 验证通过返回 nodeHash 与规范化 relay 字段,失败返回 null
191
302
  */
192
303
  export async function verifySignedAdvert(rendezvousKey, advert, now = Date.now(), maxSkewMs = 10 * 60_000) {
193
304
  const parsedHello = parseHello({ nodeHash: advert?.nodeHash, nodePubKey: advert?.nodePubKey, nonce: '0'.repeat(64) })
194
305
  if (!parsedHello) return null
195
306
  const ts = Number(advert?.ts)
196
- const sig = String(advert?.sig ?? '')
197
- if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !isSignatureHex128(sig)) return null
198
- const hasTcpPortField = !!advert?.tcpPort
307
+ const sig = isSignatureHex128(advert?.sig)
308
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !sig) return null
199
309
  const tcpPort = normalizeTcpPort(advert?.tcpPort)
200
- if (hasTcpPortField && !tcpPort) return null
201
- const lanHosts = normalizeLanHosts(advert?.lanHosts)
202
- const message = buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort, lanHosts)
203
- const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
204
- return ok ? parsedHello.nodeHash : null
310
+ if (advert?.tcpPort != null && !tcpPort) return null
311
+ const sanitized = sanitizeAdvertRelayFields(advert?.nostrRelayPool, advert?.listenNostrRelays)
312
+ if (!await verify(Buffer.from(sig, 'hex'),
313
+ buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort, normalizeLanHosts(advert?.lanHosts),
314
+ canonicalAdvertRelayBlob(sanitized.pool, sanitized.listen)),
315
+ Buffer.from(parsedHello.nodePubKey, 'hex'))) return null
316
+ return { nodeHash: parsedHello.nodeHash, relayPool: sanitized.pool, listenRelays: sanitized.listen }
205
317
  }
package/link/pipe.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  import { toBytes } from '../core/bytes_codec.mjs'
2
- import { normalizeHex64 } from '../core/hexIds.mjs'
3
2
  import { ms } from '../utils/duration.mjs'
4
3
  import { emitSafe } from '../utils/emit_safe.mjs'
5
4
  import { createLruMap } from '../utils/lru.mjs'
@@ -92,7 +91,7 @@ export function createLinkPipe(options) {
92
91
  const heartbeatMs = Number(options.heartbeatMs) || ms('15s')
93
92
  const idleTimeoutMs = Number(options.idleTimeoutMs) || ms('45s')
94
93
  const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
95
- const targetNodeHash = normalizeHex64(options.nodeHash || '')
94
+ const targetNodeHash = options.nodeHash || ''
96
95
  let closed = false
97
96
  let ready = false
98
97
  let closeReason = 'closed'
@@ -1,7 +1,6 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
- import { normalizeHex64 } from '../../core/hexIds.mjs'
5
4
  import { getBtPeerHint } from '../../discovery/bt/peer_hints.mjs'
6
5
  import { canUseBluetoothRuntime, loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from '../../discovery/bt/runtime.mjs'
7
6
  import { asLinkHandle } from '../pipe.mjs'
@@ -55,7 +54,7 @@ async function openGattPipe(options) {
55
54
 
56
55
  if (options.initiator)
57
56
  await Promise.resolve(options.write(Buffer.from(
58
- buildLinkOpen(normalizeHex64(options.linkId), options.localIdentity?.nodeHash),
57
+ buildLinkOpen(options.linkId, options.localIdentity?.nodeHash),
59
58
  'utf8',
60
59
  )))
61
60
 
@@ -69,7 +68,7 @@ async function openGattPipe(options) {
69
68
  * @returns {Promise<import('./index.mjs').LinkHandle>} 已就绪的 link
70
69
  */
71
70
  async function dialBleGatt(options) {
72
- const remoteNodeHash = normalizeHex64(options.nodeHash)
71
+ const remoteNodeHash = options.nodeHash
73
72
  const hint = getBtPeerHint(remoteNodeHash)
74
73
  if (!hint) throw new Error('p2p: ble_gatt no peer hint')
75
74
  const noble = await loadNoble()