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.
- package/index.js +1 -0
- package/nip19/index.js +305 -0
- 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.
|
|
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": {
|