libp2r2p 0.0.1
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 +20 -0
- package/README.md +129 -0
- package/base16/index.js +14 -0
- package/base64/index.js +29 -0
- package/content-key/event/index.js +85 -0
- package/content-key/index.js +1 -0
- package/content-key/services/iykc-proof.js +129 -0
- package/double-dh/index.js +166 -0
- package/ecdh/index.js +8 -0
- package/idb/index.js +64 -0
- package/idb-queue/index.js +793 -0
- package/index.js +21 -0
- package/key/index.js +139 -0
- package/network/index.js +68 -0
- package/nip44-v3/index.js +175 -0
- package/nip46/constants/index.js +5 -0
- package/nip46/helpers/frame.js +51 -0
- package/nip46/helpers/url.js +96 -0
- package/nip46/index.js +5 -0
- package/nip46/services/bunker-signer.js +49 -0
- package/nip46/services/client.js +252 -0
- package/nip46/services/server-session.js +225 -0
- package/nip46/services/transport.js +265 -0
- package/package.json +49 -0
- package/private-channel/constants/index.js +5 -0
- package/private-channel/helpers/chunk-size.js +61 -0
- package/private-channel/helpers/chunks.js +282 -0
- package/private-channel/helpers/event.js +68 -0
- package/private-channel/index.js +942 -0
- package/private-channel/services/received-chunks.js +398 -0
- package/private-message/index.js +672 -0
- package/private-messenger/constants/index.js +1 -0
- package/private-messenger/index.js +1431 -0
- package/private-messenger/recovery/index.js +316 -0
- package/relay/constants/index.js +18 -0
- package/relay/helpers/hll.js +62 -0
- package/relay/helpers/publish.js +100 -0
- package/relay/helpers/routing.js +36 -0
- package/relay/helpers/timer.js +4 -0
- package/relay/index.js +4 -0
- package/relay/services/query.js +224 -0
- package/relay/services/relay-connection.js +156 -0
- package/relay/services/relay-pool.js +908 -0
- package/temporary-storage/index.js +85 -0
- package/tests/base16.test.js +19 -0
- package/tests/base64.test.js +18 -0
- package/tests/content-key-event.test.js +48 -0
- package/tests/fixtures/nip44v3-vectors.json +1 -0
- package/tests/helpers/test-signer.js +185 -0
- package/tests/idb-queue.test.js +153 -0
- package/tests/idb.test.js +91 -0
- package/tests/nip44-v3.test.js +73 -0
- package/tests/nip46.test.js +668 -0
- package/tests/private-channel.test.js +1899 -0
- package/tests/private-message.test.js +460 -0
- package/tests/private-messenger.test.js +1715 -0
- package/tests/queries.test.js +101 -0
- package/tests/queue-parity.test.js +105 -0
- package/tests/relay-hll.test.js +32 -0
- package/tests/relay-pool.test.js +2067 -0
- package/tests/temporary-storage.test.js +89 -0
- package/tests/web-storage-queue.test.js +480 -0
- package/web-storage-queue/index.js +652 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { getPublicKey } from 'nostr-tools'
|
|
2
|
+
import { relayPool as defaultRelayPool } from '../../relay/index.js'
|
|
3
|
+
import { DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_AFTER_FIRST_EOSE } from '../constants/index.js'
|
|
4
|
+
import { createNip46Event, requestError } from '../helpers/frame.js'
|
|
5
|
+
|
|
6
|
+
const RETIRE_CONTEXT_AFTER_MS = 5000
|
|
7
|
+
const SEEN_EVENT_LIMIT = 500
|
|
8
|
+
|
|
9
|
+
function requestId () {
|
|
10
|
+
const bytes = new Uint8Array(8)
|
|
11
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
12
|
+
return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function requestExtension (value) {
|
|
16
|
+
if (value === undefined || value === null) return null
|
|
17
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('NIP46_REQUEST_EXTENSION_REQUIRED')
|
|
18
|
+
for (const key of ['id', 'method', 'params']) {
|
|
19
|
+
if (Object.hasOwn(value, key)) throw new Error(`NIP46_REQUEST_EXTENSION_CANNOT_SET_${key.toUpperCase()}`)
|
|
20
|
+
}
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function waitForNip46 (promise, { timeout = null, signal, label = 'NIP46_TIMEOUT' } = {}) {
|
|
25
|
+
if (signal?.aborted) return Promise.reject(new Error('Aborted'))
|
|
26
|
+
if (timeout === null && !signal) return Promise.resolve(promise)
|
|
27
|
+
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
let timer = null
|
|
30
|
+
let settled = false
|
|
31
|
+
const finish = (fn, value) => {
|
|
32
|
+
if (settled) return
|
|
33
|
+
settled = true
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
signal?.removeEventListener('abort', onAbort)
|
|
36
|
+
fn(value)
|
|
37
|
+
}
|
|
38
|
+
const onAbort = () => finish(reject, new Error('Aborted'))
|
|
39
|
+
|
|
40
|
+
if (timeout !== null) timer = setTimeout(() => finish(reject, new Error(label)), timeout)
|
|
41
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
42
|
+
Promise.resolve(promise).then(
|
|
43
|
+
value => finish(resolve, value),
|
|
44
|
+
error => finish(reject, error)
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function sameRelays (left, right) {
|
|
50
|
+
if (left.length !== right.length) return false
|
|
51
|
+
const values = new Set(left)
|
|
52
|
+
return right.every(value => values.has(value))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Owns the shared encrypted RPC mechanics while client/server classes enforce
|
|
56
|
+
// their different connection and authorization rules.
|
|
57
|
+
export class Nip46Transport {
|
|
58
|
+
#secretKey
|
|
59
|
+
#pubkey
|
|
60
|
+
#relayPool
|
|
61
|
+
#networkTimeout
|
|
62
|
+
#timeoutAfterFirstEose
|
|
63
|
+
#onError
|
|
64
|
+
#contexts = new Set()
|
|
65
|
+
#activeContext = null
|
|
66
|
+
#retireTimers = new Set()
|
|
67
|
+
#pending = new Map()
|
|
68
|
+
#seenEventIds = new Set()
|
|
69
|
+
#closed = false
|
|
70
|
+
|
|
71
|
+
constructor (secretKey, {
|
|
72
|
+
relayPool = defaultRelayPool,
|
|
73
|
+
networkTimeout = DEFAULT_TIMEOUT,
|
|
74
|
+
timeoutAfterFirstEose = DEFAULT_TIMEOUT_AFTER_FIRST_EOSE,
|
|
75
|
+
onError
|
|
76
|
+
} = {}) {
|
|
77
|
+
if (!(secretKey instanceof Uint8Array)) throw new Error('NIP46_SECRET_KEY_REQUIRED')
|
|
78
|
+
if (!relayPool?.getLiveEventsGenerator || !relayPool?.sendEvent) throw new Error('RELAY_POOL_REQUIRED')
|
|
79
|
+
this.#secretKey = secretKey
|
|
80
|
+
this.#pubkey = getPublicKey(secretKey)
|
|
81
|
+
this.#relayPool = relayPool
|
|
82
|
+
this.#networkTimeout = networkTimeout
|
|
83
|
+
this.#timeoutAfterFirstEose = timeoutAfterFirstEose
|
|
84
|
+
this.#onError = onError
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get pubkey () { return this.#pubkey }
|
|
88
|
+
get closed () { return this.#closed }
|
|
89
|
+
get activeContext () { return this.#activeContext }
|
|
90
|
+
get readyRelays () { return this.#activeContext?.stream.readyRelays || Object.freeze([]) }
|
|
91
|
+
|
|
92
|
+
openContext ({ filter, relays, onEvent }) {
|
|
93
|
+
if (this.#closed) throw new Error('NIP46_CLOSED')
|
|
94
|
+
const controller = new AbortController()
|
|
95
|
+
const stream = this.#relayPool.getLiveEventsGenerator(filter, relays, {
|
|
96
|
+
signal: controller.signal,
|
|
97
|
+
timeoutAfterFirstEose: this.#timeoutAfterFirstEose
|
|
98
|
+
})
|
|
99
|
+
const context = { controller, stream, relays: [...relays], consume: null }
|
|
100
|
+
this.#contexts.add(context)
|
|
101
|
+
context.consume = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
for await (const event of stream) {
|
|
104
|
+
Promise.resolve(onEvent(event)).catch(error => this.#reportError(error))
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!this.#closed && error?.message !== 'Aborted') this.#reportError(error)
|
|
108
|
+
}
|
|
109
|
+
})()
|
|
110
|
+
return context
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async awaitContextReady (context, { timeout = this.#networkTimeout, signal } = {}) {
|
|
114
|
+
const report = await waitForNip46(context.stream.ready, {
|
|
115
|
+
timeout,
|
|
116
|
+
signal,
|
|
117
|
+
label: 'NIP46_LISTENER_TIMEOUT'
|
|
118
|
+
})
|
|
119
|
+
if (!context.stream.readyRelays.length) {
|
|
120
|
+
throw requestError(report.errors?.[0]?.reason || 'NIP46_NO_READY_RELAYS')
|
|
121
|
+
}
|
|
122
|
+
return context.stream.readyRelays
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
activateContext (context, { retirePrevious = true } = {}) {
|
|
126
|
+
const previous = this.#activeContext
|
|
127
|
+
this.#activeContext = context
|
|
128
|
+
if (retirePrevious && previous && previous !== context) this.retireContext(previous)
|
|
129
|
+
return previous
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
retireContext (context, delay = RETIRE_CONTEXT_AFTER_MS) {
|
|
133
|
+
if (!context || !this.#contexts.has(context)) return
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
this.#retireTimers.delete(timer)
|
|
136
|
+
this.closeContext(context)
|
|
137
|
+
}, delay)
|
|
138
|
+
this.#retireTimers.add(timer)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async closeContext (context) {
|
|
142
|
+
if (!context || !this.#contexts.delete(context)) return
|
|
143
|
+
context.controller.abort()
|
|
144
|
+
try {
|
|
145
|
+
await context.consume
|
|
146
|
+
} catch {
|
|
147
|
+
// Unexpected stream failures are reported by the consume loop.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
isNewEvent (event) {
|
|
152
|
+
if (!event?.id) return true
|
|
153
|
+
if (this.#seenEventIds.has(event.id)) return false
|
|
154
|
+
if (this.#seenEventIds.size >= SEEN_EVENT_LIMIT) {
|
|
155
|
+
this.#seenEventIds.delete(this.#seenEventIds.values().next().value)
|
|
156
|
+
}
|
|
157
|
+
this.#seenEventIds.add(event.id)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async sendRequest (peerPubkey, method, params = [], {
|
|
162
|
+
timeout = null,
|
|
163
|
+
signal,
|
|
164
|
+
extension
|
|
165
|
+
} = {}) {
|
|
166
|
+
if (this.#closed) throw new Error('NIP46_CLOSED')
|
|
167
|
+
if (typeof method !== 'string' || !method) throw new Error('NIP46_METHOD_REQUIRED')
|
|
168
|
+
if (!Array.isArray(params) || !params.every(param => typeof param === 'string')) {
|
|
169
|
+
throw new Error('NIP46_PARAMS_REQUIRED')
|
|
170
|
+
}
|
|
171
|
+
if (signal?.aborted) throw new Error('Aborted')
|
|
172
|
+
const context = this.#activeContext
|
|
173
|
+
if (!context) throw new Error('NIP46_LISTENER_UNAVAILABLE')
|
|
174
|
+
await this.awaitContextReady(context, { signal })
|
|
175
|
+
|
|
176
|
+
const id = requestId()
|
|
177
|
+
const response = Promise.withResolvers()
|
|
178
|
+
const extra = requestExtension(extension)
|
|
179
|
+
this.#pending.set(id, { ...response, peerPubkey })
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await this.publish(peerPubkey, { id, method, params, ...(extra || {}) }, { context })
|
|
183
|
+
return await waitForNip46(response.promise, {
|
|
184
|
+
timeout,
|
|
185
|
+
signal,
|
|
186
|
+
label: 'NIP46_REQUEST_TIMEOUT'
|
|
187
|
+
})
|
|
188
|
+
} finally {
|
|
189
|
+
const pending = this.#pending.get(id)
|
|
190
|
+
if (pending?.promise === response.promise) this.#pending.delete(id)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
receiveResponse (peerPubkey, response, { onAuthUrl } = {}) {
|
|
195
|
+
if (!response || typeof response.id !== 'string') return false
|
|
196
|
+
const pending = this.#pending.get(response.id)
|
|
197
|
+
if (!pending || pending.peerPubkey !== peerPubkey) return false
|
|
198
|
+
|
|
199
|
+
if (response.result === 'auth_url') {
|
|
200
|
+
try {
|
|
201
|
+
onAuthUrl?.(typeof response.error === 'string' ? response.error : '')
|
|
202
|
+
} catch (error) {
|
|
203
|
+
this.#reportError(error)
|
|
204
|
+
}
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.#pending.delete(response.id)
|
|
209
|
+
if (Object.hasOwn(response, 'error') && response.error !== null && response.error !== undefined) {
|
|
210
|
+
pending.reject(requestError(response.error))
|
|
211
|
+
} else if (Object.hasOwn(response, 'result')) {
|
|
212
|
+
pending.resolve(response.result)
|
|
213
|
+
} else {
|
|
214
|
+
pending.reject(new Error('NIP46_INVALID_RESPONSE'))
|
|
215
|
+
}
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
reply (peerPubkey, id, result, error = null, options) {
|
|
220
|
+
return this.publish(peerPubkey, { id, result, error }, options)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async publish (peerPubkey, payload, { context = this.#activeContext } = {}) {
|
|
224
|
+
if (this.#closed) throw new Error('NIP46_CLOSED')
|
|
225
|
+
if (!context) throw new Error('NIP46_LISTENER_UNAVAILABLE')
|
|
226
|
+
await this.awaitContextReady(context)
|
|
227
|
+
const relays = context.stream.readyRelays
|
|
228
|
+
if (!relays.length) throw new Error('NIP46_NO_READY_RELAYS')
|
|
229
|
+
const event = createNip46Event({ secretKey: this.#secretKey, recipientPubkey: peerPubkey, payload })
|
|
230
|
+
const published = await this.#relayPool.sendEvent(event, relays, {
|
|
231
|
+
timeout: this.#networkTimeout,
|
|
232
|
+
timeoutUntilFirstFulfillment: null
|
|
233
|
+
})
|
|
234
|
+
if (!published.success) {
|
|
235
|
+
const report = await published.promise
|
|
236
|
+
throw requestError(report.errors?.[0]?.reason || 'NIP46_PUBLISH_FAILED')
|
|
237
|
+
}
|
|
238
|
+
return event
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async close () {
|
|
242
|
+
if (this.#closed) return
|
|
243
|
+
this.#closed = true
|
|
244
|
+
for (const timer of this.#retireTimers) clearTimeout(timer)
|
|
245
|
+
this.#retireTimers.clear()
|
|
246
|
+
const contexts = [...this.#contexts]
|
|
247
|
+
for (const context of contexts) context.controller.abort()
|
|
248
|
+
await Promise.all(contexts.map(context => this.closeContext(context)))
|
|
249
|
+
this.#activeContext = null
|
|
250
|
+
for (const pending of this.#pending.values()) pending.reject(new Error('NIP46_CLOSED'))
|
|
251
|
+
this.#pending.clear()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#reportError (error) {
|
|
255
|
+
try {
|
|
256
|
+
if (this.#onError) {
|
|
257
|
+
Promise.resolve(this.#onError(error)).catch(callbackError => {
|
|
258
|
+
console.error('NIP46 onError failed:', callbackError)
|
|
259
|
+
})
|
|
260
|
+
} else console.error('NIP46 stream failed:', error)
|
|
261
|
+
} catch (callbackError) {
|
|
262
|
+
console.error('NIP46 onError failed:', callbackError)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "libp2r2p",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Peer-to-relay-to-peer",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"p2r2p",
|
|
7
|
+
"p2p",
|
|
8
|
+
"nostr"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "arthurfranca",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test --experimental-test-module-mocks 'tests/**/*.test.js'",
|
|
16
|
+
"test:only": "node --test --test-only --experimental-test-module-mocks 'tests/**/*.test.js'"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./index.js",
|
|
20
|
+
"./base16": "./base16/index.js",
|
|
21
|
+
"./base64": "./base64/index.js",
|
|
22
|
+
"./content-key": "./content-key/index.js",
|
|
23
|
+
"./content-key/event": "./content-key/event/index.js",
|
|
24
|
+
"./double-dh": "./double-dh/index.js",
|
|
25
|
+
"./ecdh": "./ecdh/index.js",
|
|
26
|
+
"./idb": "./idb/index.js",
|
|
27
|
+
"./idb-queue": "./idb-queue/index.js",
|
|
28
|
+
"./key": "./key/index.js",
|
|
29
|
+
"./network": "./network/index.js",
|
|
30
|
+
"./nip44-v3": "./nip44-v3/index.js",
|
|
31
|
+
"./nip46": "./nip46/index.js",
|
|
32
|
+
"./private-channel": "./private-channel/index.js",
|
|
33
|
+
"./private-message": "./private-message/index.js",
|
|
34
|
+
"./private-messenger": "./private-messenger/index.js",
|
|
35
|
+
"./private-messenger/recovery": "./private-messenger/recovery/index.js",
|
|
36
|
+
"./relay": "./relay/index.js",
|
|
37
|
+
"./temporary-storage": "./temporary-storage/index.js",
|
|
38
|
+
"./web-storage-queue": "./web-storage-queue/index.js"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@noble/ciphers": "2.2.0",
|
|
42
|
+
"@noble/curves": "2.2.0",
|
|
43
|
+
"@noble/hashes": "2.2.0",
|
|
44
|
+
"nostr-tools": "2.23.5"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"fake-indexeddb": "6.2.5"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { MAX_EVENT_BYTES, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from '../constants/index.js'
|
|
2
|
+
import { eventByteLength } from './event.js'
|
|
3
|
+
import { payloadByteLength as nip44v3PayloadByteLength } from '../../nip44-v3/index.js'
|
|
4
|
+
|
|
5
|
+
const MAX_TIME_SECONDS = 9999999999
|
|
6
|
+
const MAX_CHUNK_TAG_VALUE = '9999999999'
|
|
7
|
+
const SAMPLE_PUBKEY = 'f'.repeat(64)
|
|
8
|
+
const SAMPLE_SIGNATURE = 'f'.repeat(128)
|
|
9
|
+
|
|
10
|
+
function base64EncodedByteLength (byteLength) {
|
|
11
|
+
return Math.ceil(byteLength / 3) * 4
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function routerPlaintextByteLengthForChunk (jsonlByteLength) {
|
|
15
|
+
return eventByteLength({
|
|
16
|
+
kind: ROUTER_KIND,
|
|
17
|
+
pubkey: SAMPLE_PUBKEY,
|
|
18
|
+
created_at: MAX_TIME_SECONDS,
|
|
19
|
+
tags: [['f', SAMPLE_PUBKEY], ['imkc', SAMPLE_PUBKEY, `${MAX_TIME_SECONDS}:${SAMPLE_SIGNATURE}`], ['c', MAX_CHUNK_TAG_VALUE, MAX_CHUNK_TAG_VALUE], ['r', SAMPLE_PUBKEY]],
|
|
20
|
+
content: 'A'.repeat(base64EncodedByteLength(jsonlByteLength)),
|
|
21
|
+
id: SAMPLE_PUBKEY,
|
|
22
|
+
sig: SAMPLE_SIGNATURE
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function outerEventByteLengthForChunk (jsonlByteLength) {
|
|
27
|
+
const contentByteLength = nip44v3PayloadByteLength(routerPlaintextByteLengthForChunk(jsonlByteLength), 0)
|
|
28
|
+
if (!Number.isFinite(contentByteLength)) return Infinity
|
|
29
|
+
|
|
30
|
+
return eventByteLength({
|
|
31
|
+
kind: PRIVATE_BROADCAST_KIND,
|
|
32
|
+
created_at: MAX_TIME_SECONDS,
|
|
33
|
+
// Reserve the deletion capability tag used by high-level private sends.
|
|
34
|
+
tags: [['s', SAMPLE_PUBKEY], ['expiration', String(MAX_TIME_SECONDS)]],
|
|
35
|
+
content: 'A'.repeat(contentByteLength),
|
|
36
|
+
pubkey: SAMPLE_PUBKEY,
|
|
37
|
+
id: SAMPLE_PUBKEY,
|
|
38
|
+
sig: SAMPLE_SIGNATURE
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function maxJsonlChunkByteSize () {
|
|
43
|
+
let min = 1
|
|
44
|
+
let max = MAX_EVENT_BYTES
|
|
45
|
+
|
|
46
|
+
while (min < max) {
|
|
47
|
+
const mid = Math.ceil((min + max) / 2)
|
|
48
|
+
if (outerEventByteLengthForChunk(mid) <= MAX_EVENT_BYTES) min = mid
|
|
49
|
+
else max = mid - 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return min
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// NIP-44 v3 padding has large size jumps, so derive this from the actual signed
|
|
56
|
+
// event shapes instead of estimating a flat overhead.
|
|
57
|
+
export const JSONL_CHUNK_BYTES = maxJsonlChunkByteSize()
|
|
58
|
+
// Nym carrier content is a base64 slice of the inner event JSON. The router
|
|
59
|
+
// budget is more conservative because routers carry extra tags, so it is safe
|
|
60
|
+
// to reuse it as the maximum carrier content length.
|
|
61
|
+
export const NYM_CARRIER_CHUNK_CHARS = JSONL_CHUNK_BYTES
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
|
2
|
+
import { bytesToBase64, base64ToBytes } from '../../base64/index.js'
|
|
3
|
+
import { bytesToHex } from '../../base16/index.js'
|
|
4
|
+
import { verifyIykcProof } from '../../content-key/event/index.js'
|
|
5
|
+
import * as nip44v3 from '../../nip44-v3/index.js'
|
|
6
|
+
import { JSONL_CHUNK_BYTES } from './chunk-size.js'
|
|
7
|
+
import { ROUTER_KIND } from '../constants/index.js'
|
|
8
|
+
import { createTemporaryStorage } from '../../temporary-storage/index.js'
|
|
9
|
+
|
|
10
|
+
const encoder = new TextEncoder()
|
|
11
|
+
const decoder = new TextDecoder()
|
|
12
|
+
const STORAGE_PREFIX = 'libp2r2p:private-channel:'
|
|
13
|
+
|
|
14
|
+
function appendBytes (left, right) {
|
|
15
|
+
const out = new Uint8Array(left.length + right.length)
|
|
16
|
+
out.set(left)
|
|
17
|
+
out.set(right, left.length)
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tempKey (id, index) {
|
|
22
|
+
return `${STORAGE_PREFIX}${id}:${index}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rowTempKey (id, index) {
|
|
26
|
+
return `${STORAGE_PREFIX}${id}:row:${index}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeContentKey ({ receiverPubkey, iykcPubkey = '', iykcProof = '' } = {}) {
|
|
30
|
+
if (!iykcPubkey) return { iykcPubkey: '', iykcProof: '' }
|
|
31
|
+
if (!verifyIykcProof({ receiverPubkey, iykcPubkey, iykcProof })) throw new Error('INVALID_IYKC_PROOF')
|
|
32
|
+
return { iykcPubkey, iykcProof }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function receiverRecord (receiver, receiverContentKeys) {
|
|
36
|
+
if (typeof receiver === 'string') {
|
|
37
|
+
const fetchedContentKey = normalizeContentKey({ receiverPubkey: receiver, ...receiverContentKeys[receiver] })
|
|
38
|
+
return {
|
|
39
|
+
receiverPubkey: receiver,
|
|
40
|
+
...fetchedContentKey
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(receiver)) {
|
|
45
|
+
const [receiverPubkey, iykcPubkey = '', iykcProof = ''] = receiver
|
|
46
|
+
const explicitContentKey = normalizeContentKey({ receiverPubkey, iykcPubkey, iykcProof })
|
|
47
|
+
const fetchedContentKey = normalizeContentKey({ receiverPubkey, ...receiverContentKeys[receiverPubkey] })
|
|
48
|
+
const contentKey = explicitContentKey.iykcPubkey ? explicitContentKey : fetchedContentKey
|
|
49
|
+
return {
|
|
50
|
+
receiverPubkey,
|
|
51
|
+
...contentKey
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const receiverPubkey = receiver?.receiverPubkey || receiver?.pubkey || ''
|
|
56
|
+
const explicitContentKey = normalizeContentKey({ receiverPubkey, ...receiver })
|
|
57
|
+
const fetchedContentKey = normalizeContentKey({ receiverPubkey, ...receiverContentKeys[receiverPubkey] })
|
|
58
|
+
const resolvedContentKey = explicitContentKey.iykcPubkey ? explicitContentKey : fetchedContentKey
|
|
59
|
+
return {
|
|
60
|
+
receiverPubkey,
|
|
61
|
+
...resolvedContentKey
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildRecipientRow ({ receiverPubkey, iykcPubkey, iykcProof }, ciphertext) {
|
|
66
|
+
const line = [receiverPubkey, ciphertext]
|
|
67
|
+
if (iykcPubkey) line.push(iykcPubkey, iykcProof)
|
|
68
|
+
return JSON.stringify(line)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildPayloadRow (ciphertext) {
|
|
72
|
+
return JSON.stringify([ciphertext])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function encryptedPayload ({ messageSecretKey, event }) {
|
|
76
|
+
const messagePubkey = getPublicKey(messageSecretKey)
|
|
77
|
+
return nip44v3.encrypt(messageSecretKey, messagePubkey, ROUTER_KIND, '', JSON.stringify(event))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function appendLine (chunk, line, id, chunkIndex, temporaryStorage) {
|
|
81
|
+
while (line.length) {
|
|
82
|
+
const available = JSONL_CHUNK_BYTES - chunk.length
|
|
83
|
+
chunk = appendBytes(chunk, line.slice(0, available))
|
|
84
|
+
line = line.slice(available)
|
|
85
|
+
if (chunk.length === JSONL_CHUNK_BYTES) {
|
|
86
|
+
temporaryStorage.setItem(tempKey(id, chunkIndex++), bytesToBase64(chunk))
|
|
87
|
+
chunk = new Uint8Array()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { chunk, chunkIndex }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendRow (chunk, row, id, chunkIndex, temporaryStorage) {
|
|
94
|
+
return appendLine(chunk, encoder.encode(`${row}\n`), id, chunkIndex, temporaryStorage)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function joinByteChunks (parts) {
|
|
98
|
+
let length = 0
|
|
99
|
+
const decoded = parts.map(part => {
|
|
100
|
+
const bytes = base64ToBytes(part)
|
|
101
|
+
length += bytes.length
|
|
102
|
+
return bytes
|
|
103
|
+
})
|
|
104
|
+
const out = new Uint8Array(length)
|
|
105
|
+
let offset = 0
|
|
106
|
+
for (const bytes of decoded) {
|
|
107
|
+
out.set(bytes, offset)
|
|
108
|
+
offset += bytes.length
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function storageFor (temporaryStorage) {
|
|
114
|
+
return temporaryStorage || createTemporaryStorage()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readChunkContent (id, index, temporaryStorage) {
|
|
118
|
+
return storageFor(temporaryStorage).getItem(tempKey(id, index))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function decodeChunkLines (content) {
|
|
122
|
+
return decodeChunkText(content).split('\n').filter(Boolean)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function decodeChunkText (content) {
|
|
126
|
+
return decoder.decode(base64ToBytes(content))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function joinChunksAsBase64 (parts) {
|
|
130
|
+
return bytesToBase64(joinByteChunks(parts))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function receiverPubkeys (receivers) {
|
|
134
|
+
return receivers.map(receiver => receiverRecord(receiver, {}).receiverPubkey).filter(Boolean)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function receiverPubkeysWithoutContentKeys (receivers) {
|
|
138
|
+
return receivers
|
|
139
|
+
.map(receiver => receiverRecord(receiver, {}))
|
|
140
|
+
.filter(receiver => receiver.receiverPubkey && !receiver.iykcPubkey)
|
|
141
|
+
.map(receiver => receiver.receiverPubkey)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function temporaryId () {
|
|
145
|
+
return `${Date.now()}:${Math.random().toString(16).slice(2)}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function cleanupPreparedRows (id, totalRows, temporaryStorage) {
|
|
149
|
+
const keys = []
|
|
150
|
+
for (let i = 0; i < totalRows; i++) keys.push(rowTempKey(id, i))
|
|
151
|
+
storageFor(temporaryStorage).removeItems(keys)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function setPreparedRow (id, index, row, temporaryStorage) {
|
|
155
|
+
storageFor(temporaryStorage).setItem(rowTempKey(id, index), row)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readPreparedRow (preparedRows, index) {
|
|
159
|
+
const row = storageFor(preparedRows.temporaryStorage).getItem(rowTempKey(preparedRows.id, index))
|
|
160
|
+
if (typeof row !== 'string') throw new Error('MISSING_PREPARED_ROW')
|
|
161
|
+
return row
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function prepareEnvelopeRowsOnce ({ id, senderSigner, receivers, receiverContentKeys = {}, event, rowScope = '', temporaryStorage }) {
|
|
165
|
+
const useDoubleDh = typeof senderSigner?.nip44EncryptDoubleDH === 'function'
|
|
166
|
+
let foundOwnContentPubkey = false
|
|
167
|
+
let usedOwnContentPubkey = ''
|
|
168
|
+
const messageSecretKey = generateSecretKey()
|
|
169
|
+
const messageSeckey = bytesToHex(messageSecretKey)
|
|
170
|
+
const rowIndexes = []
|
|
171
|
+
const receiverPubkeys = []
|
|
172
|
+
const receiverRowIndexesByPubkey = {}
|
|
173
|
+
|
|
174
|
+
setPreparedRow(id, 0, buildPayloadRow(encryptedPayload({ messageSecretKey, event })), temporaryStorage)
|
|
175
|
+
|
|
176
|
+
for (const receiver of receivers) {
|
|
177
|
+
const row = receiverRecord(receiver, useDoubleDh ? receiverContentKeys : {})
|
|
178
|
+
let ciphertext
|
|
179
|
+
if (useDoubleDh) {
|
|
180
|
+
const encrypted = await senderSigner.nip44EncryptDoubleDH(
|
|
181
|
+
row.receiverPubkey,
|
|
182
|
+
ROUTER_KIND,
|
|
183
|
+
rowScope,
|
|
184
|
+
bytesToBase64(encoder.encode(messageSeckey)),
|
|
185
|
+
row.iykcPubkey
|
|
186
|
+
)
|
|
187
|
+
ciphertext = encrypted[0]
|
|
188
|
+
const nextContentPubkey = encrypted[1] || ''
|
|
189
|
+
if (foundOwnContentPubkey && nextContentPubkey !== usedOwnContentPubkey) {
|
|
190
|
+
throw new Error('INCONSISTENT_IMKC_PUBKEY')
|
|
191
|
+
}
|
|
192
|
+
foundOwnContentPubkey = true
|
|
193
|
+
if (nextContentPubkey) usedOwnContentPubkey = nextContentPubkey
|
|
194
|
+
} else {
|
|
195
|
+
ciphertext = await senderSigner.nip44v3Encrypt(row.receiverPubkey, ROUTER_KIND, rowScope, bytesToBase64(encoder.encode(messageSeckey)))
|
|
196
|
+
}
|
|
197
|
+
const rowIndex = rowIndexes.length + 1
|
|
198
|
+
setPreparedRow(id, rowIndex, buildRecipientRow(row, ciphertext), temporaryStorage)
|
|
199
|
+
rowIndexes.push(rowIndex)
|
|
200
|
+
receiverPubkeys.push(row.receiverPubkey)
|
|
201
|
+
if (row.receiverPubkey && receiverRowIndexesByPubkey[row.receiverPubkey] === undefined) {
|
|
202
|
+
receiverRowIndexesByPubkey[row.receiverPubkey] = rowIndex
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
totalRows: rowIndexes.length + 1,
|
|
209
|
+
rowIndexes,
|
|
210
|
+
receiverPubkeys,
|
|
211
|
+
receiverRowIndexesByPubkey,
|
|
212
|
+
ownContentPubkey: usedOwnContentPubkey,
|
|
213
|
+
temporaryStorage
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function prepareEnvelopeRows ({ temporaryStorageArea, ...options } = {}) {
|
|
218
|
+
const temporaryStorage = createTemporaryStorage({ storageArea: temporaryStorageArea })
|
|
219
|
+
const maxRows = (options.receivers?.length || 0) + 1
|
|
220
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
221
|
+
const id = temporaryId()
|
|
222
|
+
try {
|
|
223
|
+
return await prepareEnvelopeRowsOnce({ ...options, id, temporaryStorage })
|
|
224
|
+
} catch (err) {
|
|
225
|
+
cleanupPreparedRows(id, maxRows, temporaryStorage)
|
|
226
|
+
if (err?.message === 'INCONSISTENT_IMKC_PUBKEY' && attempt === 0) continue
|
|
227
|
+
throw err
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function cleanupEnvelopeRows (preparedRows) {
|
|
233
|
+
if (!preparedRows?.id || !Number.isSafeInteger(preparedRows.totalRows)) return
|
|
234
|
+
cleanupPreparedRows(preparedRows.id, preparedRows.totalRows, preparedRows.temporaryStorage)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function preparedRowIndexesForReceivers (preparedRows, receivers) {
|
|
238
|
+
const indexes = []
|
|
239
|
+
const seen = new Set()
|
|
240
|
+
for (const receiver of receivers || []) {
|
|
241
|
+
const pubkey = receiverRecord(receiver, {}).receiverPubkey
|
|
242
|
+
const index = preparedRows?.receiverRowIndexesByPubkey?.[pubkey]
|
|
243
|
+
if (!pubkey || index === undefined) throw new Error('MISSING_PREPARED_RECEIVER')
|
|
244
|
+
if (seen.has(index)) continue
|
|
245
|
+
seen.add(index)
|
|
246
|
+
indexes.push(index)
|
|
247
|
+
}
|
|
248
|
+
return indexes
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function writeChunksFromPreparedRows (preparedRows, rowIndexes = preparedRows?.rowIndexes || []) {
|
|
252
|
+
const temporaryStorage = storageFor(preparedRows?.temporaryStorage)
|
|
253
|
+
const id = temporaryId()
|
|
254
|
+
let chunk = new Uint8Array()
|
|
255
|
+
let chunkIndex = 0
|
|
256
|
+
try {
|
|
257
|
+
;({ chunk, chunkIndex } = appendRow(chunk, readPreparedRow(preparedRows, 0), id, chunkIndex, temporaryStorage))
|
|
258
|
+
for (const rowIndex of rowIndexes) {
|
|
259
|
+
;({ chunk, chunkIndex } = appendRow(chunk, readPreparedRow(preparedRows, rowIndex), id, chunkIndex, temporaryStorage))
|
|
260
|
+
}
|
|
261
|
+
if (chunk.length || chunkIndex === 0) temporaryStorage.setItem(tempKey(id, chunkIndex++), bytesToBase64(chunk))
|
|
262
|
+
return { id, total: chunkIndex, ownContentPubkey: preparedRows.ownContentPubkey || '' }
|
|
263
|
+
} catch (err) {
|
|
264
|
+
cleanupChunks(id, chunkIndex + 1, temporaryStorage)
|
|
265
|
+
throw err
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function writeChunks (options) {
|
|
270
|
+
const preparedRows = await prepareEnvelopeRows(options)
|
|
271
|
+
try {
|
|
272
|
+
return writeChunksFromPreparedRows(preparedRows)
|
|
273
|
+
} finally {
|
|
274
|
+
cleanupEnvelopeRows(preparedRows)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function cleanupChunks (id, total, temporaryStorage) {
|
|
279
|
+
const keys = []
|
|
280
|
+
for (let i = 0; i < total; i++) keys.push(tempKey(id, i))
|
|
281
|
+
storageFor(temporaryStorage).removeItems(keys)
|
|
282
|
+
}
|