libp2r2p 0.7.0 → 0.9.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/README.md +112 -8
- package/base16/index.js +6 -3
- package/base36/index.js +12 -8
- package/base62/index.js +15 -11
- package/base64/index.js +18 -2
- package/base93/index.js +19 -7
- package/content-key/event/index.js +40 -12
- package/double-dh/index.js +21 -6
- package/ecdh/index.js +12 -1
- package/error/index.js +32 -0
- package/event/helpers/serialize.js +31 -0
- package/event/index.js +116 -0
- package/i18n/index.js +15 -13
- package/idb/index.js +5 -3
- package/idb-queue/index.js +21 -20
- package/index.js +10 -0
- package/key/index.js +31 -18
- package/kind/index.js +244 -0
- package/nip04/index.js +47 -0
- package/nip05/index.js +61 -0
- package/nip19/index.js +176 -43
- package/nip44/helpers.js +95 -0
- package/nip44/index.js +14 -0
- package/nip44-v3/index.js +35 -16
- package/nip46/helpers/frame.js +6 -6
- package/nip46/helpers/url.js +8 -6
- package/nip46/services/bunker-signer.js +7 -6
- package/nip46/services/client.js +8 -7
- package/nip46/services/server-session.js +8 -7
- package/nip46/services/transport.js +9 -8
- package/nip96/index.js +285 -0
- package/nip98/index.js +56 -0
- package/nwt/index.js +241 -0
- package/package.json +22 -3
- package/private-channel/helpers/chunks.js +7 -6
- package/private-channel/helpers/event.js +5 -4
- package/private-channel/index.js +63 -61
- package/private-channel/services/received-chunks.js +4 -3
- package/private-message/index.js +32 -31
- package/private-messenger/index.js +313 -54
- package/private-messenger/recovery/index.js +15 -14
- package/private-messenger/services/channel-state.js +28 -7
- package/private-messenger/services/storage-maintenance.js +142 -46
- package/relay/helpers/hll.js +3 -1
- package/relay/services/query.js +3 -3
- package/relay/services/relay-connection.js +222 -121
- package/relay/services/relay-pool.js +13 -10
- package/temporary-storage/index.js +3 -1
- package/url/index.js +131 -0
- package/web-storage-queue/index.js +8 -6
package/nip19/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bech32 } from '@scure/base'
|
|
2
2
|
import { BASE62_ALPHABET, base62ToBytes, bytesToBase62 } from '../base62/index.js'
|
|
3
|
+
import { ValidationError } from '../error/index.js'
|
|
3
4
|
|
|
4
5
|
const MAX_ENTITY_SIZE = 5000
|
|
5
6
|
const MAX_TLV_VALUE_SIZE = 255
|
|
@@ -29,6 +30,10 @@ const channelByPrefix = Object.fromEntries(
|
|
|
29
30
|
Object.entries(prefixByChannel).map(([channel, prefix]) => [prefix, channel])
|
|
30
31
|
)
|
|
31
32
|
|
|
33
|
+
function invalid (code, message, cause) {
|
|
34
|
+
return new ValidationError(code, { message, cause })
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
function bytesToHex (bytes) {
|
|
33
38
|
let result = ''
|
|
34
39
|
for (const byte of bytes) result += byte.toString(16).padStart(2, '0')
|
|
@@ -37,7 +42,7 @@ function bytesToHex (bytes) {
|
|
|
37
42
|
|
|
38
43
|
function hexToBytes (hex, fieldName = 'hex value') {
|
|
39
44
|
if (typeof hex !== 'string' || !/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) {
|
|
40
|
-
throw
|
|
45
|
+
throw invalid('INVALID_NIP19_HEX', `Invalid ${fieldName}`)
|
|
41
46
|
}
|
|
42
47
|
const result = new Uint8Array(hex.length / 2)
|
|
43
48
|
for (let index = 0; index < result.length; index++) {
|
|
@@ -48,7 +53,7 @@ function hexToBytes (hex, fieldName = 'hex value') {
|
|
|
48
53
|
|
|
49
54
|
function fixedHexToBytes (hex, byteLength, fieldName) {
|
|
50
55
|
const bytes = hexToBytes(hex, fieldName)
|
|
51
|
-
if (bytes.length !== byteLength) throw
|
|
56
|
+
if (bytes.length !== byteLength) throw invalid('INVALID_NIP19_FIELD_LENGTH', `${fieldName} should be ${byteLength} bytes`)
|
|
52
57
|
return bytes
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -63,34 +68,34 @@ function concatBytes (arrays) {
|
|
|
63
68
|
return result
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
function encodeText (value, fieldName) {
|
|
67
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
68
|
-
throw
|
|
71
|
+
function encodeText (value, fieldName, { allowEmpty = false } = {}) {
|
|
72
|
+
if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
|
|
73
|
+
throw invalid('INVALID_NIP19_TEXT_FIELD', `${fieldName} should be ${allowEmpty ? 'a string' : 'a non-empty string'}`)
|
|
69
74
|
}
|
|
70
75
|
const bytes = textEncoder.encode(value)
|
|
71
|
-
if (bytes.length > MAX_TLV_VALUE_SIZE) throw
|
|
76
|
+
if (bytes.length > MAX_TLV_VALUE_SIZE) throw invalid('NIP19_FIELD_TOO_LARGE', `${fieldName} is too big`)
|
|
72
77
|
// TextEncoder replaces lone surrogates. Reject them instead of silently changing
|
|
73
78
|
// the signed/canonical entity value.
|
|
74
|
-
if (fatalTextDecoder.decode(bytes) !== value) throw
|
|
79
|
+
if (fatalTextDecoder.decode(bytes) !== value) throw invalid('INVALID_NIP19_UTF8', `Invalid ${fieldName} UTF-8`)
|
|
75
80
|
return bytes
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
function decodeText (bytes, fieldName) {
|
|
79
|
-
if (bytes.length === 0 || bytes.length > MAX_TLV_VALUE_SIZE) {
|
|
80
|
-
throw
|
|
83
|
+
function decodeText (bytes, fieldName, { allowEmpty = false } = {}) {
|
|
84
|
+
if ((!allowEmpty && bytes.length === 0) || bytes.length > MAX_TLV_VALUE_SIZE) {
|
|
85
|
+
throw invalid('INVALID_NIP19_FIELD_LENGTH', `Invalid ${fieldName} length`)
|
|
81
86
|
}
|
|
82
87
|
try {
|
|
83
88
|
return fatalTextDecoder.decode(bytes)
|
|
84
|
-
} catch {
|
|
85
|
-
throw
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
throw invalid('INVALID_NIP19_UTF8', `Invalid ${fieldName} UTF-8`, cause)
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
function encodeTlvEntries (entries) {
|
|
90
95
|
return concatBytes(entries.map(([type, value]) => {
|
|
91
|
-
if (!Number.isInteger(type) || type < 0 || type > 255) throw
|
|
92
|
-
if (!(value instanceof Uint8Array)) throw
|
|
93
|
-
if (value.length > MAX_TLV_VALUE_SIZE) throw
|
|
96
|
+
if (!Number.isInteger(type) || type < 0 || type > 255) throw invalid('INVALID_TLV_TYPE', 'Invalid TLV type')
|
|
97
|
+
if (!(value instanceof Uint8Array)) throw invalid('INVALID_TLV_VALUE', 'TLV value should be a Uint8Array')
|
|
98
|
+
if (value.length > MAX_TLV_VALUE_SIZE) throw invalid('TLV_VALUE_TOO_LARGE', 'TLV value is too big')
|
|
94
99
|
const entry = new Uint8Array(value.length + 2)
|
|
95
100
|
entry[0] = type
|
|
96
101
|
entry[1] = value.length
|
|
@@ -103,11 +108,11 @@ function decodeTlvEntries (bytes) {
|
|
|
103
108
|
const entries = []
|
|
104
109
|
let offset = 0
|
|
105
110
|
while (offset < bytes.length) {
|
|
106
|
-
if (bytes.length - offset < 2) throw
|
|
111
|
+
if (bytes.length - offset < 2) throw invalid('TRUNCATED_TLV_HEADER', 'Truncated TLV header')
|
|
107
112
|
const type = bytes[offset]
|
|
108
113
|
const length = bytes[offset + 1]
|
|
109
114
|
offset += 2
|
|
110
|
-
if (bytes.length - offset < length) throw
|
|
115
|
+
if (bytes.length - offset < length) throw invalid('TRUNCATED_TLV_VALUE', `Truncated TLV value for type ${type}`)
|
|
111
116
|
entries.push([type, bytes.slice(offset, offset + length)])
|
|
112
117
|
offset += length
|
|
113
118
|
}
|
|
@@ -115,12 +120,65 @@ function decodeTlvEntries (bytes) {
|
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
function onlyValue (values, fieldName) {
|
|
118
|
-
if (values.length > 1) throw
|
|
123
|
+
if (values.length > 1) throw invalid('DUPLICATE_NIP19_FIELD', `Duplicate ${fieldName}`)
|
|
119
124
|
return values[0]
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
function encodeBech32Tlv (prefix, entries) {
|
|
128
|
+
return bech32.encode(prefix, bech32.toWords(encodeTlvEntries(entries)), MAX_ENTITY_SIZE)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function encodeStandardPointer (prefix, entries) {
|
|
132
|
+
return encodeBech32Tlv(prefix, entries.slice().sort(([left], [right]) => right - left))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function decodeBech32Bytes (entity, prefix) {
|
|
136
|
+
if (typeof entity !== 'string' || entity !== entity.toLowerCase() || !entity.startsWith(`${prefix}1`)) {
|
|
137
|
+
throw invalid('NON_CANONICAL_NIP19_ENTITY', `${prefix} should use canonical lowercase Bech32`)
|
|
138
|
+
}
|
|
139
|
+
let decoded
|
|
140
|
+
try {
|
|
141
|
+
decoded = bech32.decode(entity, MAX_ENTITY_SIZE)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw invalid('INVALID_NIP19_ENTITY', `Invalid ${prefix}: ${error.message}`, error)
|
|
144
|
+
}
|
|
145
|
+
if (decoded.prefix !== prefix) throw invalid('INVALID_NIP19_PREFIX', `Invalid ${prefix} prefix`)
|
|
146
|
+
try {
|
|
147
|
+
return new Uint8Array(bech32.fromWords(decoded.words))
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw invalid('INVALID_NIP19_DATA', `Invalid ${prefix} data: ${error.message}`, error)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function decodeKnownTlv (entity, prefix, types) {
|
|
154
|
+
const known = new Map(types.map(type => [type, []]))
|
|
155
|
+
for (const [type, value] of decodeTlvEntries(decodeBech32Bytes(entity, prefix))) known.get(type)?.push(value)
|
|
156
|
+
return known
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function encodeUint32 (value, fieldName = 'kind') {
|
|
160
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw invalid('INVALID_NIP19_UINT32', `Invalid ${fieldName}`)
|
|
161
|
+
const bytes = new Uint8Array(4)
|
|
162
|
+
new DataView(bytes.buffer).setUint32(0, value, false)
|
|
163
|
+
return bytes
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function decodeUint32 (bytes, fieldName = 'kind') {
|
|
167
|
+
if (!bytes || bytes.length !== 4) throw invalid('INVALID_NIP19_UINT32', `${fieldName} should be 4 bytes`)
|
|
168
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, false)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function relayEntries (relays) {
|
|
172
|
+
if (!Array.isArray(relays)) throw invalid('INVALID_RELAY_HINTS', 'relays should be an array')
|
|
173
|
+
return relays.map(relay => [1, encodeText(relay, 'relay hint')])
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function decodeRelayHints (values) {
|
|
177
|
+
return values.map(value => decodeText(value, 'relay hint'))
|
|
178
|
+
}
|
|
179
|
+
|
|
122
180
|
export function nfileEncode ({ root, relays = [], author, mime, filename }) {
|
|
123
|
-
if (!Array.isArray(relays)) throw
|
|
181
|
+
if (!Array.isArray(relays)) throw invalid('INVALID_RELAY_HINTS', 'relays should be an array')
|
|
124
182
|
const entries = [[0, fixedHexToBytes(root, 32, 'MMR root')]]
|
|
125
183
|
for (const relay of relays) entries.push([1, encodeText(relay, 'relay hint')])
|
|
126
184
|
if (author !== undefined) entries.push([2, fixedHexToBytes(author, 32, 'author hint')])
|
|
@@ -133,32 +191,32 @@ export function nfileEncode ({ root, relays = [], author, mime, filename }) {
|
|
|
133
191
|
|
|
134
192
|
export function nfileDecode (entity) {
|
|
135
193
|
if (typeof entity !== 'string' || entity !== entity.toLowerCase()) {
|
|
136
|
-
throw
|
|
194
|
+
throw invalid('NON_CANONICAL_NFILE', 'nfile should use canonical lowercase Bech32')
|
|
137
195
|
}
|
|
138
196
|
let decoded
|
|
139
197
|
try {
|
|
140
198
|
decoded = bech32.decode(entity, MAX_ENTITY_SIZE)
|
|
141
199
|
} catch (error) {
|
|
142
|
-
throw
|
|
200
|
+
throw invalid('INVALID_NFILE', `Invalid nfile: ${error.message}`, error)
|
|
143
201
|
}
|
|
144
|
-
if (decoded.prefix !== 'nfile') throw
|
|
202
|
+
if (decoded.prefix !== 'nfile') throw invalid('INVALID_NFILE_PREFIX', 'Invalid nfile prefix')
|
|
145
203
|
|
|
146
204
|
let bytes
|
|
147
205
|
try {
|
|
148
206
|
bytes = new Uint8Array(bech32.fromWords(decoded.words))
|
|
149
207
|
} catch (error) {
|
|
150
|
-
throw
|
|
208
|
+
throw invalid('INVALID_NFILE_DATA', `Invalid nfile data: ${error.message}`, error)
|
|
151
209
|
}
|
|
152
210
|
|
|
153
211
|
const known = new Map([[0, []], [1, []], [2, []], [3, []], [4, []]])
|
|
154
212
|
for (const [type, value] of decodeTlvEntries(bytes)) known.get(type)?.push(value)
|
|
155
213
|
|
|
156
214
|
const rootBytes = onlyValue(known.get(0), 'MMR root')
|
|
157
|
-
if (!rootBytes) throw
|
|
158
|
-
if (rootBytes.length !== 32) throw
|
|
215
|
+
if (!rootBytes) throw invalid('MISSING_NFILE_ROOT', 'Missing MMR root')
|
|
216
|
+
if (rootBytes.length !== 32) throw invalid('INVALID_NFILE_ROOT', 'MMR root should be 32 bytes')
|
|
159
217
|
|
|
160
218
|
const authorBytes = onlyValue(known.get(2), 'author hint')
|
|
161
|
-
if (authorBytes && authorBytes.length !== 32) throw
|
|
219
|
+
if (authorBytes && authorBytes.length !== 32) throw invalid('INVALID_NFILE_AUTHOR', 'Author hint should be 32 bytes')
|
|
162
220
|
const mimeBytes = onlyValue(known.get(3), 'MIME')
|
|
163
221
|
const filenameBytes = onlyValue(known.get(4), 'filename')
|
|
164
222
|
|
|
@@ -172,14 +230,93 @@ export function nfileDecode (entity) {
|
|
|
172
230
|
return result
|
|
173
231
|
}
|
|
174
232
|
|
|
233
|
+
export function noteEncode (eventId) {
|
|
234
|
+
return bech32.encode('note', bech32.toWords(fixedHexToBytes(eventId, 32, 'event ID')), MAX_ENTITY_SIZE)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function noteDecode (entity) {
|
|
238
|
+
const bytes = decodeBech32Bytes(entity, 'note')
|
|
239
|
+
if (bytes.length !== 32) throw invalid('INVALID_NOTE_EVENT_ID', 'event ID should be 32 bytes')
|
|
240
|
+
return bytesToHex(bytes)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function nprofileEncode ({ pubkey, relays = [] }) {
|
|
244
|
+
return encodeStandardPointer('nprofile', [
|
|
245
|
+
[0, fixedHexToBytes(pubkey, 32, 'profile pubkey')],
|
|
246
|
+
...relayEntries(relays)
|
|
247
|
+
])
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function nprofileDecode (entity) {
|
|
251
|
+
const known = decodeKnownTlv(entity, 'nprofile', [0, 1])
|
|
252
|
+
const pubkey = onlyValue(known.get(0), 'profile pubkey')
|
|
253
|
+
if (!pubkey || pubkey.length !== 32) throw invalid('INVALID_NPROFILE_PUBKEY', 'profile pubkey should be 32 bytes')
|
|
254
|
+
return { pubkey: bytesToHex(pubkey), relays: decodeRelayHints(known.get(1)) }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function neventEncode ({ id, relays = [], author, kind }) {
|
|
258
|
+
const entries = [[0, fixedHexToBytes(id, 32, 'event ID')], ...relayEntries(relays)]
|
|
259
|
+
if (author !== undefined) entries.push([2, fixedHexToBytes(author, 32, 'event author')])
|
|
260
|
+
if (kind !== undefined) entries.push([3, encodeUint32(kind)])
|
|
261
|
+
return encodeStandardPointer('nevent', entries)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function neventDecode (entity) {
|
|
265
|
+
const known = decodeKnownTlv(entity, 'nevent', [0, 1, 2, 3])
|
|
266
|
+
const id = onlyValue(known.get(0), 'event ID')
|
|
267
|
+
const author = onlyValue(known.get(2), 'event author')
|
|
268
|
+
const kind = onlyValue(known.get(3), 'event kind')
|
|
269
|
+
if (!id || id.length !== 32) throw invalid('INVALID_NEVENT_ID', 'event ID should be 32 bytes')
|
|
270
|
+
if (author && author.length !== 32) throw invalid('INVALID_NEVENT_AUTHOR', 'event author should be 32 bytes')
|
|
271
|
+
const result = { id: bytesToHex(id), relays: decodeRelayHints(known.get(1)) }
|
|
272
|
+
if (author) result.author = bytesToHex(author)
|
|
273
|
+
if (kind) result.kind = decodeUint32(kind, 'event kind')
|
|
274
|
+
return result
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function naddrEncode ({ identifier, pubkey, kind, relays = [] }) {
|
|
278
|
+
return encodeStandardPointer('naddr', [
|
|
279
|
+
[0, encodeText(identifier, 'identifier', { allowEmpty: true })],
|
|
280
|
+
...relayEntries(relays),
|
|
281
|
+
[2, fixedHexToBytes(pubkey, 32, 'address author')],
|
|
282
|
+
[3, encodeUint32(kind)]
|
|
283
|
+
])
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function naddrDecode (entity) {
|
|
287
|
+
const known = decodeKnownTlv(entity, 'naddr', [0, 1, 2, 3])
|
|
288
|
+
const identifier = onlyValue(known.get(0), 'identifier')
|
|
289
|
+
const pubkey = onlyValue(known.get(2), 'address author')
|
|
290
|
+
const kind = onlyValue(known.get(3), 'address kind')
|
|
291
|
+
if (!identifier) throw invalid('MISSING_NADDR_IDENTIFIER', 'Missing identifier')
|
|
292
|
+
if (!pubkey || pubkey.length !== 32) throw invalid('INVALID_NADDR_AUTHOR', 'address author should be 32 bytes')
|
|
293
|
+
return {
|
|
294
|
+
identifier: decodeText(identifier, 'identifier', { allowEmpty: true }),
|
|
295
|
+
pubkey: bytesToHex(pubkey),
|
|
296
|
+
kind: decodeUint32(kind, 'address kind'),
|
|
297
|
+
relays: decodeRelayHints(known.get(1))
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function nrelayEncode (relay) {
|
|
302
|
+
return encodeStandardPointer('nrelay', [[0, encodeText(relay, 'relay URL')]])
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function nrelayDecode (entity) {
|
|
306
|
+
const known = decodeKnownTlv(entity, 'nrelay', [0])
|
|
307
|
+
const relay = onlyValue(known.get(0), 'relay URL')
|
|
308
|
+
if (!relay) throw invalid('MISSING_NRELAY_URL', 'Missing relay URL')
|
|
309
|
+
return decodeText(relay, 'relay URL')
|
|
310
|
+
}
|
|
311
|
+
|
|
175
312
|
function isNostrAppDTagSafe (value) {
|
|
176
313
|
return typeof value === 'string' && value.length <= NOSTR_APP_D_TAG_MAX_LENGTH
|
|
177
314
|
}
|
|
178
315
|
|
|
179
316
|
export function appEncode (ref) {
|
|
180
|
-
if (!isNostrAppDTagSafe(ref
|
|
317
|
+
if (!isNostrAppDTagSafe(ref?.dTag)) throw invalid('INVALID_APP_D_TAG', 'Invalid deduplication tag')
|
|
181
318
|
const channel = ref.channel ? (prefixByChannel[ref.channel] && ref.channel) : channelByKind[ref.kind]
|
|
182
|
-
if (!channel) throw
|
|
319
|
+
if (!channel) throw invalid('INVALID_APP_CHANNEL', 'Wrong channel')
|
|
183
320
|
|
|
184
321
|
// Keep the established app-entity byte ordering exactly for compatibility.
|
|
185
322
|
const groups = [
|
|
@@ -195,15 +332,15 @@ export function appEncode (ref) {
|
|
|
195
332
|
for (const value of values) entries.push([type, value])
|
|
196
333
|
})
|
|
197
334
|
const entity = `${prefixByChannel[channel]}${bytesToBase62(encodeTlvEntries(entries))}`
|
|
198
|
-
if (entity.length > MAX_ENTITY_SIZE) throw
|
|
335
|
+
if (entity.length > MAX_ENTITY_SIZE) throw invalid('APP_ENTITY_TOO_LARGE', 'App entity is too big')
|
|
199
336
|
return entity
|
|
200
337
|
}
|
|
201
338
|
|
|
202
339
|
export function appDecode (entity) {
|
|
203
|
-
if (typeof entity !== 'string' || entity.length > MAX_ENTITY_SIZE) throw
|
|
340
|
+
if (typeof entity !== 'string' || entity.length > MAX_ENTITY_SIZE) throw invalid('INVALID_APP_ENTITY', 'Invalid app entity')
|
|
204
341
|
const prefix = entity.match(/^\+*/)?.[0]
|
|
205
342
|
const channel = channelByPrefix[prefix]
|
|
206
|
-
if (!channel) throw
|
|
343
|
+
if (!channel) throw invalid('INVALID_APP_CHANNEL', 'Invalid channel')
|
|
207
344
|
|
|
208
345
|
const values = new Map()
|
|
209
346
|
for (const [type, value] of decodeTlvEntries(base62ToBytes(entity.slice(prefix.length)))) {
|
|
@@ -213,11 +350,11 @@ export function appDecode (entity) {
|
|
|
213
350
|
}
|
|
214
351
|
const dTagBytes = values.get(0)?.[0]
|
|
215
352
|
const pubkeyBytes = values.get(2)?.[0]
|
|
216
|
-
if (!dTagBytes) throw
|
|
217
|
-
if (!pubkeyBytes) throw
|
|
218
|
-
if (pubkeyBytes.length !== 32) throw
|
|
353
|
+
if (!dTagBytes) throw invalid('MISSING_APP_D_TAG', 'Missing deduplication tag')
|
|
354
|
+
if (!pubkeyBytes) throw invalid('MISSING_APP_AUTHOR', 'Missing author pubkey')
|
|
355
|
+
if (pubkeyBytes.length !== 32) throw invalid('INVALID_APP_AUTHOR', 'Author pubkey should be 32 bytes')
|
|
219
356
|
const dTag = textDecoder.decode(dTagBytes)
|
|
220
|
-
if (!isNostrAppDTagSafe(dTag)) throw
|
|
357
|
+
if (!isNostrAppDTagSafe(dTag)) throw invalid('INVALID_APP_D_TAG', 'Invalid deduplication tag')
|
|
221
358
|
|
|
222
359
|
return {
|
|
223
360
|
dTag,
|
|
@@ -245,16 +382,12 @@ export function nsecDecode (entity) {
|
|
|
245
382
|
}
|
|
246
383
|
|
|
247
384
|
function decodeSimpleEntity (entity, prefix, fieldName) {
|
|
248
|
-
if (typeof entity !== 'string' || !entity.startsWith(`${prefix}1`)) {
|
|
249
|
-
throw new Error(`Invalid ${prefix} format`)
|
|
250
|
-
}
|
|
251
385
|
try {
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
const bytes = new Uint8Array(bech32.fromWords(decoded.words))
|
|
255
|
-
if (bytes.length !== 32) throw new Error(`Invalid ${fieldName} length`)
|
|
386
|
+
const bytes = decodeBech32Bytes(entity, prefix)
|
|
387
|
+
if (bytes.length !== 32) throw invalid('INVALID_NIP19_FIELD_LENGTH', `Invalid ${fieldName} length`)
|
|
256
388
|
return bytesToHex(bytes)
|
|
257
389
|
} catch (error) {
|
|
258
|
-
|
|
390
|
+
if (error instanceof ValidationError && error.code === 'INVALID_NIP19_FIELD_LENGTH') throw error
|
|
391
|
+
throw invalid('INVALID_NIP19_ENTITY', `Failed to decode ${prefix}: ${error.message}`, error)
|
|
259
392
|
}
|
|
260
393
|
}
|
package/nip44/helpers.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { chacha20 } from '@noble/ciphers/chacha.js'
|
|
2
|
+
import { equalBytes, randomBytes } from '@noble/ciphers/utils.js'
|
|
3
|
+
import { expand, extract } from '@noble/hashes/hkdf.js'
|
|
4
|
+
import { hmac } from '@noble/hashes/hmac.js'
|
|
5
|
+
import { sha256 } from '@noble/hashes/sha2.js'
|
|
6
|
+
|
|
7
|
+
import { base64ToBytes, bytesToBase64 } from '../base64/index.js'
|
|
8
|
+
import { ValidationError } from '../error/index.js'
|
|
9
|
+
|
|
10
|
+
const encoder = new TextEncoder()
|
|
11
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
12
|
+
const minPlaintextSize = 1
|
|
13
|
+
const maxPlaintextSize = 0xffffffff
|
|
14
|
+
const maxRawPayloadSize = 1 + 32 + 6 + 0x100000000 + 32
|
|
15
|
+
const maxEncodedPayloadSize = Math.ceil(maxRawPayloadSize / 3) * 4
|
|
16
|
+
|
|
17
|
+
function concatBytes (...arrays) {
|
|
18
|
+
const output = new Uint8Array(arrays.reduce((total, value) => total + value.length, 0))
|
|
19
|
+
let offset = 0
|
|
20
|
+
for (const value of arrays) {
|
|
21
|
+
output.set(value, offset)
|
|
22
|
+
offset += value.length
|
|
23
|
+
}
|
|
24
|
+
return output
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getMessageKeys (conversationKey, nonce) {
|
|
28
|
+
if (!(conversationKey instanceof Uint8Array) || conversationKey.length !== 32) throw new ValidationError('INVALID_CONVERSATION_KEY')
|
|
29
|
+
if (!(nonce instanceof Uint8Array) || nonce.length !== 32) throw new ValidationError('INVALID_NONCE')
|
|
30
|
+
const keys = expand(sha256, conversationKey, nonce, 76)
|
|
31
|
+
return { key: keys.subarray(0, 32), nonce: keys.subarray(32, 44), hmacKey: keys.subarray(44) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function calcPaddedLen (length) {
|
|
35
|
+
if (!Number.isSafeInteger(length) || length < minPlaintextSize || length > maxPlaintextSize) throw new ValidationError('INVALID_PLAINTEXT_SIZE')
|
|
36
|
+
if (length <= 32) return 32
|
|
37
|
+
const nextPower = 2 ** (Math.floor(Math.log2(length - 1)) + 1)
|
|
38
|
+
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
|
39
|
+
return chunk * (Math.floor((length - 1) / chunk) + 1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pad (plaintext) {
|
|
43
|
+
if (typeof plaintext !== 'string') throw new ValidationError('PLAINTEXT_SHOULD_BE_A_STRING')
|
|
44
|
+
const bytes = encoder.encode(plaintext)
|
|
45
|
+
const length = bytes.length
|
|
46
|
+
calcPaddedLen(length)
|
|
47
|
+
const prefixLength = length < 0x10000 ? 2 : 6
|
|
48
|
+
const prefix = new Uint8Array(prefixLength)
|
|
49
|
+
const view = new DataView(prefix.buffer)
|
|
50
|
+
if (prefixLength === 2) view.setUint16(0, length, false)
|
|
51
|
+
else view.setUint32(2, length, false)
|
|
52
|
+
return concatBytes(prefix, bytes, new Uint8Array(calcPaddedLen(length) - length))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function unpad (padded) {
|
|
56
|
+
if (!(padded instanceof Uint8Array) || padded.length < 2) throw new ValidationError('INVALID_PADDING')
|
|
57
|
+
const view = new DataView(padded.buffer, padded.byteOffset, padded.byteLength)
|
|
58
|
+
const shortLength = view.getUint16(0, false)
|
|
59
|
+
const prefixLength = shortLength === 0 ? 6 : 2
|
|
60
|
+
if (padded.length < prefixLength) throw new ValidationError('INVALID_PADDING')
|
|
61
|
+
const length = shortLength === 0 ? view.getUint32(2, false) : shortLength
|
|
62
|
+
if (shortLength === 0 && length < 0x10000) throw new ValidationError('INVALID_PADDING')
|
|
63
|
+
let expected
|
|
64
|
+
try { expected = prefixLength + calcPaddedLen(length) } catch (cause) { throw new ValidationError('INVALID_PADDING', { cause }) }
|
|
65
|
+
if (padded.length !== expected || prefixLength + length > padded.length) throw new ValidationError('INVALID_PADDING')
|
|
66
|
+
try { return decoder.decode(padded.subarray(prefixLength, prefixLength + length)) } catch (cause) { throw new ValidationError('INVALID_UTF8', { cause }) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function encodePayload (conversationKey, plaintext, nonce = randomBytes(32)) {
|
|
70
|
+
const messageKeys = getMessageKeys(conversationKey, nonce)
|
|
71
|
+
const ciphertext = chacha20(messageKeys.key, messageKeys.nonce, pad(plaintext))
|
|
72
|
+
const mac = hmac(sha256, messageKeys.hmacKey, concatBytes(nonce, ciphertext))
|
|
73
|
+
return bytesToBase64(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decodePayload (conversationKey, payload) {
|
|
77
|
+
if (typeof payload !== 'string' || payload.length < 132 || payload.length > maxEncodedPayloadSize || payload[0] === '#') throw new ValidationError('INVALID_NIP44_PAYLOAD')
|
|
78
|
+
if (payload.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(payload)) throw new ValidationError('INVALID_BASE64')
|
|
79
|
+
const data = base64ToBytes(payload)
|
|
80
|
+
if (bytesToBase64(data) !== payload || data.length < 99 || data[0] !== 2) throw new ValidationError('INVALID_NIP44_PAYLOAD')
|
|
81
|
+
const nonce = data.subarray(1, 33)
|
|
82
|
+
const ciphertext = data.subarray(33, -32)
|
|
83
|
+
const mac = data.subarray(-32)
|
|
84
|
+
const messageKeys = getMessageKeys(conversationKey, nonce)
|
|
85
|
+
const calculated = hmac(sha256, messageKeys.hmacKey, concatBytes(nonce, ciphertext))
|
|
86
|
+
if (!equalBytes(mac, calculated)) throw new ValidationError('INVALID_MAC')
|
|
87
|
+
return unpad(chacha20(messageKeys.key, messageKeys.nonce, ciphertext))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function extractConversationKey (sharedSecret, salt = 'nip44-v2') {
|
|
91
|
+
if (typeof salt !== 'string') throw new ValidationError('SALT_SHOULD_BE_A_STRING')
|
|
92
|
+
const saltBytes = encoder.encode(salt)
|
|
93
|
+
if (saltBytes.length === 0 || saltBytes.length > 32) throw new ValidationError('INVALID_SALT')
|
|
94
|
+
return extract(sha256, sharedSecret, saltBytes)
|
|
95
|
+
}
|
package/nip44/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { sharedXOnlySecret } from '../ecdh/index.js'
|
|
2
|
+
import { decodePayload, encodePayload, extractConversationKey } from './helpers.js'
|
|
3
|
+
|
|
4
|
+
export function getConversationKey (secretKey, pubkey, { salt = 'nip44-v2' } = {}) {
|
|
5
|
+
return extractConversationKey(sharedXOnlySecret(secretKey, pubkey), salt)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function encrypt (plaintext, conversationKey, nonce) {
|
|
9
|
+
return encodePayload(conversationKey, plaintext, nonce)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function decrypt (ciphertext, conversationKey) {
|
|
13
|
+
return decodePayload(conversationKey, ciphertext)
|
|
14
|
+
}
|
package/nip44-v3/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { concatBytes, utf8ToBytes } from '@noble/hashes/utils.js'
|
|
|
5
5
|
import { chacha20 } from '@noble/ciphers/chacha.js'
|
|
6
6
|
import { bytesToBase64, base64ToBytes } from '../base64/index.js'
|
|
7
7
|
import { sharedXOnlySecret } from '../ecdh/index.js'
|
|
8
|
+
import { ValidationError } from '../error/index.js'
|
|
8
9
|
|
|
9
10
|
// NIP-44 v3 — local implementation (spec.nostr.land/nip44v3)
|
|
10
11
|
// Copied from the bunker testbench and verified against the vendored
|
|
@@ -34,7 +35,7 @@ function readU32be (b, off) {
|
|
|
34
35
|
return new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(off, false)
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function
|
|
38
|
+
function areBytesEqual (a, b) {
|
|
38
39
|
if (a.length !== b.length) return false
|
|
39
40
|
let diff = 0
|
|
40
41
|
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
|
|
@@ -47,7 +48,15 @@ function randomBytes32 () {
|
|
|
47
48
|
return bytes
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
function assertBytes (value, code, length) {
|
|
52
|
+
if (!(value instanceof Uint8Array) || (length !== undefined && value.length !== length)) {
|
|
53
|
+
throw new ValidationError(code)
|
|
54
|
+
}
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
export function deriveKeys (seckey, pubkey, nonce) {
|
|
59
|
+
assertBytes(nonce, 'INVALID_NONCE', 32)
|
|
51
60
|
const shared = sharedXOnlySecret(seckey, pubkey)
|
|
52
61
|
const salt = concatBytes(utf8ToBytes('nip44-v3\x00'), nonce)
|
|
53
62
|
const prk = hkdfExtract(sha256, shared, salt)
|
|
@@ -71,6 +80,8 @@ export function payloadByteLength (plaintextByteLength, scopeByteLength = 0) {
|
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
export function deriveKeysFromConversationKey (conversationKey, nonce) {
|
|
83
|
+
assertBytes(conversationKey, 'INVALID_CONVERSATION_KEY', 32)
|
|
84
|
+
assertBytes(nonce, 'INVALID_NONCE', 32)
|
|
74
85
|
const salt = concatBytes(utf8ToBytes('nip44-v3\x00'), nonce)
|
|
75
86
|
const prk = hkdfExtract(sha256, conversationKey, salt)
|
|
76
87
|
return {
|
|
@@ -87,6 +98,11 @@ export function encryptBytes (seckey, pubkey, kind, scope, plaintext, nonce) {
|
|
|
87
98
|
|
|
88
99
|
export function encryptWithConversationKeyBytes (conversationKey, kind, scope, plaintext, nonce) {
|
|
89
100
|
nonce ??= randomBytes32()
|
|
101
|
+
assertBytes(conversationKey, 'INVALID_CONVERSATION_KEY', 32)
|
|
102
|
+
kind = normalizeKind(kind)
|
|
103
|
+
assertBytes(scope, 'INVALID_SCOPE')
|
|
104
|
+
assertBytes(plaintext, 'INVALID_PLAINTEXT')
|
|
105
|
+
assertBytes(nonce, 'INVALID_NONCE', 32)
|
|
90
106
|
const { encryption_key: encryptionKey, mac_key: macKey } = deriveKeysFromConversationKey(conversationKey, nonce)
|
|
91
107
|
const prefixed = concatBytes(u32be(plaintext.length), plaintext)
|
|
92
108
|
const padded = new Uint8Array(targetSize(prefixed.length))
|
|
@@ -102,34 +118,37 @@ export function decryptBytes (seckey, pubkey, expectedKind, expectedScope, ciphe
|
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
export function decryptWithConversationKeyBytes (conversationKey, expectedKind, expectedScope, ciphertext) {
|
|
105
|
-
|
|
106
|
-
|
|
121
|
+
assertBytes(conversationKey, 'INVALID_CONVERSATION_KEY', 32)
|
|
122
|
+
expectedKind = normalizeKind(expectedKind)
|
|
123
|
+
assertBytes(expectedScope, 'INVALID_SCOPE')
|
|
124
|
+
if (typeof ciphertext !== 'string' || ciphertext.length === 0) throw new ValidationError('EMPTY_CIPHERTEXT', { message: 'empty ciphertext' })
|
|
125
|
+
if (ciphertext[0] === '#') throw new ValidationError('UNSUPPORTED_NIP44_VERSION', { message: 'unsupported future version' })
|
|
107
126
|
let decoded
|
|
108
|
-
try { decoded = base64ToBytes(ciphertext) } catch { throw new
|
|
109
|
-
if (decoded.length < 77) throw new
|
|
110
|
-
if (decoded[0] !== VERSION) throw new
|
|
127
|
+
try { decoded = base64ToBytes(ciphertext) } catch (cause) { throw new ValidationError('INVALID_BASE64', { message: 'invalid base64', cause }) }
|
|
128
|
+
if (decoded.length < 77) throw new ValidationError('NIP44_CIPHERTEXT_TOO_SHORT', { message: 'ciphertext too short' })
|
|
129
|
+
if (decoded[0] !== VERSION) throw new ValidationError('UNSUPPORTED_NIP44_VERSION', { message: `unsupported version ${decoded[0]}` })
|
|
111
130
|
const nonce = decoded.subarray(1, 33)
|
|
112
131
|
const mac = decoded.subarray(33, 65)
|
|
113
132
|
const kind = readU32be(decoded, 65)
|
|
114
133
|
const scopeLength = readU32be(decoded, 69)
|
|
115
|
-
if (scopeLength > decoded.length - 73) throw new
|
|
134
|
+
if (scopeLength > decoded.length - 73) throw new ValidationError('INVALID_NIP44_SCOPE_LENGTH', { message: 'invalid scope length' })
|
|
116
135
|
const scope = decoded.subarray(73, 73 + scopeLength)
|
|
117
|
-
try { fatalTextDecoder.decode(scope) } catch { throw new
|
|
136
|
+
try { fatalTextDecoder.decode(scope) } catch (cause) { throw new ValidationError('INVALID_NIP44_SCOPE_UTF8', { message: 'scope is not valid UTF-8', cause }) }
|
|
118
137
|
const ct = decoded.subarray(73 + scopeLength)
|
|
119
|
-
if (ct.length < 4) throw new
|
|
120
|
-
if (kind !== expectedKind) throw new
|
|
121
|
-
if (!
|
|
138
|
+
if (ct.length < 4) throw new ValidationError('NIP44_CIPHERTEXT_TOO_SHORT', { message: 'ciphertext too short' })
|
|
139
|
+
if (kind !== expectedKind) throw new ValidationError('NIP44_KIND_MISMATCH', { message: `kind mismatch: got ${kind}, expected ${expectedKind}` })
|
|
140
|
+
if (!areBytesEqual(scope, expectedScope)) throw new ValidationError('NIP44_SCOPE_MISMATCH', { message: 'scope mismatch' })
|
|
122
141
|
const { encryption_key: encryptionKey, mac_key: macKey } = deriveKeysFromConversationKey(conversationKey, nonce)
|
|
123
142
|
const authData = concatBytes(nonce, u32be(kind), u32be(scope.length), scope, ct)
|
|
124
|
-
if (!
|
|
143
|
+
if (!areBytesEqual(mac, hmac(sha256, macKey, authData))) throw new ValidationError('INVALID_MAC', { message: 'invalid MAC' })
|
|
125
144
|
const padded = chacha(encryptionKey, ct)
|
|
126
145
|
const plaintextLength = readU32be(padded, 0)
|
|
127
|
-
if (plaintextLength + 4 > padded.length) throw new
|
|
128
|
-
if (plaintextLength > 2 ** 31 - 1) throw new
|
|
146
|
+
if (plaintextLength + 4 > padded.length) throw new ValidationError('INVALID_PLAINTEXT_LENGTH', { message: 'invalid plaintext length' })
|
|
147
|
+
if (plaintextLength > 2 ** 31 - 1) throw new ValidationError('PLAINTEXT_TOO_LONG', { message: 'plaintext too long' })
|
|
129
148
|
// Only verify the padding is all-zeroes. Per spec, implementations MUST NOT do any
|
|
130
149
|
// other check on the padding length — non-standard zero-padding must decrypt.
|
|
131
150
|
const padding = padded.subarray(4 + plaintextLength)
|
|
132
|
-
if (!
|
|
151
|
+
if (!areBytesEqual(padding, new Uint8Array(padding.length))) throw new ValidationError('INVALID_PADDING', { message: 'invalid padding' })
|
|
133
152
|
return padded.subarray(4, 4 + plaintextLength)
|
|
134
153
|
}
|
|
135
154
|
|
|
@@ -139,7 +158,7 @@ function deriveSharedConversationKey (seckey, pubkey) {
|
|
|
139
158
|
|
|
140
159
|
export function normalizeKind (kind) {
|
|
141
160
|
const n = typeof kind === 'string' && kind.trim() !== '' ? Number(kind) : kind
|
|
142
|
-
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new
|
|
161
|
+
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new ValidationError('INVALID_KIND')
|
|
143
162
|
return n
|
|
144
163
|
}
|
|
145
164
|
|
package/nip46/helpers/frame.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { finalizeEvent,
|
|
2
|
-
import { decrypt, encrypt, getConversationKey } from '
|
|
1
|
+
import { finalizeEvent, isValidEvent } from '../../event/index.js'
|
|
2
|
+
import { decrypt, encrypt, getConversationKey } from '../../nip44/index.js'
|
|
3
3
|
import { NIP46_KIND } from '../constants/index.js'
|
|
4
4
|
|
|
5
5
|
const PUBKEY = /^[0-9a-f]{64}$/
|
|
6
6
|
|
|
7
|
-
export function
|
|
7
|
+
export function isValidPubkey (value) {
|
|
8
8
|
return typeof value === 'string' && PUBKEY.test(value)
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -14,9 +14,9 @@ export function hasPTag (event, pubkey) {
|
|
|
14
14
|
|
|
15
15
|
export function isNip46EventFor (event, pubkey) {
|
|
16
16
|
return event?.kind === NIP46_KIND &&
|
|
17
|
-
|
|
17
|
+
isValidPubkey(event.pubkey) &&
|
|
18
18
|
hasPTag(event, pubkey) &&
|
|
19
|
-
|
|
19
|
+
isValidEvent(event)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function decodeNip46Frame (event, secretKey) {
|
|
@@ -38,7 +38,7 @@ export function createNip46Event ({ secretKey, recipientPubkey, payload }) {
|
|
|
38
38
|
}, secretKey)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function
|
|
41
|
+
export function isValidRequestFrame (frame) {
|
|
42
42
|
return typeof frame?.id === 'string' && frame.id &&
|
|
43
43
|
typeof frame.method === 'string' && frame.method &&
|
|
44
44
|
Array.isArray(frame.params) && frame.params.every(param => typeof param === 'string')
|