libp2r2p 0.8.0 → 0.10.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 +72 -45
- 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/idb/index.js +5 -3
- package/idb-queue/index.js +21 -20
- package/index.js +10 -1
- 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 -5
- 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 +23 -22
- package/private-messenger/recovery/index.js +15 -14
- package/private-messenger/services/channel-state.js +2 -1
- 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/i18n/index.js +0 -235
package/nip04/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cbc } from '@noble/ciphers/aes.js'
|
|
2
|
+
import { randomBytes } from '@noble/ciphers/utils.js'
|
|
3
|
+
|
|
4
|
+
import { base16ToBytes } from '../base16/index.js'
|
|
5
|
+
import { base64ToBytes, bytesToBase64 } from '../base64/index.js'
|
|
6
|
+
import { sharedXOnlySecret } from '../ecdh/index.js'
|
|
7
|
+
import { ValidationError } from '../error/index.js'
|
|
8
|
+
|
|
9
|
+
const encoder = new TextEncoder()
|
|
10
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
11
|
+
|
|
12
|
+
function decodeBase64 (value) {
|
|
13
|
+
if (typeof value !== 'string' || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
14
|
+
throw new ValidationError('INVALID_BASE64')
|
|
15
|
+
}
|
|
16
|
+
const bytes = base64ToBytes(value)
|
|
17
|
+
if (bytesToBase64(bytes) !== value) throw new ValidationError('NON_CANONICAL_BASE64')
|
|
18
|
+
return bytes
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function conversationKey (secretKey, pubkey) {
|
|
22
|
+
if (!(secretKey instanceof Uint8Array) || secretKey.length !== 32) throw new ValidationError('INVALID_SECRET_KEY')
|
|
23
|
+
if (typeof pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(pubkey)) throw new ValidationError('INVALID_PUBLIC_KEY')
|
|
24
|
+
return sharedXOnlySecret(secretKey, pubkey)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encrypt (secretKey, pubkey, plaintext) {
|
|
28
|
+
if (typeof plaintext !== 'string') throw new ValidationError('PLAINTEXT_SHOULD_BE_A_STRING')
|
|
29
|
+
const iv = randomBytes(16)
|
|
30
|
+
const ciphertext = cbc(conversationKey(secretKey, pubkey), iv).encrypt(encoder.encode(plaintext))
|
|
31
|
+
return `${bytesToBase64(ciphertext)}?iv=${bytesToBase64(iv)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function decrypt (secretKey, pubkey, payload) {
|
|
35
|
+
if (typeof payload !== 'string') throw new ValidationError('CIPHERTEXT_SHOULD_BE_A_STRING')
|
|
36
|
+
const parts = payload.split('?iv=')
|
|
37
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new ValidationError('INVALID_NIP04_ENVELOPE')
|
|
38
|
+
const ciphertext = decodeBase64(parts[0])
|
|
39
|
+
const iv = decodeBase64(parts[1])
|
|
40
|
+
if (iv.length !== 16 || ciphertext.length === 0 || ciphertext.length % 16 !== 0) throw new ValidationError('INVALID_NIP04_ENVELOPE')
|
|
41
|
+
try {
|
|
42
|
+
return decoder.decode(cbc(conversationKey(secretKey, pubkey), iv).decrypt(ciphertext))
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
if (cause instanceof ValidationError && cause.code !== 'INVALID_NIP04_CIPHERTEXT') throw cause
|
|
45
|
+
throw new ValidationError('INVALID_NIP04_CIPHERTEXT', { cause })
|
|
46
|
+
}
|
|
47
|
+
}
|
package/nip05/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { normalizeRelayUrl } from '../url/index.js'
|
|
2
|
+
|
|
3
|
+
const PUBKEY = /^[0-9a-f]{64}$/
|
|
4
|
+
const LOCAL_PART = /^[a-z0-9._-]+$/
|
|
5
|
+
|
|
6
|
+
export async function queryProfile (identifier, {
|
|
7
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
8
|
+
signal,
|
|
9
|
+
timeoutMs = 5000
|
|
10
|
+
} = {}) {
|
|
11
|
+
if (typeof identifier !== 'string' || typeof fetchImpl !== 'function') return null
|
|
12
|
+
const normalized = identifier.trim().toLowerCase()
|
|
13
|
+
const separator = normalized.lastIndexOf('@')
|
|
14
|
+
if (separator <= 0 || separator === normalized.length - 1) return null
|
|
15
|
+
const name = normalized.slice(0, separator)
|
|
16
|
+
const domain = normalized.slice(separator + 1)
|
|
17
|
+
if (!LOCAL_PART.test(name) || /[/?#@]/.test(domain)) return null
|
|
18
|
+
|
|
19
|
+
let url
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(`https://${domain}/.well-known/nostr.json`)
|
|
22
|
+
if (url.hostname !== domain.split(':')[0] || url.username || url.password) return null
|
|
23
|
+
url.searchParams.set('name', name)
|
|
24
|
+
} catch {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const controller = new AbortController()
|
|
29
|
+
const onAbort = () => controller.abort(signal.reason)
|
|
30
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException('This operation was aborted', 'AbortError')
|
|
31
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
32
|
+
const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0
|
|
33
|
+
? setTimeout(() => controller.abort(new DOMException('NIP-05 request timed out', 'TimeoutError')), timeoutMs)
|
|
34
|
+
: null
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetchImpl(url, {
|
|
38
|
+
headers: { Accept: 'application/json' },
|
|
39
|
+
redirect: 'error',
|
|
40
|
+
signal: controller.signal
|
|
41
|
+
})
|
|
42
|
+
if (!response.ok) return null
|
|
43
|
+
const data = await response.json()
|
|
44
|
+
const pubkey = data?.names?.[name]
|
|
45
|
+
if (!PUBKEY.test(pubkey)) return null
|
|
46
|
+
const relays = []
|
|
47
|
+
for (const relay of data?.relays?.[pubkey] ?? []) {
|
|
48
|
+
try {
|
|
49
|
+
const normalizedRelay = normalizeRelayUrl(relay)
|
|
50
|
+
if (!relays.includes(normalizedRelay)) relays.push(normalizedRelay)
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
return { pubkey, relays }
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (signal?.aborted) throw signal.reason ?? error
|
|
56
|
+
return null
|
|
57
|
+
} finally {
|
|
58
|
+
if (timeout !== null) clearTimeout(timeout)
|
|
59
|
+
signal?.removeEventListener('abort', onAbort)
|
|
60
|
+
}
|
|
61
|
+
}
|
package/nip19/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bech32 } from '@scure/base'
|
|
2
2
|
import { BASE62_ALPHABET, base62ToBytes, bytesToBase62 } from '../base62/index.js'
|
|
3
|
+
import { ValidationError } from '../error/index.js'
|
|
3
4
|
|
|
4
5
|
const MAX_ENTITY_SIZE = 5000
|
|
5
6
|
const MAX_TLV_VALUE_SIZE = 255
|
|
@@ -29,6 +30,10 @@ const channelByPrefix = Object.fromEntries(
|
|
|
29
30
|
Object.entries(prefixByChannel).map(([channel, prefix]) => [prefix, channel])
|
|
30
31
|
)
|
|
31
32
|
|
|
33
|
+
function invalid (code, message, cause) {
|
|
34
|
+
return new ValidationError(code, { message, cause })
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
function bytesToHex (bytes) {
|
|
33
38
|
let result = ''
|
|
34
39
|
for (const byte of bytes) result += byte.toString(16).padStart(2, '0')
|
|
@@ -37,7 +42,7 @@ function bytesToHex (bytes) {
|
|
|
37
42
|
|
|
38
43
|
function hexToBytes (hex, fieldName = 'hex value') {
|
|
39
44
|
if (typeof hex !== 'string' || !/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) {
|
|
40
|
-
throw
|
|
45
|
+
throw invalid('INVALID_NIP19_HEX', `Invalid ${fieldName}`)
|
|
41
46
|
}
|
|
42
47
|
const result = new Uint8Array(hex.length / 2)
|
|
43
48
|
for (let index = 0; index < result.length; index++) {
|
|
@@ -48,7 +53,7 @@ function hexToBytes (hex, fieldName = 'hex value') {
|
|
|
48
53
|
|
|
49
54
|
function fixedHexToBytes (hex, byteLength, fieldName) {
|
|
50
55
|
const bytes = hexToBytes(hex, fieldName)
|
|
51
|
-
if (bytes.length !== byteLength) throw
|
|
56
|
+
if (bytes.length !== byteLength) throw invalid('INVALID_NIP19_FIELD_LENGTH', `${fieldName} should be ${byteLength} bytes`)
|
|
52
57
|
return bytes
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -63,34 +68,34 @@ function concatBytes (arrays) {
|
|
|
63
68
|
return result
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
function encodeText (value, fieldName) {
|
|
67
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
68
|
-
throw
|
|
71
|
+
function encodeText (value, fieldName, { allowEmpty = false } = {}) {
|
|
72
|
+
if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
|
|
73
|
+
throw invalid('INVALID_NIP19_TEXT_FIELD', `${fieldName} should be ${allowEmpty ? 'a string' : 'a non-empty string'}`)
|
|
69
74
|
}
|
|
70
75
|
const bytes = textEncoder.encode(value)
|
|
71
|
-
if (bytes.length > MAX_TLV_VALUE_SIZE) throw
|
|
76
|
+
if (bytes.length > MAX_TLV_VALUE_SIZE) throw invalid('NIP19_FIELD_TOO_LARGE', `${fieldName} is too big`)
|
|
72
77
|
// TextEncoder replaces lone surrogates. Reject them instead of silently changing
|
|
73
78
|
// the signed/canonical entity value.
|
|
74
|
-
if (fatalTextDecoder.decode(bytes) !== value) throw
|
|
79
|
+
if (fatalTextDecoder.decode(bytes) !== value) throw invalid('INVALID_NIP19_UTF8', `Invalid ${fieldName} UTF-8`)
|
|
75
80
|
return bytes
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
function decodeText (bytes, fieldName) {
|
|
79
|
-
if (bytes.length === 0 || bytes.length > MAX_TLV_VALUE_SIZE) {
|
|
80
|
-
throw
|
|
83
|
+
function decodeText (bytes, fieldName, { allowEmpty = false } = {}) {
|
|
84
|
+
if ((!allowEmpty && bytes.length === 0) || bytes.length > MAX_TLV_VALUE_SIZE) {
|
|
85
|
+
throw invalid('INVALID_NIP19_FIELD_LENGTH', `Invalid ${fieldName} length`)
|
|
81
86
|
}
|
|
82
87
|
try {
|
|
83
88
|
return fatalTextDecoder.decode(bytes)
|
|
84
|
-
} catch {
|
|
85
|
-
throw
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
throw invalid('INVALID_NIP19_UTF8', `Invalid ${fieldName} UTF-8`, cause)
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
function encodeTlvEntries (entries) {
|
|
90
95
|
return concatBytes(entries.map(([type, value]) => {
|
|
91
|
-
if (!Number.isInteger(type) || type < 0 || type > 255) throw
|
|
92
|
-
if (!(value instanceof Uint8Array)) throw
|
|
93
|
-
if (value.length > MAX_TLV_VALUE_SIZE) throw
|
|
96
|
+
if (!Number.isInteger(type) || type < 0 || type > 255) throw invalid('INVALID_TLV_TYPE', 'Invalid TLV type')
|
|
97
|
+
if (!(value instanceof Uint8Array)) throw invalid('INVALID_TLV_VALUE', 'TLV value should be a Uint8Array')
|
|
98
|
+
if (value.length > MAX_TLV_VALUE_SIZE) throw invalid('TLV_VALUE_TOO_LARGE', 'TLV value is too big')
|
|
94
99
|
const entry = new Uint8Array(value.length + 2)
|
|
95
100
|
entry[0] = type
|
|
96
101
|
entry[1] = value.length
|
|
@@ -103,11 +108,11 @@ function decodeTlvEntries (bytes) {
|
|
|
103
108
|
const entries = []
|
|
104
109
|
let offset = 0
|
|
105
110
|
while (offset < bytes.length) {
|
|
106
|
-
if (bytes.length - offset < 2) throw
|
|
111
|
+
if (bytes.length - offset < 2) throw invalid('TRUNCATED_TLV_HEADER', 'Truncated TLV header')
|
|
107
112
|
const type = bytes[offset]
|
|
108
113
|
const length = bytes[offset + 1]
|
|
109
114
|
offset += 2
|
|
110
|
-
if (bytes.length - offset < length) throw
|
|
115
|
+
if (bytes.length - offset < length) throw invalid('TRUNCATED_TLV_VALUE', `Truncated TLV value for type ${type}`)
|
|
111
116
|
entries.push([type, bytes.slice(offset, offset + length)])
|
|
112
117
|
offset += length
|
|
113
118
|
}
|
|
@@ -115,12 +120,65 @@ function decodeTlvEntries (bytes) {
|
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
function onlyValue (values, fieldName) {
|
|
118
|
-
if (values.length > 1) throw
|
|
123
|
+
if (values.length > 1) throw invalid('DUPLICATE_NIP19_FIELD', `Duplicate ${fieldName}`)
|
|
119
124
|
return values[0]
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
function encodeBech32Tlv (prefix, entries) {
|
|
128
|
+
return bech32.encode(prefix, bech32.toWords(encodeTlvEntries(entries)), MAX_ENTITY_SIZE)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function encodeStandardPointer (prefix, entries) {
|
|
132
|
+
return encodeBech32Tlv(prefix, entries.slice().sort(([left], [right]) => right - left))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function decodeBech32Bytes (entity, prefix) {
|
|
136
|
+
if (typeof entity !== 'string' || entity !== entity.toLowerCase() || !entity.startsWith(`${prefix}1`)) {
|
|
137
|
+
throw invalid('NON_CANONICAL_NIP19_ENTITY', `${prefix} should use canonical lowercase Bech32`)
|
|
138
|
+
}
|
|
139
|
+
let decoded
|
|
140
|
+
try {
|
|
141
|
+
decoded = bech32.decode(entity, MAX_ENTITY_SIZE)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw invalid('INVALID_NIP19_ENTITY', `Invalid ${prefix}: ${error.message}`, error)
|
|
144
|
+
}
|
|
145
|
+
if (decoded.prefix !== prefix) throw invalid('INVALID_NIP19_PREFIX', `Invalid ${prefix} prefix`)
|
|
146
|
+
try {
|
|
147
|
+
return new Uint8Array(bech32.fromWords(decoded.words))
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw invalid('INVALID_NIP19_DATA', `Invalid ${prefix} data: ${error.message}`, error)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function decodeKnownTlv (entity, prefix, types) {
|
|
154
|
+
const known = new Map(types.map(type => [type, []]))
|
|
155
|
+
for (const [type, value] of decodeTlvEntries(decodeBech32Bytes(entity, prefix))) known.get(type)?.push(value)
|
|
156
|
+
return known
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function encodeUint32 (value, fieldName = 'kind') {
|
|
160
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw invalid('INVALID_NIP19_UINT32', `Invalid ${fieldName}`)
|
|
161
|
+
const bytes = new Uint8Array(4)
|
|
162
|
+
new DataView(bytes.buffer).setUint32(0, value, false)
|
|
163
|
+
return bytes
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function decodeUint32 (bytes, fieldName = 'kind') {
|
|
167
|
+
if (!bytes || bytes.length !== 4) throw invalid('INVALID_NIP19_UINT32', `${fieldName} should be 4 bytes`)
|
|
168
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, false)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function relayEntries (relays) {
|
|
172
|
+
if (!Array.isArray(relays)) throw invalid('INVALID_RELAY_HINTS', 'relays should be an array')
|
|
173
|
+
return relays.map(relay => [1, encodeText(relay, 'relay hint')])
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function decodeRelayHints (values) {
|
|
177
|
+
return values.map(value => decodeText(value, 'relay hint'))
|
|
178
|
+
}
|
|
179
|
+
|
|
122
180
|
export function nfileEncode ({ root, relays = [], author, mime, filename }) {
|
|
123
|
-
if (!Array.isArray(relays)) throw
|
|
181
|
+
if (!Array.isArray(relays)) throw invalid('INVALID_RELAY_HINTS', 'relays should be an array')
|
|
124
182
|
const entries = [[0, fixedHexToBytes(root, 32, 'MMR root')]]
|
|
125
183
|
for (const relay of relays) entries.push([1, encodeText(relay, 'relay hint')])
|
|
126
184
|
if (author !== undefined) entries.push([2, fixedHexToBytes(author, 32, 'author hint')])
|
|
@@ -133,32 +191,32 @@ export function nfileEncode ({ root, relays = [], author, mime, filename }) {
|
|
|
133
191
|
|
|
134
192
|
export function nfileDecode (entity) {
|
|
135
193
|
if (typeof entity !== 'string' || entity !== entity.toLowerCase()) {
|
|
136
|
-
throw
|
|
194
|
+
throw invalid('NON_CANONICAL_NFILE', 'nfile should use canonical lowercase Bech32')
|
|
137
195
|
}
|
|
138
196
|
let decoded
|
|
139
197
|
try {
|
|
140
198
|
decoded = bech32.decode(entity, MAX_ENTITY_SIZE)
|
|
141
199
|
} catch (error) {
|
|
142
|
-
throw
|
|
200
|
+
throw invalid('INVALID_NFILE', `Invalid nfile: ${error.message}`, error)
|
|
143
201
|
}
|
|
144
|
-
if (decoded.prefix !== 'nfile') throw
|
|
202
|
+
if (decoded.prefix !== 'nfile') throw invalid('INVALID_NFILE_PREFIX', 'Invalid nfile prefix')
|
|
145
203
|
|
|
146
204
|
let bytes
|
|
147
205
|
try {
|
|
148
206
|
bytes = new Uint8Array(bech32.fromWords(decoded.words))
|
|
149
207
|
} catch (error) {
|
|
150
|
-
throw
|
|
208
|
+
throw invalid('INVALID_NFILE_DATA', `Invalid nfile data: ${error.message}`, error)
|
|
151
209
|
}
|
|
152
210
|
|
|
153
211
|
const known = new Map([[0, []], [1, []], [2, []], [3, []], [4, []]])
|
|
154
212
|
for (const [type, value] of decodeTlvEntries(bytes)) known.get(type)?.push(value)
|
|
155
213
|
|
|
156
214
|
const rootBytes = onlyValue(known.get(0), 'MMR root')
|
|
157
|
-
if (!rootBytes) throw
|
|
158
|
-
if (rootBytes.length !== 32) throw
|
|
215
|
+
if (!rootBytes) throw invalid('MISSING_NFILE_ROOT', 'Missing MMR root')
|
|
216
|
+
if (rootBytes.length !== 32) throw invalid('INVALID_NFILE_ROOT', 'MMR root should be 32 bytes')
|
|
159
217
|
|
|
160
218
|
const authorBytes = onlyValue(known.get(2), 'author hint')
|
|
161
|
-
if (authorBytes && authorBytes.length !== 32) throw
|
|
219
|
+
if (authorBytes && authorBytes.length !== 32) throw invalid('INVALID_NFILE_AUTHOR', 'Author hint should be 32 bytes')
|
|
162
220
|
const mimeBytes = onlyValue(known.get(3), 'MIME')
|
|
163
221
|
const filenameBytes = onlyValue(known.get(4), 'filename')
|
|
164
222
|
|
|
@@ -172,14 +230,93 @@ export function nfileDecode (entity) {
|
|
|
172
230
|
return result
|
|
173
231
|
}
|
|
174
232
|
|
|
233
|
+
export function noteEncode (eventId) {
|
|
234
|
+
return bech32.encode('note', bech32.toWords(fixedHexToBytes(eventId, 32, 'event ID')), MAX_ENTITY_SIZE)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function noteDecode (entity) {
|
|
238
|
+
const bytes = decodeBech32Bytes(entity, 'note')
|
|
239
|
+
if (bytes.length !== 32) throw invalid('INVALID_NOTE_EVENT_ID', 'event ID should be 32 bytes')
|
|
240
|
+
return bytesToHex(bytes)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function nprofileEncode ({ pubkey, relays = [] }) {
|
|
244
|
+
return encodeStandardPointer('nprofile', [
|
|
245
|
+
[0, fixedHexToBytes(pubkey, 32, 'profile pubkey')],
|
|
246
|
+
...relayEntries(relays)
|
|
247
|
+
])
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function nprofileDecode (entity) {
|
|
251
|
+
const known = decodeKnownTlv(entity, 'nprofile', [0, 1])
|
|
252
|
+
const pubkey = onlyValue(known.get(0), 'profile pubkey')
|
|
253
|
+
if (!pubkey || pubkey.length !== 32) throw invalid('INVALID_NPROFILE_PUBKEY', 'profile pubkey should be 32 bytes')
|
|
254
|
+
return { pubkey: bytesToHex(pubkey), relays: decodeRelayHints(known.get(1)) }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function neventEncode ({ id, relays = [], author, kind }) {
|
|
258
|
+
const entries = [[0, fixedHexToBytes(id, 32, 'event ID')], ...relayEntries(relays)]
|
|
259
|
+
if (author !== undefined) entries.push([2, fixedHexToBytes(author, 32, 'event author')])
|
|
260
|
+
if (kind !== undefined) entries.push([3, encodeUint32(kind)])
|
|
261
|
+
return encodeStandardPointer('nevent', entries)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function neventDecode (entity) {
|
|
265
|
+
const known = decodeKnownTlv(entity, 'nevent', [0, 1, 2, 3])
|
|
266
|
+
const id = onlyValue(known.get(0), 'event ID')
|
|
267
|
+
const author = onlyValue(known.get(2), 'event author')
|
|
268
|
+
const kind = onlyValue(known.get(3), 'event kind')
|
|
269
|
+
if (!id || id.length !== 32) throw invalid('INVALID_NEVENT_ID', 'event ID should be 32 bytes')
|
|
270
|
+
if (author && author.length !== 32) throw invalid('INVALID_NEVENT_AUTHOR', 'event author should be 32 bytes')
|
|
271
|
+
const result = { id: bytesToHex(id), relays: decodeRelayHints(known.get(1)) }
|
|
272
|
+
if (author) result.author = bytesToHex(author)
|
|
273
|
+
if (kind) result.kind = decodeUint32(kind, 'event kind')
|
|
274
|
+
return result
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function naddrEncode ({ identifier, pubkey, kind, relays = [] }) {
|
|
278
|
+
return encodeStandardPointer('naddr', [
|
|
279
|
+
[0, encodeText(identifier, 'identifier', { allowEmpty: true })],
|
|
280
|
+
...relayEntries(relays),
|
|
281
|
+
[2, fixedHexToBytes(pubkey, 32, 'address author')],
|
|
282
|
+
[3, encodeUint32(kind)]
|
|
283
|
+
])
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function naddrDecode (entity) {
|
|
287
|
+
const known = decodeKnownTlv(entity, 'naddr', [0, 1, 2, 3])
|
|
288
|
+
const identifier = onlyValue(known.get(0), 'identifier')
|
|
289
|
+
const pubkey = onlyValue(known.get(2), 'address author')
|
|
290
|
+
const kind = onlyValue(known.get(3), 'address kind')
|
|
291
|
+
if (!identifier) throw invalid('MISSING_NADDR_IDENTIFIER', 'Missing identifier')
|
|
292
|
+
if (!pubkey || pubkey.length !== 32) throw invalid('INVALID_NADDR_AUTHOR', 'address author should be 32 bytes')
|
|
293
|
+
return {
|
|
294
|
+
identifier: decodeText(identifier, 'identifier', { allowEmpty: true }),
|
|
295
|
+
pubkey: bytesToHex(pubkey),
|
|
296
|
+
kind: decodeUint32(kind, 'address kind'),
|
|
297
|
+
relays: decodeRelayHints(known.get(1))
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function nrelayEncode (relay) {
|
|
302
|
+
return encodeStandardPointer('nrelay', [[0, encodeText(relay, 'relay URL')]])
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function nrelayDecode (entity) {
|
|
306
|
+
const known = decodeKnownTlv(entity, 'nrelay', [0])
|
|
307
|
+
const relay = onlyValue(known.get(0), 'relay URL')
|
|
308
|
+
if (!relay) throw invalid('MISSING_NRELAY_URL', 'Missing relay URL')
|
|
309
|
+
return decodeText(relay, 'relay URL')
|
|
310
|
+
}
|
|
311
|
+
|
|
175
312
|
function isNostrAppDTagSafe (value) {
|
|
176
313
|
return typeof value === 'string' && value.length <= NOSTR_APP_D_TAG_MAX_LENGTH
|
|
177
314
|
}
|
|
178
315
|
|
|
179
316
|
export function appEncode (ref) {
|
|
180
|
-
if (!isNostrAppDTagSafe(ref
|
|
317
|
+
if (!isNostrAppDTagSafe(ref?.dTag)) throw invalid('INVALID_APP_D_TAG', 'Invalid deduplication tag')
|
|
181
318
|
const channel = ref.channel ? (prefixByChannel[ref.channel] && ref.channel) : channelByKind[ref.kind]
|
|
182
|
-
if (!channel) throw
|
|
319
|
+
if (!channel) throw invalid('INVALID_APP_CHANNEL', 'Wrong channel')
|
|
183
320
|
|
|
184
321
|
// Keep the established app-entity byte ordering exactly for compatibility.
|
|
185
322
|
const groups = [
|
|
@@ -195,15 +332,15 @@ export function appEncode (ref) {
|
|
|
195
332
|
for (const value of values) entries.push([type, value])
|
|
196
333
|
})
|
|
197
334
|
const entity = `${prefixByChannel[channel]}${bytesToBase62(encodeTlvEntries(entries))}`
|
|
198
|
-
if (entity.length > MAX_ENTITY_SIZE) throw
|
|
335
|
+
if (entity.length > MAX_ENTITY_SIZE) throw invalid('APP_ENTITY_TOO_LARGE', 'App entity is too big')
|
|
199
336
|
return entity
|
|
200
337
|
}
|
|
201
338
|
|
|
202
339
|
export function appDecode (entity) {
|
|
203
|
-
if (typeof entity !== 'string' || entity.length > MAX_ENTITY_SIZE) throw
|
|
340
|
+
if (typeof entity !== 'string' || entity.length > MAX_ENTITY_SIZE) throw invalid('INVALID_APP_ENTITY', 'Invalid app entity')
|
|
204
341
|
const prefix = entity.match(/^\+*/)?.[0]
|
|
205
342
|
const channel = channelByPrefix[prefix]
|
|
206
|
-
if (!channel) throw
|
|
343
|
+
if (!channel) throw invalid('INVALID_APP_CHANNEL', 'Invalid channel')
|
|
207
344
|
|
|
208
345
|
const values = new Map()
|
|
209
346
|
for (const [type, value] of decodeTlvEntries(base62ToBytes(entity.slice(prefix.length)))) {
|
|
@@ -213,11 +350,11 @@ export function appDecode (entity) {
|
|
|
213
350
|
}
|
|
214
351
|
const dTagBytes = values.get(0)?.[0]
|
|
215
352
|
const pubkeyBytes = values.get(2)?.[0]
|
|
216
|
-
if (!dTagBytes) throw
|
|
217
|
-
if (!pubkeyBytes) throw
|
|
218
|
-
if (pubkeyBytes.length !== 32) throw
|
|
353
|
+
if (!dTagBytes) throw invalid('MISSING_APP_D_TAG', 'Missing deduplication tag')
|
|
354
|
+
if (!pubkeyBytes) throw invalid('MISSING_APP_AUTHOR', 'Missing author pubkey')
|
|
355
|
+
if (pubkeyBytes.length !== 32) throw invalid('INVALID_APP_AUTHOR', 'Author pubkey should be 32 bytes')
|
|
219
356
|
const dTag = textDecoder.decode(dTagBytes)
|
|
220
|
-
if (!isNostrAppDTagSafe(dTag)) throw
|
|
357
|
+
if (!isNostrAppDTagSafe(dTag)) throw invalid('INVALID_APP_D_TAG', 'Invalid deduplication tag')
|
|
221
358
|
|
|
222
359
|
return {
|
|
223
360
|
dTag,
|
|
@@ -245,16 +382,12 @@ export function nsecDecode (entity) {
|
|
|
245
382
|
}
|
|
246
383
|
|
|
247
384
|
function decodeSimpleEntity (entity, prefix, fieldName) {
|
|
248
|
-
if (typeof entity !== 'string' || !entity.startsWith(`${prefix}1`)) {
|
|
249
|
-
throw new Error(`Invalid ${prefix} format`)
|
|
250
|
-
}
|
|
251
385
|
try {
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
const bytes = new Uint8Array(bech32.fromWords(decoded.words))
|
|
255
|
-
if (bytes.length !== 32) throw new Error(`Invalid ${fieldName} length`)
|
|
386
|
+
const bytes = decodeBech32Bytes(entity, prefix)
|
|
387
|
+
if (bytes.length !== 32) throw invalid('INVALID_NIP19_FIELD_LENGTH', `Invalid ${fieldName} length`)
|
|
256
388
|
return bytesToHex(bytes)
|
|
257
389
|
} catch (error) {
|
|
258
|
-
|
|
390
|
+
if (error instanceof ValidationError && error.code === 'INVALID_NIP19_FIELD_LENGTH') throw error
|
|
391
|
+
throw invalid('INVALID_NIP19_ENTITY', `Failed to decode ${prefix}: ${error.message}`, error)
|
|
259
392
|
}
|
|
260
393
|
}
|
package/nip44/helpers.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { chacha20 } from '@noble/ciphers/chacha.js'
|
|
2
|
+
import { equalBytes, randomBytes } from '@noble/ciphers/utils.js'
|
|
3
|
+
import { expand, extract } from '@noble/hashes/hkdf.js'
|
|
4
|
+
import { hmac } from '@noble/hashes/hmac.js'
|
|
5
|
+
import { sha256 } from '@noble/hashes/sha2.js'
|
|
6
|
+
|
|
7
|
+
import { base64ToBytes, bytesToBase64 } from '../base64/index.js'
|
|
8
|
+
import { ValidationError } from '../error/index.js'
|
|
9
|
+
|
|
10
|
+
const encoder = new TextEncoder()
|
|
11
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
12
|
+
const minPlaintextSize = 1
|
|
13
|
+
const maxPlaintextSize = 0xffffffff
|
|
14
|
+
const maxRawPayloadSize = 1 + 32 + 6 + 0x100000000 + 32
|
|
15
|
+
const maxEncodedPayloadSize = Math.ceil(maxRawPayloadSize / 3) * 4
|
|
16
|
+
|
|
17
|
+
function concatBytes (...arrays) {
|
|
18
|
+
const output = new Uint8Array(arrays.reduce((total, value) => total + value.length, 0))
|
|
19
|
+
let offset = 0
|
|
20
|
+
for (const value of arrays) {
|
|
21
|
+
output.set(value, offset)
|
|
22
|
+
offset += value.length
|
|
23
|
+
}
|
|
24
|
+
return output
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getMessageKeys (conversationKey, nonce) {
|
|
28
|
+
if (!(conversationKey instanceof Uint8Array) || conversationKey.length !== 32) throw new ValidationError('INVALID_CONVERSATION_KEY')
|
|
29
|
+
if (!(nonce instanceof Uint8Array) || nonce.length !== 32) throw new ValidationError('INVALID_NONCE')
|
|
30
|
+
const keys = expand(sha256, conversationKey, nonce, 76)
|
|
31
|
+
return { key: keys.subarray(0, 32), nonce: keys.subarray(32, 44), hmacKey: keys.subarray(44) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function calcPaddedLen (length) {
|
|
35
|
+
if (!Number.isSafeInteger(length) || length < minPlaintextSize || length > maxPlaintextSize) throw new ValidationError('INVALID_PLAINTEXT_SIZE')
|
|
36
|
+
if (length <= 32) return 32
|
|
37
|
+
const nextPower = 2 ** (Math.floor(Math.log2(length - 1)) + 1)
|
|
38
|
+
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
|
39
|
+
return chunk * (Math.floor((length - 1) / chunk) + 1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pad (plaintext) {
|
|
43
|
+
if (typeof plaintext !== 'string') throw new ValidationError('PLAINTEXT_SHOULD_BE_A_STRING')
|
|
44
|
+
const bytes = encoder.encode(plaintext)
|
|
45
|
+
const length = bytes.length
|
|
46
|
+
calcPaddedLen(length)
|
|
47
|
+
const prefixLength = length < 0x10000 ? 2 : 6
|
|
48
|
+
const prefix = new Uint8Array(prefixLength)
|
|
49
|
+
const view = new DataView(prefix.buffer)
|
|
50
|
+
if (prefixLength === 2) view.setUint16(0, length, false)
|
|
51
|
+
else view.setUint32(2, length, false)
|
|
52
|
+
return concatBytes(prefix, bytes, new Uint8Array(calcPaddedLen(length) - length))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function unpad (padded) {
|
|
56
|
+
if (!(padded instanceof Uint8Array) || padded.length < 2) throw new ValidationError('INVALID_PADDING')
|
|
57
|
+
const view = new DataView(padded.buffer, padded.byteOffset, padded.byteLength)
|
|
58
|
+
const shortLength = view.getUint16(0, false)
|
|
59
|
+
const prefixLength = shortLength === 0 ? 6 : 2
|
|
60
|
+
if (padded.length < prefixLength) throw new ValidationError('INVALID_PADDING')
|
|
61
|
+
const length = shortLength === 0 ? view.getUint32(2, false) : shortLength
|
|
62
|
+
if (shortLength === 0 && length < 0x10000) throw new ValidationError('INVALID_PADDING')
|
|
63
|
+
let expected
|
|
64
|
+
try { expected = prefixLength + calcPaddedLen(length) } catch (cause) { throw new ValidationError('INVALID_PADDING', { cause }) }
|
|
65
|
+
if (padded.length !== expected || prefixLength + length > padded.length) throw new ValidationError('INVALID_PADDING')
|
|
66
|
+
try { return decoder.decode(padded.subarray(prefixLength, prefixLength + length)) } catch (cause) { throw new ValidationError('INVALID_UTF8', { cause }) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function encodePayload (conversationKey, plaintext, nonce = randomBytes(32)) {
|
|
70
|
+
const messageKeys = getMessageKeys(conversationKey, nonce)
|
|
71
|
+
const ciphertext = chacha20(messageKeys.key, messageKeys.nonce, pad(plaintext))
|
|
72
|
+
const mac = hmac(sha256, messageKeys.hmacKey, concatBytes(nonce, ciphertext))
|
|
73
|
+
return bytesToBase64(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decodePayload (conversationKey, payload) {
|
|
77
|
+
if (typeof payload !== 'string' || payload.length < 132 || payload.length > maxEncodedPayloadSize || payload[0] === '#') throw new ValidationError('INVALID_NIP44_PAYLOAD')
|
|
78
|
+
if (payload.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(payload)) throw new ValidationError('INVALID_BASE64')
|
|
79
|
+
const data = base64ToBytes(payload)
|
|
80
|
+
if (bytesToBase64(data) !== payload || data.length < 99 || data[0] !== 2) throw new ValidationError('INVALID_NIP44_PAYLOAD')
|
|
81
|
+
const nonce = data.subarray(1, 33)
|
|
82
|
+
const ciphertext = data.subarray(33, -32)
|
|
83
|
+
const mac = data.subarray(-32)
|
|
84
|
+
const messageKeys = getMessageKeys(conversationKey, nonce)
|
|
85
|
+
const calculated = hmac(sha256, messageKeys.hmacKey, concatBytes(nonce, ciphertext))
|
|
86
|
+
if (!equalBytes(mac, calculated)) throw new ValidationError('INVALID_MAC')
|
|
87
|
+
return unpad(chacha20(messageKeys.key, messageKeys.nonce, ciphertext))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function extractConversationKey (sharedSecret, salt = 'nip44-v2') {
|
|
91
|
+
if (typeof salt !== 'string') throw new ValidationError('SALT_SHOULD_BE_A_STRING')
|
|
92
|
+
const saltBytes = encoder.encode(salt)
|
|
93
|
+
if (saltBytes.length === 0 || saltBytes.length > 32) throw new ValidationError('INVALID_SALT')
|
|
94
|
+
return extract(sha256, sharedSecret, saltBytes)
|
|
95
|
+
}
|
package/nip44/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { sharedXOnlySecret } from '../ecdh/index.js'
|
|
2
|
+
import { decodePayload, encodePayload, extractConversationKey } from './helpers.js'
|
|
3
|
+
|
|
4
|
+
export function getConversationKey (secretKey, pubkey, { salt = 'nip44-v2' } = {}) {
|
|
5
|
+
return extractConversationKey(sharedXOnlySecret(secretKey, pubkey), salt)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function encrypt (plaintext, conversationKey, nonce) {
|
|
9
|
+
return encodePayload(conversationKey, plaintext, nonce)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function decrypt (ciphertext, conversationKey) {
|
|
13
|
+
return decodePayload(conversationKey, ciphertext)
|
|
14
|
+
}
|