libp2r2p 0.8.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 (49) hide show
  1. package/README.md +83 -2
  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 +23 -22
  41. package/private-messenger/recovery/index.js +15 -14
  42. package/private-messenger/services/channel-state.js +2 -1
  43. package/relay/helpers/hll.js +3 -1
  44. package/relay/services/query.js +3 -3
  45. package/relay/services/relay-connection.js +222 -121
  46. package/relay/services/relay-pool.js +13 -10
  47. package/temporary-storage/index.js +3 -1
  48. package/url/index.js +131 -0
  49. package/web-storage-queue/index.js +8 -6
package/README.md CHANGED
@@ -91,7 +91,7 @@ await messenger.tell({
91
91
  ```
92
92
 
93
93
  ```js
94
- import { finalizeEvent } from 'nostr-tools'
94
+ import { finalizeEvent } from 'libp2r2p/event'
95
95
  import { keypairFromSeckey } from 'libp2r2p/key'
96
96
  import { relayPool } from 'libp2r2p/relay'
97
97
 
@@ -149,7 +149,7 @@ PrivateMessenger.maintainStorage().catch(console.warn)
149
149
 
150
150
  Maintenance removes interrupted-send staging, expired receive chunks, and
151
151
  storage belonging to inactive principal identities. It also
152
- resumes any interrupted database-set deletion. An application does not need to
152
+ resumes any interrupted storage-set deletion. An application does not need to
153
153
  know database names or enumerate IndexedDB. Pass `temporaryStorageArea` only
154
154
  when the messenger was configured to use a Storage area other than the default
155
155
  `sessionStorage`.
@@ -245,6 +245,87 @@ Use explicit subpath imports for bundle size. The package root re-exports the
245
245
  main messenger API for convenience, but applications that only need one piece
246
246
  should import that subpath directly.
247
247
 
248
+ ## Nostr primitives
249
+
250
+ The modern stack can use the package without `nostr-tools`. Its intentionally
251
+ small public surface includes strict, non-caching NIP-01 helpers, NIP-04 for
252
+ legacy interoperability, NIP-44 v2, key helpers, event-kind classification,
253
+ NIP-05 lookup, NIP-96 compatibility, NIP-98 authorization, Nostr Web Tokens,
254
+ and relay URL normalization:
255
+
256
+ ```js
257
+ import {
258
+ assertSerializableEvent,
259
+ assertValidEvent,
260
+ finalizeEvent,
261
+ isSerializableEvent,
262
+ isValidEvent
263
+ } from 'libp2r2p/event'
264
+ import { generateSecretKey, getPublicKey } from 'libp2r2p/key'
265
+ import { eventKinds, classifyKind } from 'libp2r2p/kind'
266
+ import * as nip44 from 'libp2r2p/nip44'
267
+ import { assertValidPublicRelayUrl, normalizeRelayUrl } from 'libp2r2p/url'
268
+ ```
269
+
270
+ `classifyEvent()` from `libp2r2p/event` combines the exact NIP-01 kind
271
+ ranges with tag-defined behavior. The first `d` tag may add `replaceable` or
272
+ `addressable`, while an `expiration` tag equal to `created_at` adds
273
+ `ephemeral`. An event is also regular when it is neither replaceable nor
274
+ addressable. Classifications are additive and callers can disable the legacy
275
+ kind ranges with `{ includeLegacyKindRanges: false }`.
276
+
277
+ NIP-44 v2 uses the interoperable `nip44-v2` salt by default. A custom UTF-8
278
+ salt of at most 32 bytes may be passed to `getConversationKey()`, but messages
279
+ derived with it are not interoperable with standard NIP-44 implementations.
280
+
281
+ Nostr Web Tokens are available from `libp2r2p/nwt`. Creation returns a signed
282
+ kind `27519` event, while transport encoding is kept separate:
283
+
284
+ ```js
285
+ import { createToken, encodeToken, validateToken } from 'libp2r2p/nwt'
286
+
287
+ const event = await createToken({
288
+ signEvent,
289
+ audience: ['api.example.com'],
290
+ expiration: Math.floor(Date.now() / 1000) + 300,
291
+ claims: [['action', 'upload']],
292
+ content: 'Authorize an upload'
293
+ })
294
+ const authorization = encodeToken(event, { includeAuthorizationScheme: true })
295
+ const claims = validateToken(authorization, { audience: 'api.example.com' })
296
+ ```
297
+
298
+ Transport decoding requires canonical unpadded Base64URL. Validation verifies
299
+ the Nostr signature on every call, enforces registered-claim cardinality and
300
+ time bounds, and requires the verifier to provide its identity whenever an
301
+ `aud` claim is present. Tokens without `aud` or `exp` retain the draft
302
+ specification's public/unbounded defaults; servers can reject those forms with
303
+ `requireAudience` and `requireExpiration`.
304
+
305
+ The NIP-96 module is provided only for interoperability with older file
306
+ servers. New applications should prefer NIP-B7. Its upload API accepts an
307
+ `AbortSignal` and a ProgressEvent-compatible callback; browsers use XHR for
308
+ real upload progress when available, while the fetch fallback reports only
309
+ estimated start and successful completion.
310
+
311
+ `isSerializableEvent()` checks only the NIP-01 fields used during
312
+ serialization. `isValidEvent()` additionally recalculates the ID and verifies
313
+ the Schnorr signature on every call; it never adds a cache marker to the
314
+ event. Their `assert…` counterparts return the original event or throw a
315
+ `ValidationError` with a stable code.
316
+
317
+ Public validity checks consistently use a non-throwing `is…` predicate plus an
318
+ `assert…` counterpart when callers need the exact reason. Strict codecs,
319
+ decoders, token validation, and malformed public arguments also throw
320
+ `ValidationError` from `libp2r2p/error`. Network, timeout, abort, quota, and
321
+ closed-state failures remain ordinary operational errors.
322
+
323
+ NIP-04 remains available at
324
+ `libp2r2p/nip04` only for compatibility with older Nostr applications.
325
+ Low-level relay sockets, subscriptions, message parsing, and serialization are
326
+ internal implementation details; use `RelayPool` or the `relayPool` singleton
327
+ from `libp2r2p/relay`.
328
+
248
329
  ## Internationalization
249
330
 
250
331
  The dependency-free `libp2r2p/i18n` subpath exposes locale detection and a
package/base16/index.js CHANGED
@@ -1,13 +1,16 @@
1
+ import { ValidationError } from '../error/index.js'
2
+
1
3
  export function bytesToBase16 (bytes) {
4
+ if (!bytes || typeof bytes[Symbol.iterator] !== 'function') throw new ValidationError('INVALID_BYTE_ARRAY')
2
5
  let s = ''
3
6
  for (const b of bytes) s += b.toString(16).padStart(2, '0')
4
7
  return s
5
8
  }
6
9
 
7
10
  export function base16ToBytes (base16) {
8
- if (typeof base16 !== 'string') throw new TypeError('Base16 value should be a string')
9
- if (base16.length % 2 !== 0) throw new Error('Invalid Base16 length')
10
- if (!/^[0-9a-f]*$/i.test(base16)) throw new Error('Invalid Base16 character')
11
+ if (typeof base16 !== 'string') throw new ValidationError('INVALID_BASE16_TYPE', { message: 'Base16 value should be a string' })
12
+ if (base16.length % 2 !== 0) throw new ValidationError('INVALID_BASE16_LENGTH', { message: 'Invalid Base16 length' })
13
+ if (!/^[0-9a-f]*$/i.test(base16)) throw new ValidationError('INVALID_BASE16_CHARACTER', { message: 'Invalid Base16 character' })
11
14
  const out = new Uint8Array(base16.length / 2)
12
15
  for (let i = 0; i < out.length; i++) {
13
16
  out[i] = Number.parseInt(base16.slice(i * 2, i * 2 + 2), 16)
package/base36/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { base16ToBytes, bytesToBase16 } from '../base16/index.js'
2
+ import { ValidationError } from '../error/index.js'
2
3
 
3
4
  export const BASE36_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
4
5
 
@@ -30,7 +31,7 @@ function parseBase36Integer (value) {
30
31
  let number = 0n
31
32
  for (const character of value) {
32
33
  const digit = CHAR_MAP.get(character)
33
- if (digit === undefined) throw new Error('Invalid Base36 character: ' + character)
34
+ if (digit === undefined) throw new ValidationError('INVALID_BASE36_CHARACTER', { message: 'Invalid Base36 character: ' + character })
34
35
  number = number * BASE + digit
35
36
  }
36
37
  return number
@@ -49,6 +50,9 @@ function integerToMinimalBytes (number) {
49
50
  // A binary-safe Base36 codec. Every leading zero byte is represented by a
50
51
  // leading zero character, making arbitrary byte arrays round-trip exactly.
51
52
  export function bytesToBase36 (bytes) {
53
+ if (!bytes || !Number.isSafeInteger(bytes.length) || typeof bytes[Symbol.iterator] !== 'function') {
54
+ throw new ValidationError('INVALID_BYTE_ARRAY')
55
+ }
52
56
  if (bytes.length === 0) return ''
53
57
 
54
58
  const number = bytesToInteger(bytes)
@@ -60,7 +64,7 @@ export function bytesToBase36 (bytes) {
60
64
  }
61
65
 
62
66
  export function base36ToBytes (value) {
63
- if (typeof value !== 'string') throw new TypeError('Base36 value should be a string')
67
+ if (typeof value !== 'string') throw new ValidationError('INVALID_BASE36_TYPE', { message: 'Base36 value should be a string' })
64
68
  if (value.length === 0) return new Uint8Array()
65
69
 
66
70
  let leadingZeros = 0
@@ -84,21 +88,21 @@ export function base36ToBase16 (value) {
84
88
  // as exactly 50 lowercase Base36 digits. Its leading zeros are numeric width,
85
89
  // not zero-byte markers as they are in the binary-safe codec above.
86
90
  export function bytesToBase36Nsite (bytes) {
87
- if (bytes.length !== NSITE_BYTE_LENGTH) {
88
- throw new Error('Nsite Base36 input should be ' + NSITE_BYTE_LENGTH + ' bytes')
91
+ if (!bytes || bytes.length !== NSITE_BYTE_LENGTH || typeof bytes[Symbol.iterator] !== 'function') {
92
+ throw new ValidationError('INVALID_NSITE_BASE36_BYTE_LENGTH', { message: 'Nsite Base36 input should be ' + NSITE_BYTE_LENGTH + ' bytes' })
89
93
  }
90
94
  return integerToBase36(bytesToInteger(bytes)).padStart(NSITE_TEXT_LENGTH, LEADER)
91
95
  }
92
96
 
93
97
  export function base36NsiteToBytes (value) {
94
- if (typeof value !== 'string') throw new TypeError('Nsite Base36 value should be a string')
98
+ if (typeof value !== 'string') throw new ValidationError('INVALID_NSITE_BASE36_TYPE', { message: 'Nsite Base36 value should be a string' })
95
99
  if (value.length !== NSITE_TEXT_LENGTH) {
96
- throw new Error('Nsite Base36 value should be ' + NSITE_TEXT_LENGTH + ' characters')
100
+ throw new ValidationError('INVALID_NSITE_BASE36_LENGTH', { message: 'Nsite Base36 value should be ' + NSITE_TEXT_LENGTH + ' characters' })
97
101
  }
98
- if (!/^[0-9a-z]+$/.test(value)) throw new Error('Invalid Nsite Base36 character')
102
+ if (!/^[0-9a-z]+$/.test(value)) throw new ValidationError('INVALID_NSITE_BASE36_CHARACTER', { message: 'Invalid Nsite Base36 character' })
99
103
 
100
104
  const number = parseBase36Integer(value)
101
- if (number > MAX_NSITE_VALUE) throw new Error('Nsite Base36 value exceeds 32 bytes')
105
+ if (number > MAX_NSITE_VALUE) throw new ValidationError('NSITE_BASE36_OVERFLOW', { message: 'Nsite Base36 value exceeds 32 bytes' })
102
106
  const bytes = integerToMinimalBytes(number)
103
107
  const result = new Uint8Array(NSITE_BYTE_LENGTH)
104
108
  if (number !== 0n) result.set(bytes, NSITE_BYTE_LENGTH - bytes.length)
package/base62/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { base16ToBytes, bytesToBase16 } from '../base16/index.js'
2
+ import { ValidationError } from '../error/index.js'
2
3
 
3
4
  export const BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
4
5
 
@@ -11,16 +12,16 @@ const CHAR_MAP = new Map(
11
12
  function readOptions (options, allowedKeys) {
12
13
  if (options === undefined) return {}
13
14
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
14
- throw new TypeError('Base62 options should be an object')
15
+ throw new ValidationError('INVALID_BASE62_OPTIONS', { message: 'Base62 options should be an object' })
15
16
  }
16
17
  for (const key of Object.keys(options)) {
17
- if (!allowedKeys.includes(key)) throw new Error('Unknown Base62 option: ' + key)
18
+ if (!allowedKeys.includes(key)) throw new ValidationError('UNKNOWN_BASE62_OPTION', { message: 'Unknown Base62 option: ' + key })
18
19
  }
19
20
  return options
20
21
  }
21
22
 
22
23
  function readMode (mode = 'bytes') {
23
- if (mode !== 'bytes' && mode !== 'integer') throw new Error('Invalid Base62 mode: ' + mode)
24
+ if (mode !== 'bytes' && mode !== 'integer') throw new ValidationError('INVALID_BASE62_MODE', { message: 'Invalid Base62 mode: ' + mode })
24
25
  return mode
25
26
  }
26
27
 
@@ -28,7 +29,7 @@ function readLength (value, name, defaultValue, minimum = 0) {
28
29
  if (value === undefined) return defaultValue
29
30
  if (!Number.isSafeInteger(value) || value < minimum) {
30
31
  const qualifier = minimum === 0 ? 'non-negative' : 'positive'
31
- throw new RangeError('Base62 ' + name + ' should be a ' + qualifier + ' safe integer')
32
+ throw new ValidationError('INVALID_BASE62_LENGTH', { message: 'Base62 ' + name + ' should be a ' + qualifier + ' safe integer' })
32
33
  }
33
34
  return value
34
35
  }
@@ -52,7 +53,7 @@ function parseBase62Integer (value) {
52
53
  let number = 0n
53
54
  for (const character of value) {
54
55
  const digit = CHAR_MAP.get(character)
55
- if (digit === undefined) throw new Error('Invalid Base62 character: ' + character)
56
+ if (digit === undefined) throw new ValidationError('INVALID_BASE62_CHARACTER', { message: 'Invalid Base62 character: ' + character })
56
57
  number = number * BASE + digit
57
58
  }
58
59
  return number
@@ -71,13 +72,16 @@ function integerToMinimalBytes (number) {
71
72
  // Byte mode preserves every leading zero byte. Integer mode treats the input
72
73
  // as an unsigned big-endian value and supports fixed-width textual output.
73
74
  export function bytesToBase62 (bytes, options) {
75
+ if (!bytes || !Number.isSafeInteger(bytes.length) || typeof bytes[Symbol.iterator] !== 'function') {
76
+ throw new ValidationError('INVALID_BYTE_ARRAY')
77
+ }
74
78
  const { mode: rawMode, minLength: rawMinLength } = readOptions(options, ['mode', 'minLength'])
75
79
  const mode = readMode(rawMode)
76
80
  if (mode === 'bytes' && rawMinLength !== undefined) {
77
- throw new Error('Base62 minLength requires integer mode')
81
+ throw new ValidationError('INVALID_BASE62_MIN_LENGTH_MODE', { message: 'Base62 minLength requires integer mode' })
78
82
  }
79
83
  if (mode === 'integer') {
80
- if (bytes.length === 0) throw new Error('Base62 integer input should not be empty')
84
+ if (bytes.length === 0) throw new ValidationError('EMPTY_BASE62_INTEGER', { message: 'Base62 integer input should not be empty' })
81
85
  const minLength = readLength(rawMinLength, 'minLength', 0)
82
86
  return integerToBase62(bytesToInteger(bytes)).padStart(minLength, LEADER)
83
87
  }
@@ -92,18 +96,18 @@ export function bytesToBase62 (bytes, options) {
92
96
  }
93
97
 
94
98
  export function base62ToBytes (value, options) {
95
- if (typeof value !== 'string') throw new TypeError('Base62 value should be a string')
99
+ if (typeof value !== 'string') throw new ValidationError('INVALID_BASE62_TYPE', { message: 'Base62 value should be a string' })
96
100
  const { mode: rawMode, byteLength: rawByteLength } = readOptions(options, ['mode', 'byteLength'])
97
101
  const mode = readMode(rawMode)
98
102
  if (mode === 'bytes' && rawByteLength !== undefined) {
99
- throw new Error('Base62 byteLength requires integer mode')
103
+ throw new ValidationError('INVALID_BASE62_BYTE_LENGTH_MODE', { message: 'Base62 byteLength requires integer mode' })
100
104
  }
101
105
  if (mode === 'integer') {
102
- if (value.length === 0) throw new Error('Base62 integer value should not be empty')
106
+ if (value.length === 0) throw new ValidationError('EMPTY_BASE62_INTEGER', { message: 'Base62 integer value should not be empty' })
103
107
  const byteLength = readLength(rawByteLength, 'byteLength', undefined, 1)
104
108
  const bytes = integerToMinimalBytes(parseBase62Integer(value))
105
109
  if (byteLength === undefined) return bytes
106
- if (bytes.length > byteLength) throw new Error('Base62 integer exceeds ' + byteLength + ' bytes')
110
+ if (bytes.length > byteLength) throw new ValidationError('BASE62_INTEGER_OVERFLOW', { message: 'Base62 integer exceeds ' + byteLength + ' bytes' })
107
111
  const result = new Uint8Array(byteLength)
108
112
  result.set(bytes, byteLength - bytes.length)
109
113
  return result
package/base64/index.js CHANGED
@@ -2,7 +2,12 @@
2
2
  // Plain base64 is used where a protocol expects standard base64. URL-safe
3
3
  // base64 is useful for WebAuthn credential IDs and URL/query-string material.
4
4
 
5
+ import { ValidationError } from '../error/index.js'
6
+
5
7
  export function bytesToBase64 (bytes) {
8
+ if (!bytes || !Number.isSafeInteger(bytes.length) || typeof bytes[Symbol.iterator] !== 'function') {
9
+ throw new ValidationError('INVALID_BYTE_ARRAY')
10
+ }
6
11
  if (typeof Buffer === 'function' && typeof Buffer.from === 'function') {
7
12
  return Buffer.from(bytes).toString('base64')
8
13
  }
@@ -12,7 +17,13 @@ export function bytesToBase64 (bytes) {
12
17
  }
13
18
 
14
19
  export function base64ToBytes (b64) {
15
- const bin = atob(b64)
20
+ if (typeof b64 !== 'string') throw new ValidationError('INVALID_BASE64_TYPE', { message: 'Base64 value should be a string' })
21
+ let bin
22
+ try {
23
+ bin = atob(b64)
24
+ } catch (cause) {
25
+ throw new ValidationError('INVALID_BASE64', { cause })
26
+ }
16
27
  const out = new Uint8Array(bin.length)
17
28
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
18
29
  return out
@@ -26,7 +37,12 @@ export function bytesToBase64Url (bytes) {
26
37
  }
27
38
 
28
39
  export function base64UrlToBytes (base64url) {
40
+ if (typeof base64url !== 'string') throw new ValidationError('INVALID_BASE64URL_TYPE', { message: 'Base64URL value should be a string' })
29
41
  const value = String(base64url)
30
42
  const pad = value.length % 4 === 0 ? '' : '='.repeat(4 - (value.length % 4))
31
- return base64ToBytes(value.replace(/-/g, '+').replace(/_/g, '/') + pad)
43
+ try {
44
+ return base64ToBytes(value.replace(/-/g, '+').replace(/_/g, '/') + pad)
45
+ } catch (cause) {
46
+ throw new ValidationError('INVALID_BASE64URL', { cause })
47
+ }
32
48
  }
package/base93/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // Apache-2.0. JSON-safe alphabet: space is included; double quote and
3
3
  // backslash are intentionally excluded.
4
4
 
5
+ import { ValidationError } from '../error/index.js'
6
+
5
7
  export const BASE93_ALPHABET =
6
8
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&'()*+,-./:;<=>?@[]^_`{|}~ "
7
9
 
@@ -34,7 +36,11 @@ function asBytes (bytes) {
34
36
  if (bytes instanceof Uint8Array) return bytes
35
37
  if (ArrayBuffer.isView(bytes)) return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
36
38
  if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes)
37
- return Uint8Array.from(bytes)
39
+ try {
40
+ return Uint8Array.from(bytes)
41
+ } catch (cause) {
42
+ throw new ValidationError('INVALID_BYTE_ARRAY', { cause })
43
+ }
38
44
  }
39
45
 
40
46
  export class Base93Encoder {
@@ -110,7 +116,7 @@ function decodeUnchecked (value) {
110
116
  for (let i = 0; i < value.length; i++) {
111
117
  const code = value.charCodeAt(i)
112
118
  if (code >= DECODING_TABLE.length || DECODING_TABLE[code] < 0) {
113
- throw new Error(`Invalid Base93 character at offset ${i}.`)
119
+ throw new ValidationError('INVALID_BASE93_CHARACTER', { message: `Invalid Base93 character at offset ${i}.` })
114
120
  }
115
121
  const decoded = DECODING_TABLE[code]
116
122
  if (first === -1) {
@@ -134,14 +140,20 @@ function decodeUnchecked (value) {
134
140
  }
135
141
 
136
142
  export function decode (text, offset = 0, length = -1) {
137
- if (typeof text !== 'string') throw new TypeError('Base93 input must be a string.')
138
- if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length) throw new RangeError('Invalid Base93 offset.')
139
- if (!Number.isSafeInteger(length) || length < -1) throw new RangeError('Invalid Base93 length.')
143
+ if (typeof text !== 'string') throw new ValidationError('INVALID_BASE93_TYPE', { message: 'Base93 input must be a string.' })
144
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length) {
145
+ throw new ValidationError('INVALID_BASE93_OFFSET', { message: 'Invalid Base93 offset.' })
146
+ }
147
+ if (!Number.isSafeInteger(length) || length < -1) {
148
+ throw new ValidationError('INVALID_BASE93_LENGTH', { message: 'Invalid Base93 length.' })
149
+ }
140
150
 
141
151
  const end = length < 0 ? text.length : Math.min(text.length, offset + length)
142
152
  const encoded = text.slice(offset, end)
143
153
  const bytes = decodeUnchecked(encoded)
144
- if (encode(bytes) !== encoded) throw new Error('Non-canonical or truncated Base93 input.')
154
+ if (encode(bytes) !== encoded) {
155
+ throw new ValidationError('NON_CANONICAL_BASE93', { message: 'Non-canonical or truncated Base93 input.' })
156
+ }
145
157
  return bytes
146
158
  }
147
159
 
@@ -149,7 +161,7 @@ function normalizeSource (source) {
149
161
  if (typeof source === 'function') source = source()
150
162
  if (typeof source === 'string') return [source]
151
163
  if (source?.[Symbol.asyncIterator] || source?.[Symbol.iterator]) return source
152
- throw new TypeError('Base93 stream source must be iterable.')
164
+ throw new ValidationError('INVALID_BASE93_STREAM_SOURCE', { message: 'Base93 stream source must be iterable.' })
153
165
  }
154
166
 
155
167
  export class Base93Decoder {
@@ -1,8 +1,9 @@
1
- import { getEventHash, verifyEvent } from 'nostr-tools'
1
+ import { ValidationError } from '../../error/index.js'
2
+ import { getEventHash, isValidEvent } from '../../event/index.js'
2
3
 
3
4
  export const CONTENT_KEY_KIND = 18716
4
5
 
5
- const HEX_PUBKEY = /^[0-9a-f]{64}$/i
6
+ const HEX_PUBKEY = /^[0-9a-f]{64}$/
6
7
  const HEX_SIG = /^[0-9a-f]{128}$/i
7
8
 
8
9
  function nowSeconds () {
@@ -10,8 +11,8 @@ function nowSeconds () {
10
11
  }
11
12
 
12
13
  export async function makeContentKeyEventForPubkey ({ userSigner, contentPubkey, createdAt = nowSeconds() }) {
13
- if (!userSigner?.getPublicKey || !userSigner?.signEvent) throw new Error('USER_SIGNER_REQUIRED')
14
- if (!HEX_PUBKEY.test(contentPubkey || '')) throw new Error('CONTENT_PUBKEY_REQUIRED')
14
+ if (!userSigner?.getPublicKey || !userSigner?.signEvent) throw new ValidationError('USER_SIGNER_REQUIRED')
15
+ if (!HEX_PUBKEY.test(contentPubkey || '')) throw new ValidationError('CONTENT_PUBKEY_REQUIRED')
15
16
 
16
17
  return userSigner.signEvent({
17
18
  kind: CONTENT_KEY_KIND,
@@ -22,7 +23,7 @@ export async function makeContentKeyEventForPubkey ({ userSigner, contentPubkey,
22
23
  }
23
24
 
24
25
  export async function makeContentKeyEvent ({ userSigner, contentKeySigner, createdAt = nowSeconds() }) {
25
- if (!contentKeySigner?.getPublicKey) throw new Error('CONTENT_KEY_SIGNER_REQUIRED')
26
+ if (!contentKeySigner?.getPublicKey) throw new ValidationError('CONTENT_KEY_SIGNER_REQUIRED')
26
27
  return makeContentKeyEventForPubkey({
27
28
  userSigner,
28
29
  contentPubkey: await contentKeySigner.getPublicKey(),
@@ -34,7 +35,7 @@ export function parseContentKeyEvent (event) {
34
35
  if (!event || event.kind !== CONTENT_KEY_KIND || event.content !== '') return null
35
36
  if (!HEX_PUBKEY.test(event.pubkey) || !Number.isSafeInteger(event.created_at)) return null
36
37
  if (!Array.isArray(event.tags) || event.tags.length !== 1) return null
37
- if (event.id !== getEventHash(event) || !verifyEvent(event)) return null
38
+ if (!isValidEvent(event)) return null
38
39
 
39
40
  const [name, contentPubkey, ...rest] = event.tags[0] || []
40
41
  if (name !== 'cp' || rest.length || !HEX_PUBKEY.test(contentPubkey || '')) return null
@@ -59,10 +60,11 @@ function parseContentKeyProof (proof) {
59
60
  return { created_at, sig }
60
61
  }
61
62
 
62
- export function verifyContentKeyProof ({ ownerPubkey, contentPubkey, proof }) {
63
- if (!HEX_PUBKEY.test(ownerPubkey || '') || !HEX_PUBKEY.test(contentPubkey || '')) return false
63
+ function contentKeyProofError ({ ownerPubkey, contentPubkey, proof }) {
64
+ if (!HEX_PUBKEY.test(ownerPubkey || '')) return 'INVALID_CONTENT_KEY_OWNER_PUBKEY'
65
+ if (!HEX_PUBKEY.test(contentPubkey || '')) return 'INVALID_CONTENT_KEY_PUBKEY'
64
66
  const parsed = parseContentKeyProof(proof)
65
- if (!parsed) return false
67
+ if (!parsed) return 'INVALID_CONTENT_KEY_PROOF'
66
68
 
67
69
  const event = {
68
70
  kind: CONTENT_KEY_KIND,
@@ -73,13 +75,39 @@ export function verifyContentKeyProof ({ ownerPubkey, contentPubkey, proof }) {
73
75
  sig: parsed.sig
74
76
  }
75
77
  event.id = getEventHash(event)
76
- return verifyEvent(event)
78
+ return isValidEvent(event) ? null : 'INVALID_CONTENT_KEY_PROOF_SIGNATURE'
77
79
  }
78
80
 
79
- export function verifyIykcProof ({ receiverPubkey, iykcPubkey, iykcProof }) {
80
- return verifyContentKeyProof({
81
+ function iykcProofError ({ receiverPubkey, iykcPubkey, iykcProof }) {
82
+ const error = contentKeyProofError({
81
83
  ownerPubkey: receiverPubkey,
82
84
  contentPubkey: iykcPubkey,
83
85
  proof: iykcProof
84
86
  })
87
+ return {
88
+ INVALID_CONTENT_KEY_OWNER_PUBKEY: 'INVALID_IYKC_RECEIVER_PUBKEY',
89
+ INVALID_CONTENT_KEY_PUBKEY: 'INVALID_IYKC_PUBKEY',
90
+ INVALID_CONTENT_KEY_PROOF: 'INVALID_IYKC_PROOF',
91
+ INVALID_CONTENT_KEY_PROOF_SIGNATURE: 'INVALID_IYKC_PROOF_SIGNATURE'
92
+ }[error] ?? null
93
+ }
94
+
95
+ export function isValidContentKeyProof (value) {
96
+ return contentKeyProofError(value ?? {}) === null
97
+ }
98
+
99
+ export function assertValidContentKeyProof (value) {
100
+ const code = contentKeyProofError(value ?? {})
101
+ if (code) throw new ValidationError(code)
102
+ return value
103
+ }
104
+
105
+ export function isValidIykcProof (value) {
106
+ return iykcProofError(value ?? {}) === null
107
+ }
108
+
109
+ export function assertValidIykcProof (value) {
110
+ const code = iykcProofError(value ?? {})
111
+ if (code) throw new ValidationError(code)
112
+ return value
85
113
  }
@@ -1,7 +1,9 @@
1
+ import { secp256k1 } from '@noble/curves/secp256k1.js'
1
2
  import { extract, expand } from '@noble/hashes/hkdf.js'
2
3
  import { sha256 } from '@noble/hashes/sha2.js'
3
4
  import { concatBytes } from '@noble/hashes/utils.js'
4
5
  import { sharedXOnlySecret } from '../ecdh/index.js'
6
+ import { ValidationError } from '../error/index.js'
5
7
 
6
8
  const textEncoder = new TextEncoder()
7
9
 
@@ -57,7 +59,7 @@ function u32be (n) {
57
59
 
58
60
  function normalizeKind (kind) {
59
61
  const n = typeof kind === 'string' && kind.trim() !== '' ? Number(kind) : kind
60
- if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new Error('INVALID_KIND')
62
+ if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new ValidationError('INVALID_KIND')
61
63
  return n
62
64
  }
63
65
 
@@ -119,8 +121,21 @@ export function deriveDoubleDhConversationKey ({
119
121
  kind,
120
122
  scope = ''
121
123
  }) {
122
- if (role !== 'sender' && role !== 'receiver') throw new Error('INVALID_DOUBLE_DH_ROLE')
123
- if (!identitySecretKey || !identityPubkey || !peerIdentityPubkey) throw new Error('DOUBLE_DH_IDENTITY_REQUIRED')
124
+ if (role !== 'sender' && role !== 'receiver') throw new ValidationError('INVALID_DOUBLE_DH_ROLE')
125
+ if (!identitySecretKey || !identityPubkey || !peerIdentityPubkey) throw new ValidationError('DOUBLE_DH_IDENTITY_REQUIRED')
126
+ if (!(identitySecretKey instanceof Uint8Array) || !secp256k1.utils.isValidSecretKey(identitySecretKey)) {
127
+ throw new ValidationError('INVALID_SECRET_KEY')
128
+ }
129
+ for (const pubkey of [identityPubkey, peerIdentityPubkey, contentPubkey, peerContentPubkey]) {
130
+ if (pubkey && (typeof pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(pubkey))) {
131
+ throw new ValidationError('INVALID_PUBLIC_KEY')
132
+ }
133
+ }
134
+ if (contentSecretKey != null &&
135
+ (!(contentSecretKey instanceof Uint8Array) || !secp256k1.utils.isValidSecretKey(contentSecretKey))) {
136
+ throw new ValidationError('INVALID_CONTENT_SECRET_KEY')
137
+ }
138
+ if (typeof scope !== 'string') throw new ValidationError('INVALID_SCOPE')
124
139
 
125
140
  const isSender = role === 'sender'
126
141
  const senderContentPubkey = isSender ? contentPubkey : peerContentPubkey
@@ -128,8 +143,8 @@ export function deriveDoubleDhConversationKey ({
128
143
  const mode = modeFor({ senderContentPubkey, receiverContentPubkey })
129
144
 
130
145
  if (mode === 'identity') return { mode, conversationKey: null }
131
- if (isSender && senderContentPubkey && !contentSecretKey) throw new Error('SENDER_CONTENT_KEY_REQUIRED')
132
- if (!isSender && receiverContentPubkey && !contentSecretKey) throw new Error('RECEIVER_CONTENT_KEY_REQUIRED')
146
+ if (isSender && senderContentPubkey && !contentSecretKey) throw new ValidationError('SENDER_CONTENT_KEY_REQUIRED')
147
+ if (!isSender && receiverContentPubkey && !contentSecretKey) throw new ValidationError('RECEIVER_CONTENT_KEY_REQUIRED')
133
148
 
134
149
  const [a, b] = orderedPair({ identityPubkey, contentPubkey, peerIdentityPubkey, peerContentPubkey })
135
150
  const steps = []
@@ -137,7 +152,7 @@ export function deriveDoubleDhConversationKey ({
137
152
  if (identityPubkey === peerIdentityPubkey) {
138
153
  const ownContentPubkey = contentPubkey || peerContentPubkey
139
154
  if (ownContentPubkey && !contentSecretKey) {
140
- throw new Error(isSender ? 'SENDER_CONTENT_KEY_REQUIRED' : 'RECEIVER_CONTENT_KEY_REQUIRED')
155
+ throw new ValidationError(isSender ? 'SENDER_CONTENT_KEY_REQUIRED' : 'RECEIVER_CONTENT_KEY_REQUIRED')
141
156
  }
142
157
  // In self-encryption, DH(identitySecret, contentPubkey) could be computed
143
158
  // with either single secret plus the other public key. The two self-DH
package/ecdh/index.js CHANGED
@@ -1,8 +1,19 @@
1
1
  import { secp256k1 } from '@noble/curves/secp256k1.js'
2
2
  import { hexToBytes } from '../base16/index.js'
3
+ import { ValidationError } from '../error/index.js'
3
4
 
4
5
  export function sharedXOnlySecret (seckey, pubkey) {
6
+ if (!(seckey instanceof Uint8Array) || seckey.length !== 32 || !secp256k1.utils.isValidSecretKey(seckey)) {
7
+ throw new ValidationError('INVALID_SECRET_KEY')
8
+ }
9
+ if (typeof pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(pubkey)) {
10
+ throw new ValidationError('INVALID_PUBLIC_KEY')
11
+ }
5
12
  // Nostr pubkeys are x-only. secp256k1 ECDH expects a compressed point, so
6
13
  // use the even-y prefix and drop the returned parity byte.
7
- return secp256k1.getSharedSecret(seckey, hexToBytes(`02${pubkey}`)).subarray(1, 33)
14
+ try {
15
+ return secp256k1.getSharedSecret(seckey, hexToBytes(`02${pubkey}`)).subarray(1, 33)
16
+ } catch (cause) {
17
+ throw new ValidationError('INVALID_PUBLIC_KEY', { cause })
18
+ }
8
19
  }
package/error/index.js ADDED
@@ -0,0 +1,32 @@
1
+ const ERROR_CODE = /^[A-Z][A-Z0-9_]*$/
2
+
3
+ export class ValidationError extends Error {
4
+ constructor (code, messageOrOptions = code, causeOrOptions) {
5
+ if (typeof code !== 'string' || !ERROR_CODE.test(code)) {
6
+ throw new TypeError('Validation error code should be uppercase snake case')
7
+ }
8
+ const objectOptions = messageOrOptions && typeof messageOrOptions === 'object'
9
+ ? messageOrOptions
10
+ : null
11
+ const message = objectOptions === messageOrOptions
12
+ ? (objectOptions.message ?? code)
13
+ : (messageOrOptions ?? code)
14
+ const cause = objectOptions
15
+ ? objectOptions.cause
16
+ : (causeOrOptions && typeof causeOrOptions === 'object' && Object.hasOwn(causeOrOptions, 'cause')
17
+ ? causeOrOptions.cause
18
+ : causeOrOptions)
19
+ super(message, cause === undefined ? undefined : { cause })
20
+ Object.defineProperty(this, 'name', {
21
+ configurable: true,
22
+ value: 'ValidationError',
23
+ writable: true
24
+ })
25
+ Object.defineProperty(this, 'code', {
26
+ configurable: false,
27
+ enumerable: true,
28
+ value: code,
29
+ writable: false
30
+ })
31
+ }
32
+ }
@@ -0,0 +1,31 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+
3
+ const HEX_32 = /^[0-9a-f]{64}$/
4
+
5
+ export function serializableEventError (event) {
6
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return 'INVALID_EVENT'
7
+ if (!Number.isSafeInteger(event.kind) || event.kind < 0 || event.kind > 0xffff) return 'INVALID_EVENT_KIND'
8
+ if (!Number.isSafeInteger(event.created_at) || event.created_at < 0) return 'INVALID_EVENT_CREATED_AT'
9
+ if (typeof event.pubkey !== 'string' || !HEX_32.test(event.pubkey)) return 'INVALID_EVENT_PUBKEY'
10
+ if (typeof event.content !== 'string') return 'INVALID_EVENT_CONTENT'
11
+ if (!Array.isArray(event.tags) ||
12
+ !event.tags.every(tag => Array.isArray(tag) && tag.length > 0 && tag.every(value => typeof value === 'string'))) {
13
+ return 'INVALID_EVENT_TAGS'
14
+ }
15
+ return null
16
+ }
17
+
18
+ export function isSerializableEvent (event) {
19
+ return serializableEventError(event) === null
20
+ }
21
+
22
+ export function assertSerializableEvent (event) {
23
+ const code = serializableEventError(event)
24
+ if (code) throw new ValidationError(code)
25
+ return event
26
+ }
27
+
28
+ export function serializeEvent (event) {
29
+ assertSerializableEvent(event)
30
+ return JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content])
31
+ }