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/README.md
CHANGED
|
@@ -21,6 +21,8 @@ const messenger = await createPrivateMessenger({
|
|
|
21
21
|
userSigner,
|
|
22
22
|
contentKeySigner,
|
|
23
23
|
offlineRecoverySeconds: 7 * 24 * 60 * 60,
|
|
24
|
+
staleChannelSeconds: 45 * 24 * 60 * 60,
|
|
25
|
+
identityStorageRetentionSeconds: 60 * 24 * 60 * 60,
|
|
24
26
|
channels: [{
|
|
25
27
|
signer: privateChannelSigner,
|
|
26
28
|
relays: ['wss://relay.example'],
|
|
@@ -89,7 +91,7 @@ await messenger.tell({
|
|
|
89
91
|
```
|
|
90
92
|
|
|
91
93
|
```js
|
|
92
|
-
import { finalizeEvent } from '
|
|
94
|
+
import { finalizeEvent } from 'libp2r2p/event'
|
|
93
95
|
import { keypairFromSeckey } from 'libp2r2p/key'
|
|
94
96
|
import { relayPool } from 'libp2r2p/relay'
|
|
95
97
|
|
|
@@ -147,26 +149,47 @@ PrivateMessenger.maintainStorage().catch(console.warn)
|
|
|
147
149
|
|
|
148
150
|
Maintenance removes interrupted-send staging, expired receive chunks, and
|
|
149
151
|
storage belonging to inactive principal identities. It also
|
|
150
|
-
resumes any interrupted
|
|
152
|
+
resumes any interrupted storage-set deletion. An application does not need to
|
|
151
153
|
know database names or enumerate IndexedDB. Pass `temporaryStorageArea` only
|
|
152
154
|
when the messenger was configured to use a Storage area other than the default
|
|
153
155
|
`sessionStorage`.
|
|
154
156
|
|
|
155
|
-
Each principal signer owns internal
|
|
156
|
-
databases. The messenger updates
|
|
157
|
-
closes all handles in `await messenger.close()`.
|
|
158
|
-
|
|
157
|
+
Each principal signer owns an internal storage set containing message,
|
|
158
|
+
recovery-seed, and channel-state databases. The messenger updates its activity
|
|
159
|
+
lease while it is open and closes all handles in `await messenger.close()`.
|
|
160
|
+
The complete set is removed after `identityStorageRetentionSeconds` without
|
|
161
|
+
use (60 days by default), including messages that were never consumed.
|
|
159
162
|
Maintenance runs on every initialization and every six hours while a messenger
|
|
160
163
|
is active; failed deletions remain journaled and retry automatically.
|
|
161
164
|
|
|
162
|
-
Channel recovery state inside an otherwise active identity
|
|
163
|
-
|
|
165
|
+
Channel recovery state inside an otherwise active identity uses the separate
|
|
166
|
+
`staleChannelSeconds` cleanup policy (45 days by default). Active instances
|
|
167
|
+
record the channels they administer, so a channel remains protected while any
|
|
168
|
+
instance or tab still uses it. Offline recovery defaults to seven days.
|
|
164
169
|
Set `offlineRecoverySeconds` on an individual channel to override the
|
|
165
170
|
messenger default for its recovery seeds, offline ranges, new outer-event
|
|
166
171
|
expiration, and new incomplete receive groups. Updating a channel applies a
|
|
167
172
|
shorter window immediately to its stored seeds and ranges; increasing it does
|
|
168
173
|
not recreate data already removed.
|
|
169
174
|
|
|
175
|
+
The effective recovery duration is capped by both `staleChannelSeconds` and
|
|
176
|
+
`identityStorageRetentionSeconds`. The requested per-channel value remains
|
|
177
|
+
stored separately, so raising a cap affects new retention decisions without
|
|
178
|
+
recreating data already removed. Both retention policies can be changed by an
|
|
179
|
+
instance at runtime; omitted fields retain their current persisted values:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
await messenger.update({
|
|
183
|
+
staleChannelSeconds: 30 * 24 * 60 * 60,
|
|
184
|
+
identityStorageRetentionSeconds: 90 * 24 * 60 * 60
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The policies are persisted per principal identity. If multiple instances use
|
|
189
|
+
the same identity, the last confirmed policy update wins and is propagated to
|
|
190
|
+
the others. A zero policy disables durable recovery immediately. Identity
|
|
191
|
+
storage itself remains protected until the final active lease closes.
|
|
192
|
+
|
|
170
193
|
Set a channel's `offlineRecoverySeconds` to `0` to disable durable recovery for
|
|
171
194
|
that channel. The messenger then stores no recovery seeds, tracks no offline
|
|
172
195
|
ranges, contacts no seeders, publishes no seeder presence, and uses no recovery
|
|
@@ -222,6 +245,87 @@ Use explicit subpath imports for bundle size. The package root re-exports the
|
|
|
222
245
|
main messenger API for convenience, but applications that only need one piece
|
|
223
246
|
should import that subpath directly.
|
|
224
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
|
+
|
|
225
329
|
## Internationalization
|
|
226
330
|
|
|
227
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
|
|
9
|
-
if (base16.length % 2 !== 0) throw new
|
|
10
|
-
if (!/^[0-9a-f]*$/i.test(base16)) throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
138
|
-
if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length)
|
|
139
|
-
|
|
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)
|
|
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
|
|
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 {
|
|
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}$/
|
|
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
|
|
14
|
-
if (!HEX_PUBKEY.test(contentPubkey || '')) throw new
|
|
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
|
|
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 (
|
|
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
|
-
|
|
63
|
-
if (!HEX_PUBKEY.test(ownerPubkey || '')
|
|
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
|
|
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
|
|
78
|
+
return isValidEvent(event) ? null : 'INVALID_CONTENT_KEY_PROOF_SIGNATURE'
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
}
|
package/double-dh/index.js
CHANGED
|
@@ -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
|
|
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
|
|
123
|
-
if (!identitySecretKey || !identityPubkey || !peerIdentityPubkey) throw new
|
|
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
|
|
132
|
-
if (!isSender && receiverContentPubkey && !contentSecretKey) throw new
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|