@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.
- package/AGENTS.md +2 -2
- package/core/entity_id.mjs +6 -6
- package/core/entity_id_parse.mjs +7 -7
- package/core/hexIds.mjs +6 -18
- package/crypto/checkpoint_sign.mjs +4 -4
- package/dag/canonicalize_row.mjs +4 -7
- package/discovery/adverts.mjs +17 -10
- package/discovery/bt/index.mjs +11 -11
- package/discovery/bt/peer_hints.mjs +4 -7
- package/discovery/index.mjs +8 -12
- package/discovery/internal/signal_crypto.mjs +1 -2
- package/discovery/lan.mjs +7 -9
- package/discovery/lan_peer_hints.mjs +5 -8
- package/discovery/nostr/census.mjs +281 -0
- package/discovery/nostr/census_math.mjs +54 -0
- package/discovery/nostr/constants.mjs +43 -0
- package/discovery/nostr/index.mjs +612 -0
- package/discovery/nostr/relays.mjs +1071 -0
- package/discovery/nostr/selection.mjs +224 -0
- package/discovery/nostr/session.mjs +584 -0
- package/discovery/peer_clue.mjs +1 -4
- package/docs/nostr_relay_discovery.md +92 -0
- package/federation/entity_key_chain.mjs +10 -9
- package/federation/message_rate_limit.mjs +1 -3
- package/files/manifest/fetch.mjs +4 -6
- package/files/manifest/normalize.mjs +2 -2
- package/files/manifest/public.mjs +5 -5
- package/governance/branch.mjs +1 -2
- package/governance/join_pow.mjs +2 -4
- package/governance/owner_succession_ballot.mjs +5 -5
- package/index.mjs +11 -4
- package/infra/priority.mjs +1 -2
- package/link/handshake.mjs +153 -41
- package/link/pipe.mjs +1 -2
- package/link/providers/ble_gatt.mjs +2 -3
- package/link/providers/lan_tcp.mjs +2 -3
- package/link/providers/link_id_pipe.mjs +3 -3
- package/link/providers/{nostr.mjs → nostr/index.mjs} +22 -17
- package/mailbox/deliver_or_store.mjs +11 -12
- package/mailbox/prune.mjs +3 -4
- package/mailbox/rate.mjs +3 -2
- package/mailbox/store.mjs +6 -9
- package/node/denylist.mjs +25 -28
- package/node/feature_config.mjs +30 -0
- package/node/identity.mjs +6 -6
- package/node/instance.mjs +21 -0
- package/node/network.mjs +12 -12
- package/node/personal_block.mjs +4 -4
- package/node/reputation_store.mjs +13 -14
- package/node/reputation_sync.mjs +9 -10
- package/node/storage.mjs +23 -0
- package/package.json +10 -4
- package/pages/app.mjs +172 -0
- package/pages/index.html +36 -0
- package/reputation/engine.mjs +13 -16
- package/schemas/discovery.mjs +5 -6
- package/schemas/federation_pull.mjs +7 -7
- package/schemas/mailbox.mjs +3 -3
- package/schemas/part_query.mjs +5 -5
- package/timeline/verify_remote.mjs +7 -7
- package/transport/link_registry.mjs +16 -19
- package/transport/mesh_keepalive.mjs +8 -11
- package/transport/offer_answer.mjs +3 -3
- package/transport/peer_health.mjs +12 -15
- package/transport/runtime_bootstrap.mjs +39 -3
- package/trust_graph/send.mjs +3 -3
- 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
|
|
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
|
|
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 =
|
|
67
|
-
if (!
|
|
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) ===
|
|
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 =
|
|
93
|
-
if (!Number.isFinite(generation) || generation < 0 || !
|
|
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 =
|
|
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:
|
|
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
|
|
11
|
+
return event?.sender || ''
|
|
14
12
|
}
|
|
15
13
|
|
|
16
14
|
/**
|
package/files/manifest/fetch.mjs
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 =
|
|
44
|
-
if (!
|
|
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
|
|
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 =
|
|
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 =
|
|
77
|
-
const sigHex =
|
|
78
|
-
if (!
|
|
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
|
package/governance/branch.mjs
CHANGED
|
@@ -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 =
|
|
107
|
+
const { sender } = event
|
|
109
108
|
if (sender) score += Number(reputationBySender[sender] ?? 0)
|
|
110
109
|
}
|
|
111
110
|
return score
|
package/governance/join_pow.mjs
CHANGED
|
@@ -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
|
|
109
|
-
const
|
|
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
|
|
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:
|
|
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 =
|
|
46
|
-
const signatureHex = entry.signature
|
|
47
|
-
if (!
|
|
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()
|
package/infra/priority.mjs
CHANGED
|
@@ -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
|
-
|
|
40
|
+
nodeHash,
|
|
42
41
|
)
|
|
43
42
|
return Math.floor(score * PRIORITY_BOOST)
|
|
44
43
|
})
|
package/link/handshake.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
|
|
4
|
-
import { isHex64, isSignatureHex128
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
42
|
+
if (!isHex64(localNodeHash))
|
|
44
43
|
throw new Error('p2p: nodeHash must be 64 hex characters')
|
|
45
|
-
return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${
|
|
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 =
|
|
60
|
-
const nodePubKey =
|
|
61
|
-
const nonce =
|
|
62
|
-
if (!
|
|
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 =
|
|
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 =
|
|
96
|
-
const nodePubKey =
|
|
97
|
-
const nonce =
|
|
98
|
-
if (!
|
|
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 =
|
|
118
|
+
const signatureHex = isSignatureHex128(auth?.sig)
|
|
120
119
|
const binding = normalizeLinkBinding(remoteBinding)
|
|
121
|
-
if (!
|
|
122
|
-
const normalizedNonce =
|
|
123
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
164
|
-
const nodePubKey =
|
|
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
|
-
|
|
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>}
|
|
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 =
|
|
197
|
-
if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !
|
|
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 (
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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()
|