libp2r2p 0.11.8 → 0.11.10
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/content-key/services/iykc-proof.js +77 -34
- package/package.json +1 -1
- package/private-channel/helpers/lookup-warning.js +29 -0
- package/private-channel/index.js +21 -2
- package/private-messenger/file/index.js +39 -5
- package/private-messenger/session/README.md +10 -3
- package/private-messenger/session/helpers/signer.js +11 -1
- package/private-messenger/session/index.js +21 -2
|
@@ -2,11 +2,13 @@ import { CONTENT_KEY_KIND, parseContentKeyEvent } from '../event/index.js'
|
|
|
2
2
|
import { pickRelaysForPubkeys, getRelaysByPubkey, relayPool } from '../../relay/index.js'
|
|
3
3
|
|
|
4
4
|
const QUERY_CACHE_MS = 40 * 60 * 1000
|
|
5
|
+
const NEGATIVE_CACHE_MS = 5 * 60 * 1000
|
|
5
6
|
const IYKC_CACHE_MAX_ITEMS = 10000
|
|
6
7
|
const HEX_PUBKEY = /^[0-9a-f]{64}$/i
|
|
7
8
|
const contentKeysByPubkey = Object.create(null)
|
|
8
9
|
const iykcCacheTimersByPubkey = Object.create(null)
|
|
9
10
|
const iykcCacheAddedAtByPubkey = Object.create(null)
|
|
11
|
+
const inFlightByPubkey = new Map()
|
|
10
12
|
|
|
11
13
|
const getEvents = (...args) => relayPool.getEvents(...args)
|
|
12
14
|
|
|
@@ -68,42 +70,30 @@ export function clearContentKeyCache () {
|
|
|
68
70
|
for (const key of Object.keys(contentKeysByPubkey)) delete contentKeysByPubkey[key]
|
|
69
71
|
for (const key of Object.keys(iykcCacheTimersByPubkey)) delete iykcCacheTimersByPubkey[key]
|
|
70
72
|
for (const key of Object.keys(iykcCacheAddedAtByPubkey)) delete iykcCacheAddedAtByPubkey[key]
|
|
73
|
+
inFlightByPubkey.clear()
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
cacheMs = QUERY_CACHE_MS
|
|
77
|
-
} = {}) {
|
|
78
|
-
const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
|
|
79
|
-
if (!pubkeyList.length) return {}
|
|
80
|
-
|
|
81
|
-
const out = {}
|
|
82
|
-
const missingPubkeys = []
|
|
83
|
-
for (const pubkey of pubkeyList) {
|
|
84
|
-
if (!hasCachedKey(contentKeysByPubkey, pubkey)) {
|
|
85
|
-
missingPubkeys.push(pubkey)
|
|
86
|
-
continue
|
|
87
|
-
}
|
|
88
|
-
const cached = cloneContentKey(contentKeysByPubkey[pubkey])
|
|
89
|
-
if (cached) out[pubkey] = cached
|
|
90
|
-
}
|
|
91
|
-
if (!missingPubkeys.length) return out
|
|
92
|
-
|
|
93
|
-
const relaysByPubkey = await _getRelaysByPubkey(missingPubkeys, { _getEvents, cacheMs })
|
|
94
|
-
const relayToAuthors = pickRelaysForPubkeys(missingPubkeys, relaysByPubkey)
|
|
76
|
+
async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs }) {
|
|
77
|
+
const relaysByPubkey = await _getRelaysByPubkey(pubkeyList, { _getEvents, cacheMs })
|
|
78
|
+
const relayToAuthors = pickRelaysForPubkeys(pubkeyList, relaysByPubkey)
|
|
95
79
|
const eventGroups = await Promise.all(
|
|
96
80
|
[...relayToAuthors.entries()]
|
|
97
81
|
.map(async ([relay, authors]) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
82
|
+
try {
|
|
83
|
+
const { result } = await _getEvents({
|
|
84
|
+
kinds: [CONTENT_KEY_KIND],
|
|
85
|
+
authors,
|
|
86
|
+
limit: authors.length
|
|
87
|
+
}, [relay], {
|
|
88
|
+
timeout: 5000,
|
|
89
|
+
timeoutAfterFirstEose: null
|
|
90
|
+
})
|
|
91
|
+
return result.map(({ event }) => event)
|
|
92
|
+
} catch {
|
|
93
|
+
// Discovery is best-effort: a failing relay group must not fail an
|
|
94
|
+
// otherwise publishable message.
|
|
95
|
+
return []
|
|
96
|
+
}
|
|
107
97
|
})
|
|
108
98
|
)
|
|
109
99
|
|
|
@@ -116,14 +106,67 @@ export async function getIykcProofs (pubkeys, {
|
|
|
116
106
|
}
|
|
117
107
|
}
|
|
118
108
|
|
|
119
|
-
|
|
109
|
+
const out = {}
|
|
110
|
+
for (const pubkey of pubkeyList) {
|
|
120
111
|
const entry = latestByPubkey[pubkey]
|
|
121
112
|
const proof = entry
|
|
122
113
|
? { iykcPubkey: entry.iykcPubkey, iykcProof: entry.iykcProof }
|
|
123
114
|
: null
|
|
124
|
-
|
|
125
|
-
|
|
115
|
+
if (proof) {
|
|
116
|
+
setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
|
|
117
|
+
out[pubkey] = proof
|
|
118
|
+
} else if (negativeCacheMs > 0) {
|
|
119
|
+
// Misses expire quickly so a peer that publishes a key right after a
|
|
120
|
+
// prefetch is picked up without waiting for the positive cache window.
|
|
121
|
+
setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, null, negativeCacheMs)
|
|
122
|
+
}
|
|
126
123
|
}
|
|
127
124
|
pruneCache(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, IYKC_CACHE_MAX_ITEMS)
|
|
128
125
|
return out
|
|
129
126
|
}
|
|
127
|
+
|
|
128
|
+
export async function getIykcProofs (pubkeys, {
|
|
129
|
+
_getEvents = getEvents,
|
|
130
|
+
_getRelaysByPubkey = getRelaysByPubkey,
|
|
131
|
+
cacheMs = QUERY_CACHE_MS,
|
|
132
|
+
negativeCacheMs = NEGATIVE_CACHE_MS
|
|
133
|
+
} = {}) {
|
|
134
|
+
const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
|
|
135
|
+
if (!pubkeyList.length) return {}
|
|
136
|
+
|
|
137
|
+
const out = {}
|
|
138
|
+
const waiting = []
|
|
139
|
+
const toQuery = []
|
|
140
|
+
for (const pubkey of pubkeyList) {
|
|
141
|
+
if (hasCachedKey(contentKeysByPubkey, pubkey)) {
|
|
142
|
+
const cached = cloneContentKey(contentKeysByPubkey[pubkey])
|
|
143
|
+
if (cached) out[pubkey] = cached
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
const pending = inFlightByPubkey.get(pubkey)
|
|
147
|
+
if (pending) waiting.push([pubkey, pending])
|
|
148
|
+
else toQuery.push(pubkey)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (toQuery.length) {
|
|
152
|
+
const query = queryContentKeyProofs(toQuery, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs })
|
|
153
|
+
for (const pubkey of toQuery) inFlightByPubkey.set(pubkey, query)
|
|
154
|
+
try {
|
|
155
|
+
Object.assign(out, await query)
|
|
156
|
+
} finally {
|
|
157
|
+
for (const pubkey of toQuery) {
|
|
158
|
+
if (inFlightByPubkey.get(pubkey) === query) inFlightByPubkey.delete(pubkey)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [pubkey, pending] of waiting) {
|
|
164
|
+
const found = await pending.catch(() => null)
|
|
165
|
+
if (found?.[pubkey]) out[pubkey] = found[pubkey]
|
|
166
|
+
else {
|
|
167
|
+
const cached = cloneContentKey(contentKeysByPubkey[pubkey])
|
|
168
|
+
if (cached) out[pubkey] = cached
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const LOOKUP_WARNING_INTERVAL_MS = 30_000
|
|
2
|
+
|
|
3
|
+
// Best-effort discovery can fail on every message while a relay or the
|
|
4
|
+
// network is unavailable. Keep the first failure visible, then debounce
|
|
5
|
+
// repeats and report how many were suppressed on the next log.
|
|
6
|
+
export function createLookupWarningLogger ({
|
|
7
|
+
intervalMs = LOOKUP_WARNING_INTERVAL_MS,
|
|
8
|
+
label = 'private-messenger content-key lookup failed',
|
|
9
|
+
warn = (...args) => console.warn(...args),
|
|
10
|
+
now = Date.now
|
|
11
|
+
} = {}) {
|
|
12
|
+
let warnedAt = null
|
|
13
|
+
let suppressed = 0
|
|
14
|
+
return (error, context = '') => {
|
|
15
|
+
const current = now()
|
|
16
|
+
if (warnedAt !== null && current - warnedAt < intervalMs) {
|
|
17
|
+
suppressed++
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
const count = suppressed
|
|
21
|
+
warnedAt = current
|
|
22
|
+
suppressed = 0
|
|
23
|
+
warn(
|
|
24
|
+
`${label}${context ? ` (${context})` : ''}${count ? ` [${count} repeated failures suppressed]` : ''}`,
|
|
25
|
+
error?.message ?? error
|
|
26
|
+
)
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
}
|
package/private-channel/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
|
|
|
9
9
|
import { incompleteFetchError } from './helpers/fetch-error.js'
|
|
10
10
|
import { readHistory } from './helpers/history.js'
|
|
11
11
|
import { subscriptionError } from './helpers/subscription-error.js'
|
|
12
|
+
import { createLookupWarningLogger } from './helpers/lookup-warning.js'
|
|
12
13
|
import * as nip44v3 from '../nip44-v3/index.js'
|
|
13
14
|
import { relayPool } from '../relay/index.js'
|
|
14
15
|
import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
|
|
@@ -35,6 +36,7 @@ const HEX_SECKEY = /^[0-9a-f]{64}$/i
|
|
|
35
36
|
const HEX_PUBKEY = /^[0-9a-f]{64}$/i
|
|
36
37
|
const encoder = new TextEncoder()
|
|
37
38
|
const decoder = new TextDecoder()
|
|
39
|
+
const warnLookupFailure = createLookupWarningLogger()
|
|
38
40
|
const NIP44_V3_SCOPE = ''
|
|
39
41
|
|
|
40
42
|
const sendToRelays = (event, relays) => relayPool.sendEvent(event, relays)
|
|
@@ -110,7 +112,19 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
110
112
|
const useDoubleDh = typeof senderSigner.nip44EncryptDoubleDH === 'function'
|
|
111
113
|
const channelPubkey = await privateChannelSigner.getPublicKey()
|
|
112
114
|
const channelReaderPubkey = privateChannelReaderPubkey || channelPubkey
|
|
113
|
-
|
|
115
|
+
let receiverContentKeys = {}
|
|
116
|
+
if (useDoubleDh) {
|
|
117
|
+
const receiversWithoutContentKeys = receiverPubkeysWithoutContentKeys(receivers)
|
|
118
|
+
try {
|
|
119
|
+
receiverContentKeys = await _getIykcProofs(receiversWithoutContentKeys) || {}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// Content-key discovery is best-effort; without a proof the row still
|
|
122
|
+
// works in sender-content mode instead of failing the send. Keep
|
|
123
|
+
// failures visible without logging on every message during an outage.
|
|
124
|
+
warnLookupFailure(error, `recipients: ${receiversWithoutContentKeys.length}`)
|
|
125
|
+
receiverContentKeys = {}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
114
128
|
const preparedRows = await prepareEnvelopeRows({
|
|
115
129
|
senderSigner,
|
|
116
130
|
imkcSigner: useDoubleDh ? imkcSigner : null,
|
|
@@ -390,13 +404,18 @@ async function unwrapRecipientEnvelope ({ payloadCiphertext, envelope, receiverS
|
|
|
390
404
|
if (envelope.iykcPubkey) {
|
|
391
405
|
assertValidEnvelopeIykcProof(envelope)
|
|
392
406
|
}
|
|
407
|
+
// In self-encryption the advertised sender content key is also our own.
|
|
408
|
+
// Rows written without a content-key lookup (useContentKeys: false) carry
|
|
409
|
+
// no iykc pubkey, so hand imkc to the signer as the receiver's own key.
|
|
410
|
+
const ownContentPubkey = envelope.iykcPubkey ||
|
|
411
|
+
(senderPubkey && receiverPubkey && senderPubkey === receiverPubkey ? imkcPubkey : '')
|
|
393
412
|
messageSeckey = base64ToText(await receiverSigner.nip44DecryptDoubleDH(
|
|
394
413
|
senderPubkey,
|
|
395
414
|
ROUTER_KIND,
|
|
396
415
|
rowScope,
|
|
397
416
|
envelope.ciphertext,
|
|
398
417
|
imkcPubkey,
|
|
399
|
-
|
|
418
|
+
ownContentPubkey
|
|
400
419
|
))
|
|
401
420
|
} else {
|
|
402
421
|
if (!receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
|
|
@@ -243,6 +243,16 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
243
243
|
const requestedSeeders = new Set()
|
|
244
244
|
const validSeeders = new Set([file.peerPubkey, ...messenger.recoverySeeders(file.controlChannelPubkey)])
|
|
245
245
|
let terminalError
|
|
246
|
+
let lastError = null
|
|
247
|
+
const reportError = error => {
|
|
248
|
+
lastError = {
|
|
249
|
+
name: error?.name || 'Error',
|
|
250
|
+
message: error?.message ?? String(error),
|
|
251
|
+
code: error?.code || '',
|
|
252
|
+
at: Date.now()
|
|
253
|
+
}
|
|
254
|
+
onError(error)
|
|
255
|
+
}
|
|
246
256
|
const fail = error => { terminalError ||= error }
|
|
247
257
|
async function accept (event, index, { save = true } = {}) {
|
|
248
258
|
signal.throwIfAborted()
|
|
@@ -297,7 +307,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
297
307
|
})
|
|
298
308
|
// Malformed remote records do not poison the ingestion chain. Operational
|
|
299
309
|
// persistence failures stop the job so completion cannot outrun storage.
|
|
300
|
-
ingest = task.catch(error => { if (error instanceof ValidationError)
|
|
310
|
+
ingest = task.catch(error => { if (error instanceof ValidationError) reportError(error); else if (!signal.aborted) fail(error) })
|
|
301
311
|
return ingest
|
|
302
312
|
}
|
|
303
313
|
try {
|
|
@@ -312,7 +322,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
312
322
|
if (total !== undefined && persisted.size === total) return
|
|
313
323
|
const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
|
|
314
324
|
const relays = await messenger.resolveWatchRelays(parent)
|
|
315
|
-
const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError }
|
|
325
|
+
const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError: reportError }
|
|
316
326
|
sub = _transport.subscribe({ ...options, liveOnly: true })
|
|
317
327
|
const stop = () => { sub?.close() }
|
|
318
328
|
signal.addEventListener('abort', stop, { once: true })
|
|
@@ -323,7 +333,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
323
333
|
const history = _transport.fetch({ ...options, signal: AbortSignal.any([signal, historyController.signal]), since: 0 }).catch(error => {
|
|
324
334
|
// Completion aborts the remaining history read in finally. Only its
|
|
325
335
|
// own cancellation is expected; genuine history failures stay visible.
|
|
326
|
-
if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason))
|
|
336
|
+
if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason)) reportError(error)
|
|
327
337
|
})
|
|
328
338
|
work.add(history); history.finally(() => work.delete(history))
|
|
329
339
|
const seeders = [...validSeeders].sort((a, b) => (successes.get(b) || 0) - (successes.get(a) || 0) || Math.random() - 0.5)
|
|
@@ -340,9 +350,33 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
340
350
|
requestedSeeders.add(peer)
|
|
341
351
|
requestRoundAt = Date.now() + _hedgeMs
|
|
342
352
|
// The request is on dm; its compact reply is on the file channel.
|
|
343
|
-
await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(
|
|
353
|
+
await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(reportError)
|
|
354
|
+
}
|
|
355
|
+
if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) {
|
|
356
|
+
const details = {
|
|
357
|
+
operation: 'file-download-stall',
|
|
358
|
+
ownerPubkey: messenger.userPubkey,
|
|
359
|
+
root: file.root,
|
|
360
|
+
peerPubkey: file.peerPubkey,
|
|
361
|
+
total,
|
|
362
|
+
persisted: persisted.size,
|
|
363
|
+
missingRanges: rangesFor(missing),
|
|
364
|
+
missingCount: total === undefined ? null : total - persisted.size,
|
|
365
|
+
seeders: [...validSeeders],
|
|
366
|
+
requestedSeeders: [...requestedSeeders],
|
|
367
|
+
lastProgressAt: new Date(lastProgress).toISOString(),
|
|
368
|
+
lastProgressAgoMs: Date.now() - lastProgress,
|
|
369
|
+
elapsedMs: Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs),
|
|
370
|
+
lastError
|
|
371
|
+
}
|
|
372
|
+
const error = new Error('FILE_DOWNLOAD_STALLED')
|
|
373
|
+
error.code = 'FILE_DOWNLOAD_STALLED'
|
|
374
|
+
error.operation = details.operation
|
|
375
|
+
error.elapsedMs = details.elapsedMs
|
|
376
|
+
error.details = details
|
|
377
|
+
console.warn('private-messenger file download stalled', details)
|
|
378
|
+
throw error
|
|
344
379
|
}
|
|
345
|
-
if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) throw new Error('FILE_DOWNLOAD_STALLED')
|
|
346
380
|
// Start a fresh batch when all positions of the previous batch arrived.
|
|
347
381
|
if (job.batch?.every(index => persisted.has(index))) { requestedSeeders.clear(); requestRoundAt = Date.now(); job.batch = null }
|
|
348
382
|
job.batch ||= missing
|
|
@@ -9,11 +9,18 @@ Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and eithe
|
|
|
9
9
|
Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
|
|
10
10
|
persistence; absent file storage uses the library cache. `mode` defaults to
|
|
11
11
|
`seeder`, `seedersForPeer(peer)` to `[peer]`, and `allowedKinds` to
|
|
12
|
-
`[5, 9, 1063, 34601]`.
|
|
12
|
+
`[5, 9, 1063, 34601]`. `useContentKeys` defaults to `true`: the coordinator looks
|
|
13
|
+
up recipients' published content-key proofs (kind 18716) while sending and falls
|
|
14
|
+
back to sender-content whenever a proof is missing or the lookup fails. Misses
|
|
15
|
+
are cached for 5 minutes (found proofs for 40), so a later prefetch picks up a
|
|
16
|
+
key published after an earlier miss. Set it to `false` when the signer owns that discovery. The DM context is `dm`; file contexts are `dm:media:<root>`.
|
|
13
17
|
`createMessengerSigner` adapts this byte signer to the transport's Base64 API.
|
|
14
18
|
|
|
15
|
-
Methods: `setPeers`, `setAvailable(boolean)`, `
|
|
16
|
-
`download`, `cancelDownload`, and `close`.
|
|
19
|
+
Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
|
|
20
|
+
`enqueue`, `retry`, `cancel`, `download`, `cancelDownload`, and `close`.
|
|
21
|
+
`prefetchContentKeys` warms the content-key lookup cache for the owner and the
|
|
22
|
+
given peers (default: current peers); it is best-effort and never rejects.
|
|
23
|
+
Callbacks are `onOutbox`, `onMedia`,
|
|
17
24
|
`onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
|
|
18
25
|
account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
|
|
19
26
|
|
|
@@ -3,11 +3,21 @@ import { base64ToBytes, bytesToBase64 } from '../../../base64/index.js'
|
|
|
3
3
|
// The library transport uses its Base64 signer contract. Conversion stays at
|
|
4
4
|
// this adapter; the injected launcher/vault channel always carries raw bytes.
|
|
5
5
|
export function messengerSigner (signer) {
|
|
6
|
-
|
|
6
|
+
const adapter = {
|
|
7
7
|
getPublicKey: () => signer.getPublicKey(),
|
|
8
8
|
getRelays: () => signer.getRelays(),
|
|
9
9
|
signEvent: event => signer.signEvent(event),
|
|
10
10
|
nip44v3Encrypt: (peer, kind, scope, text) => signer.nip44v3.encrypt(peer, kind, scope, base64ToBytes(text).buffer),
|
|
11
11
|
nip44v3Decrypt: async (peer, kind, scope, ciphertext) => bytesToBase64(new Uint8Array(await signer.nip44v3.decrypt(peer, kind, scope, ciphertext)))
|
|
12
12
|
}
|
|
13
|
+
// Double DH stays opt-in by capability: a signer without both methods keeps
|
|
14
|
+
// the identity-only fallback that the private channel selects by itself.
|
|
15
|
+
if (typeof signer?.nip44v3?.encryptDoubleDH === 'function' &&
|
|
16
|
+
typeof signer?.nip44v3?.decryptDoubleDH === 'function') {
|
|
17
|
+
adapter.nip44EncryptDoubleDH = (peer, kind, scope, text, peerContentPubkey = '') =>
|
|
18
|
+
signer.nip44v3.encryptDoubleDH(peer, kind, scope, base64ToBytes(text).buffer, peerContentPubkey)
|
|
19
|
+
adapter.nip44DecryptDoubleDH = async (peer, kind, scope, ciphertext, peerContentPubkey = '', ownContentPubkey = '') =>
|
|
20
|
+
bytesToBase64(new Uint8Array(await signer.nip44v3.decryptDoubleDH(peer, kind, scope, ciphertext, peerContentPubkey, ownContentPubkey)))
|
|
21
|
+
}
|
|
22
|
+
return adapter
|
|
13
23
|
}
|
|
@@ -3,6 +3,7 @@ import { createPrivateFileTransfer } from '../file/index.js'
|
|
|
3
3
|
import { createPrivateMessenger } from '../index.js'
|
|
4
4
|
import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
|
|
5
5
|
import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
|
|
6
|
+
import { getIykcProofs } from '../../content-key/index.js'
|
|
6
7
|
import { onOnline } from '../../network/index.js'
|
|
7
8
|
import { decodeFileMetadata } from '../../nip94/index.js'
|
|
8
9
|
import { createChatOutbox } from './helpers/work-storage.js'
|
|
@@ -18,7 +19,7 @@ export function wireEvent (value, owner) {
|
|
|
18
19
|
// Relay rejection text must not be interpreted as a local signer denial.
|
|
19
20
|
const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|BLOCKED|EXPIRED|NOT_IN_PERSONA/i.test(`${error?.code || ''} ${error?.message || ''}`)
|
|
20
21
|
|
|
21
|
-
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
22
|
+
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
22
23
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
23
24
|
const userSigner = messengerSigner(signer)
|
|
24
25
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
@@ -76,7 +77,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
76
77
|
if (closed || !available || version !== lifecycle) return
|
|
77
78
|
values.push(channel)
|
|
78
79
|
}
|
|
79
|
-
if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys
|
|
80
|
+
if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError })
|
|
80
81
|
if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
|
|
81
82
|
await messenger.update({ channels: values })
|
|
82
83
|
await messenger.resume('signer')
|
|
@@ -284,6 +285,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
284
285
|
}
|
|
285
286
|
return {
|
|
286
287
|
download, cancelDownload,
|
|
288
|
+
// Peers-based so callers can prefetch as soon as a chat route opens, before
|
|
289
|
+
// the shared-key channel exists. When group chats get real membership, add
|
|
290
|
+
// a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
|
|
291
|
+
// the channel's receivers and delegates here.
|
|
292
|
+
async prefetchContentKeys (values = [...peers]) {
|
|
293
|
+
if (closed || !useContentKeys) return {}
|
|
294
|
+
const requested = [...new Set([owner, ...(Array.isArray(values) ? values : [values])])]
|
|
295
|
+
.filter(value => typeof value === 'string' && value)
|
|
296
|
+
if (!requested.length) return {}
|
|
297
|
+
try {
|
|
298
|
+
return await _getIykcProofs(requested)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
// Prefetch only warms the lookup cache; callers must keep working with
|
|
301
|
+
// sender-content until a proof is available. Keep the failure visible.
|
|
302
|
+
console.warn('private-messenger content-key prefetch failed', error?.message ?? error)
|
|
303
|
+
return {}
|
|
304
|
+
}
|
|
305
|
+
},
|
|
287
306
|
async setPeers (values) {
|
|
288
307
|
peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
|
|
289
308
|
for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
|