libp2r2p 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +1 -0
  2. package/nip19/index.js +305 -0
  3. package/package.json +4 -1
package/index.js CHANGED
@@ -10,6 +10,7 @@ export * as idb from './idb/index.js'
10
10
  export * as idbQueue from './idb-queue/index.js'
11
11
  export * as key from './key/index.js'
12
12
  export * as network from './network/index.js'
13
+ export * as nip19 from './nip19/index.js'
13
14
  export * as nip44v3 from './nip44-v3/index.js'
14
15
  export * as nip46 from './nip46/index.js'
15
16
  export * as privateChannel from './private-channel/index.js'
package/nip19/index.js ADDED
@@ -0,0 +1,305 @@
1
+ import { bech32 } from '@scure/base'
2
+
3
+ const MAX_ENTITY_SIZE = 5000
4
+ const MAX_TLV_VALUE_SIZE = 255
5
+ const NOSTR_APP_D_TAG_MAX_LENGTH = 260
6
+ const BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
7
+ const BASE62_BASE = BigInt(BASE62_ALPHABET.length)
8
+ const BASE62_LEADER = BASE62_ALPHABET[0]
9
+ const BASE62_CHAR_MAP = new Map(
10
+ [...BASE62_ALPHABET].map((character, index) => [character, BigInt(index)])
11
+ )
12
+
13
+ export const NAPP_ENTITY_REGEX = new RegExp(
14
+ `^\\+{1,3}[${BASE62_ALPHABET}]{48,${MAX_ENTITY_SIZE}}$`
15
+ )
16
+
17
+ const textEncoder = new TextEncoder()
18
+ const textDecoder = new TextDecoder()
19
+ const fatalTextDecoder = new TextDecoder('utf-8', { fatal: true })
20
+
21
+ const kindByChannel = {
22
+ main: 35128,
23
+ next: 35129,
24
+ draft: 35130
25
+ }
26
+ const channelByKind = Object.fromEntries(
27
+ Object.entries(kindByChannel).map(([channel, kind]) => [kind, channel])
28
+ )
29
+ const prefixByChannel = {
30
+ main: '+',
31
+ next: '++',
32
+ draft: '+++'
33
+ }
34
+ const channelByPrefix = Object.fromEntries(
35
+ Object.entries(prefixByChannel).map(([channel, prefix]) => [prefix, channel])
36
+ )
37
+
38
+ function bytesToHex (bytes) {
39
+ let result = ''
40
+ for (const byte of bytes) result += byte.toString(16).padStart(2, '0')
41
+ return result
42
+ }
43
+
44
+ function hexToBytes (hex, fieldName = 'hex value') {
45
+ if (typeof hex !== 'string' || !/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) {
46
+ throw new Error(`Invalid ${fieldName}`)
47
+ }
48
+ const result = new Uint8Array(hex.length / 2)
49
+ for (let index = 0; index < result.length; index++) {
50
+ result[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16)
51
+ }
52
+ return result
53
+ }
54
+
55
+ function fixedHexToBytes (hex, byteLength, fieldName) {
56
+ const bytes = hexToBytes(hex, fieldName)
57
+ if (bytes.length !== byteLength) throw new Error(`${fieldName} should be ${byteLength} bytes`)
58
+ return bytes
59
+ }
60
+
61
+ function concatBytes (arrays) {
62
+ const size = arrays.reduce((total, array) => total + array.length, 0)
63
+ const result = new Uint8Array(size)
64
+ let offset = 0
65
+ for (const array of arrays) {
66
+ result.set(array, offset)
67
+ offset += array.length
68
+ }
69
+ return result
70
+ }
71
+
72
+ function encodeText (value, fieldName) {
73
+ if (typeof value !== 'string' || value.length === 0) {
74
+ throw new Error(`${fieldName} should be a non-empty string`)
75
+ }
76
+ const bytes = textEncoder.encode(value)
77
+ if (bytes.length > MAX_TLV_VALUE_SIZE) throw new Error(`${fieldName} is too big`)
78
+ // TextEncoder replaces lone surrogates. Reject them instead of silently changing
79
+ // the signed/canonical entity value.
80
+ if (fatalTextDecoder.decode(bytes) !== value) throw new Error(`Invalid ${fieldName} UTF-8`)
81
+ return bytes
82
+ }
83
+
84
+ function decodeText (bytes, fieldName) {
85
+ if (bytes.length === 0 || bytes.length > MAX_TLV_VALUE_SIZE) {
86
+ throw new Error(`Invalid ${fieldName} length`)
87
+ }
88
+ try {
89
+ return fatalTextDecoder.decode(bytes)
90
+ } catch {
91
+ throw new Error(`Invalid ${fieldName} UTF-8`)
92
+ }
93
+ }
94
+
95
+ function encodeTlvEntries (entries) {
96
+ return concatBytes(entries.map(([type, value]) => {
97
+ if (!Number.isInteger(type) || type < 0 || type > 255) throw new Error('Invalid TLV type')
98
+ if (!(value instanceof Uint8Array)) throw new TypeError('TLV value should be a Uint8Array')
99
+ if (value.length > MAX_TLV_VALUE_SIZE) throw new Error('TLV value is too big')
100
+ const entry = new Uint8Array(value.length + 2)
101
+ entry[0] = type
102
+ entry[1] = value.length
103
+ entry.set(value, 2)
104
+ return entry
105
+ }))
106
+ }
107
+
108
+ function decodeTlvEntries (bytes) {
109
+ const entries = []
110
+ let offset = 0
111
+ while (offset < bytes.length) {
112
+ if (bytes.length - offset < 2) throw new Error('Truncated TLV header')
113
+ const type = bytes[offset]
114
+ const length = bytes[offset + 1]
115
+ offset += 2
116
+ if (bytes.length - offset < length) throw new Error(`Truncated TLV value for type ${type}`)
117
+ entries.push([type, bytes.slice(offset, offset + length)])
118
+ offset += length
119
+ }
120
+ return entries
121
+ }
122
+
123
+ function onlyValue (values, fieldName) {
124
+ if (values.length > 1) throw new Error(`Duplicate ${fieldName}`)
125
+ return values[0]
126
+ }
127
+
128
+ export function nfileEncode ({ root, relays = [], author, mime, filename }) {
129
+ if (!Array.isArray(relays)) throw new TypeError('relays should be an array')
130
+ const entries = [[0, fixedHexToBytes(root, 32, 'MMR root')]]
131
+ for (const relay of relays) entries.push([1, encodeText(relay, 'relay hint')])
132
+ if (author !== undefined) entries.push([2, fixedHexToBytes(author, 32, 'author hint')])
133
+ if (mime !== undefined) entries.push([3, encodeText(mime, 'MIME')])
134
+ if (filename !== undefined) entries.push([4, encodeText(filename, 'filename')])
135
+
136
+ const words = bech32.toWords(encodeTlvEntries(entries))
137
+ return bech32.encode('nfile', words, MAX_ENTITY_SIZE)
138
+ }
139
+
140
+ export function nfileDecode (entity) {
141
+ if (typeof entity !== 'string' || entity !== entity.toLowerCase()) {
142
+ throw new Error('nfile should use canonical lowercase Bech32')
143
+ }
144
+ let decoded
145
+ try {
146
+ decoded = bech32.decode(entity, MAX_ENTITY_SIZE)
147
+ } catch (error) {
148
+ throw new Error(`Invalid nfile: ${error.message}`)
149
+ }
150
+ if (decoded.prefix !== 'nfile') throw new Error('Invalid nfile prefix')
151
+
152
+ let bytes
153
+ try {
154
+ bytes = new Uint8Array(bech32.fromWords(decoded.words))
155
+ } catch (error) {
156
+ throw new Error(`Invalid nfile data: ${error.message}`)
157
+ }
158
+
159
+ const known = new Map([[0, []], [1, []], [2, []], [3, []], [4, []]])
160
+ for (const [type, value] of decodeTlvEntries(bytes)) known.get(type)?.push(value)
161
+
162
+ const rootBytes = onlyValue(known.get(0), 'MMR root')
163
+ if (!rootBytes) throw new Error('Missing MMR root')
164
+ if (rootBytes.length !== 32) throw new Error('MMR root should be 32 bytes')
165
+
166
+ const authorBytes = onlyValue(known.get(2), 'author hint')
167
+ if (authorBytes && authorBytes.length !== 32) throw new Error('Author hint should be 32 bytes')
168
+ const mimeBytes = onlyValue(known.get(3), 'MIME')
169
+ const filenameBytes = onlyValue(known.get(4), 'filename')
170
+
171
+ const result = {
172
+ root: bytesToHex(rootBytes),
173
+ relays: known.get(1).map(value => decodeText(value, 'relay hint'))
174
+ }
175
+ if (authorBytes) result.author = bytesToHex(authorBytes)
176
+ if (mimeBytes) result.mime = decodeText(mimeBytes, 'MIME')
177
+ if (filenameBytes) result.filename = decodeText(filenameBytes, 'filename')
178
+ return result
179
+ }
180
+
181
+ function bytesToBase62 (bytes) {
182
+ if (bytes.length === 0) return ''
183
+ let number = 0n
184
+ for (const byte of bytes) number = (number << 8n) + BigInt(byte)
185
+
186
+ let result = ''
187
+ if (number === 0n) return BASE62_LEADER
188
+ while (number > 0n) {
189
+ result = BASE62_ALPHABET[Number(number % BASE62_BASE)] + result
190
+ number /= BASE62_BASE
191
+ }
192
+ for (const byte of bytes) {
193
+ if (byte !== 0) break
194
+ result = BASE62_LEADER + result
195
+ }
196
+ return result
197
+ }
198
+
199
+ function base62ToBytes (value) {
200
+ if (typeof value !== 'string') throw new TypeError('Base62 value should be a string')
201
+ let leadingZeros = 0
202
+ while (value[leadingZeros] === BASE62_LEADER) leadingZeros++
203
+
204
+ let number = 0n
205
+ for (const character of value) {
206
+ const digit = BASE62_CHAR_MAP.get(character)
207
+ if (digit === undefined) throw new Error(`Invalid Base62 character: ${character}`)
208
+ number = number * BASE62_BASE + digit
209
+ }
210
+ const suffix = []
211
+ while (number > 0n) {
212
+ suffix.unshift(Number(number & 0xffn))
213
+ number >>= 8n
214
+ }
215
+ const result = new Uint8Array(leadingZeros + suffix.length)
216
+ result.set(suffix, leadingZeros)
217
+ return result
218
+ }
219
+
220
+ function isNostrAppDTagSafe (value) {
221
+ return typeof value === 'string' && value.length <= NOSTR_APP_D_TAG_MAX_LENGTH
222
+ }
223
+
224
+ export function appEncode (ref) {
225
+ if (!isNostrAppDTagSafe(ref.dTag)) throw new Error('Invalid deduplication tag')
226
+ const channel = ref.channel ? (prefixByChannel[ref.channel] && ref.channel) : channelByKind[ref.kind]
227
+ if (!channel) throw new Error('Wrong channel')
228
+
229
+ // Keep the established app-entity byte ordering exactly for compatibility.
230
+ const groups = [
231
+ [textEncoder.encode(ref.dTag)],
232
+ (ref.relays || []).map(relay => textEncoder.encode(relay)),
233
+ [fixedHexToBytes(ref.pubkey, 32, 'author pubkey')]
234
+ ]
235
+ const entries = []
236
+ groups
237
+ .map((values, type) => [type, values])
238
+ .reverse()
239
+ .forEach(([type, values]) => {
240
+ for (const value of values) entries.push([type, value])
241
+ })
242
+ const entity = `${prefixByChannel[channel]}${bytesToBase62(encodeTlvEntries(entries))}`
243
+ if (entity.length > MAX_ENTITY_SIZE) throw new Error('App entity is too big')
244
+ return entity
245
+ }
246
+
247
+ export function appDecode (entity) {
248
+ if (typeof entity !== 'string' || entity.length > MAX_ENTITY_SIZE) throw new Error('Invalid app entity')
249
+ const prefix = entity.match(/^\+*/)?.[0]
250
+ const channel = channelByPrefix[prefix]
251
+ if (!channel) throw new Error('Invalid channel')
252
+
253
+ const values = new Map()
254
+ for (const [type, value] of decodeTlvEntries(base62ToBytes(entity.slice(prefix.length)))) {
255
+ const list = values.get(type) || []
256
+ list.push(value)
257
+ values.set(type, list)
258
+ }
259
+ const dTagBytes = values.get(0)?.[0]
260
+ const pubkeyBytes = values.get(2)?.[0]
261
+ if (!dTagBytes) throw new Error('Missing deduplication tag')
262
+ if (!pubkeyBytes) throw new Error('Missing author pubkey')
263
+ if (pubkeyBytes.length !== 32) throw new Error('Author pubkey should be 32 bytes')
264
+ const dTag = textDecoder.decode(dTagBytes)
265
+ if (!isNostrAppDTagSafe(dTag)) throw new Error('Invalid deduplication tag')
266
+
267
+ return {
268
+ dTag,
269
+ pubkey: bytesToHex(pubkeyBytes),
270
+ kind: kindByChannel[channel],
271
+ channel,
272
+ relays: (values.get(1) || []).map(value => textDecoder.decode(value))
273
+ }
274
+ }
275
+
276
+ export function npubEncode (hex) {
277
+ return bech32.encode('npub', bech32.toWords(fixedHexToBytes(hex, 32, 'pubkey')))
278
+ }
279
+
280
+ export function npubDecode (entity) {
281
+ return decodeSimpleEntity(entity, 'npub', 'pubkey')
282
+ }
283
+
284
+ export function nsecEncode (hex) {
285
+ return bech32.encode('nsec', bech32.toWords(fixedHexToBytes(hex, 32, 'secret key')))
286
+ }
287
+
288
+ export function nsecDecode (entity) {
289
+ return decodeSimpleEntity(entity, 'nsec', 'secret key')
290
+ }
291
+
292
+ function decodeSimpleEntity (entity, prefix, fieldName) {
293
+ if (typeof entity !== 'string' || !entity.startsWith(`${prefix}1`)) {
294
+ throw new Error(`Invalid ${prefix} format`)
295
+ }
296
+ try {
297
+ const decoded = bech32.decode(entity)
298
+ if (decoded.prefix !== prefix) throw new Error(`Invalid ${prefix} prefix`)
299
+ const bytes = new Uint8Array(bech32.fromWords(decoded.words))
300
+ if (bytes.length !== 32) throw new Error(`Invalid ${fieldName} length`)
301
+ return bytesToHex(bytes)
302
+ } catch (error) {
303
+ throw new Error(`Failed to decode ${prefix}: ${error.message}`)
304
+ }
305
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -23,6 +23,7 @@
23
23
  "index.js",
24
24
  "key",
25
25
  "network",
26
+ "nip19",
26
27
  "nip44-v3",
27
28
  "nip46",
28
29
  "private-channel",
@@ -49,6 +50,7 @@
49
50
  "./idb-queue": "./idb-queue/index.js",
50
51
  "./key": "./key/index.js",
51
52
  "./network": "./network/index.js",
53
+ "./nip19": "./nip19/index.js",
52
54
  "./nip44-v3": "./nip44-v3/index.js",
53
55
  "./nip46": "./nip46/index.js",
54
56
  "./private-channel": "./private-channel/index.js",
@@ -63,6 +65,7 @@
63
65
  "@noble/ciphers": "2.2.0",
64
66
  "@noble/curves": "2.2.0",
65
67
  "@noble/hashes": "2.2.0",
68
+ "@scure/base": "2.0.0",
66
69
  "nostr-tools": "2.23.5"
67
70
  },
68
71
  "devDependencies": {