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.
Files changed (50) hide show
  1. package/README.md +112 -8
  2. package/base16/index.js +6 -3
  3. package/base36/index.js +12 -8
  4. package/base62/index.js +15 -11
  5. package/base64/index.js +18 -2
  6. package/base93/index.js +19 -7
  7. package/content-key/event/index.js +40 -12
  8. package/double-dh/index.js +21 -6
  9. package/ecdh/index.js +12 -1
  10. package/error/index.js +32 -0
  11. package/event/helpers/serialize.js +31 -0
  12. package/event/index.js +116 -0
  13. package/i18n/index.js +15 -13
  14. package/idb/index.js +5 -3
  15. package/idb-queue/index.js +21 -20
  16. package/index.js +10 -0
  17. package/key/index.js +31 -18
  18. package/kind/index.js +244 -0
  19. package/nip04/index.js +47 -0
  20. package/nip05/index.js +61 -0
  21. package/nip19/index.js +176 -43
  22. package/nip44/helpers.js +95 -0
  23. package/nip44/index.js +14 -0
  24. package/nip44-v3/index.js +35 -16
  25. package/nip46/helpers/frame.js +6 -6
  26. package/nip46/helpers/url.js +8 -6
  27. package/nip46/services/bunker-signer.js +7 -6
  28. package/nip46/services/client.js +8 -7
  29. package/nip46/services/server-session.js +8 -7
  30. package/nip46/services/transport.js +9 -8
  31. package/nip96/index.js +285 -0
  32. package/nip98/index.js +56 -0
  33. package/nwt/index.js +241 -0
  34. package/package.json +22 -3
  35. package/private-channel/helpers/chunks.js +7 -6
  36. package/private-channel/helpers/event.js +5 -4
  37. package/private-channel/index.js +63 -61
  38. package/private-channel/services/received-chunks.js +4 -3
  39. package/private-message/index.js +32 -31
  40. package/private-messenger/index.js +313 -54
  41. package/private-messenger/recovery/index.js +15 -14
  42. package/private-messenger/services/channel-state.js +28 -7
  43. package/private-messenger/services/storage-maintenance.js +142 -46
  44. package/relay/helpers/hll.js +3 -1
  45. package/relay/services/query.js +3 -3
  46. package/relay/services/relay-connection.js +222 -121
  47. package/relay/services/relay-pool.js +13 -10
  48. package/temporary-storage/index.js +3 -1
  49. package/url/index.js +131 -0
  50. 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 new Error(`Invalid ${fieldName}`)
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 new Error(`${fieldName} should be ${byteLength} bytes`)
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 new Error(`${fieldName} should be a non-empty string`)
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 new Error(`${fieldName} is too big`)
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 new Error(`Invalid ${fieldName} UTF-8`)
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 new Error(`Invalid ${fieldName} length`)
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 new Error(`Invalid ${fieldName} UTF-8`)
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 new Error('Invalid TLV type')
92
- if (!(value instanceof Uint8Array)) throw new TypeError('TLV value should be a Uint8Array')
93
- if (value.length > MAX_TLV_VALUE_SIZE) throw new Error('TLV value is too big')
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 new Error('Truncated TLV header')
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 new Error(`Truncated TLV value for type ${type}`)
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 new Error(`Duplicate ${fieldName}`)
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 new TypeError('relays should be an array')
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 new Error('nfile should use canonical lowercase Bech32')
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 new Error(`Invalid nfile: ${error.message}`)
200
+ throw invalid('INVALID_NFILE', `Invalid nfile: ${error.message}`, error)
143
201
  }
144
- if (decoded.prefix !== 'nfile') throw new Error('Invalid nfile prefix')
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 new Error(`Invalid nfile data: ${error.message}`)
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 new Error('Missing MMR root')
158
- if (rootBytes.length !== 32) throw new Error('MMR root should be 32 bytes')
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 new Error('Author hint should be 32 bytes')
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.dTag)) throw new Error('Invalid deduplication tag')
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 new Error('Wrong channel')
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 new Error('App entity is too big')
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 new Error('Invalid app entity')
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 new Error('Invalid channel')
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 new Error('Missing deduplication tag')
217
- if (!pubkeyBytes) throw new Error('Missing author pubkey')
218
- if (pubkeyBytes.length !== 32) throw new Error('Author pubkey should be 32 bytes')
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 new Error('Invalid deduplication tag')
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 decoded = bech32.decode(entity)
253
- if (decoded.prefix !== prefix) throw new Error(`Invalid ${prefix} prefix`)
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
- throw new Error(`Failed to decode ${prefix}: ${error.message}`)
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
  }
@@ -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 equalBytes (a, b) {
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
- if (!ciphertext || ciphertext.length === 0) throw new Error('empty ciphertext')
106
- if (ciphertext[0] === '#') throw new Error('unsupported future version')
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 Error('invalid base64') }
109
- if (decoded.length < 77) throw new Error('ciphertext too short')
110
- if (decoded[0] !== VERSION) throw new Error(`unsupported version ${decoded[0]}`)
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 Error('invalid scope length')
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 Error('scope is not valid UTF-8') }
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 Error('ciphertext too short')
120
- if (kind !== expectedKind) throw new Error(`kind mismatch: got ${kind}, expected ${expectedKind}`)
121
- if (!equalBytes(scope, expectedScope)) throw new Error('scope mismatch')
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 (!equalBytes(mac, hmac(sha256, macKey, authData))) throw new Error('invalid MAC')
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 Error('invalid plaintext length')
128
- if (plaintextLength > 2 ** 31 - 1) throw new Error('plaintext too long')
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 (!equalBytes(padding, new Uint8Array(padding.length))) throw new Error('invalid padding')
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 Error('INVALID_KIND')
161
+ if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new ValidationError('INVALID_KIND')
143
162
  return n
144
163
  }
145
164
 
@@ -1,10 +1,10 @@
1
- import { finalizeEvent, verifyEvent } from 'nostr-tools'
2
- import { decrypt, encrypt, getConversationKey } from 'nostr-tools/nip44'
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 validPubkey (value) {
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
- validPubkey(event.pubkey) &&
17
+ isValidPubkey(event.pubkey) &&
18
18
  hasPTag(event, pubkey) &&
19
- verifyEvent(event)
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 validRequestFrame (frame) {
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')