libp2r2p 0.0.1

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 (63) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +129 -0
  3. package/base16/index.js +14 -0
  4. package/base64/index.js +29 -0
  5. package/content-key/event/index.js +85 -0
  6. package/content-key/index.js +1 -0
  7. package/content-key/services/iykc-proof.js +129 -0
  8. package/double-dh/index.js +166 -0
  9. package/ecdh/index.js +8 -0
  10. package/idb/index.js +64 -0
  11. package/idb-queue/index.js +793 -0
  12. package/index.js +21 -0
  13. package/key/index.js +139 -0
  14. package/network/index.js +68 -0
  15. package/nip44-v3/index.js +175 -0
  16. package/nip46/constants/index.js +5 -0
  17. package/nip46/helpers/frame.js +51 -0
  18. package/nip46/helpers/url.js +96 -0
  19. package/nip46/index.js +5 -0
  20. package/nip46/services/bunker-signer.js +49 -0
  21. package/nip46/services/client.js +252 -0
  22. package/nip46/services/server-session.js +225 -0
  23. package/nip46/services/transport.js +265 -0
  24. package/package.json +49 -0
  25. package/private-channel/constants/index.js +5 -0
  26. package/private-channel/helpers/chunk-size.js +61 -0
  27. package/private-channel/helpers/chunks.js +282 -0
  28. package/private-channel/helpers/event.js +68 -0
  29. package/private-channel/index.js +942 -0
  30. package/private-channel/services/received-chunks.js +398 -0
  31. package/private-message/index.js +672 -0
  32. package/private-messenger/constants/index.js +1 -0
  33. package/private-messenger/index.js +1431 -0
  34. package/private-messenger/recovery/index.js +316 -0
  35. package/relay/constants/index.js +18 -0
  36. package/relay/helpers/hll.js +62 -0
  37. package/relay/helpers/publish.js +100 -0
  38. package/relay/helpers/routing.js +36 -0
  39. package/relay/helpers/timer.js +4 -0
  40. package/relay/index.js +4 -0
  41. package/relay/services/query.js +224 -0
  42. package/relay/services/relay-connection.js +156 -0
  43. package/relay/services/relay-pool.js +908 -0
  44. package/temporary-storage/index.js +85 -0
  45. package/tests/base16.test.js +19 -0
  46. package/tests/base64.test.js +18 -0
  47. package/tests/content-key-event.test.js +48 -0
  48. package/tests/fixtures/nip44v3-vectors.json +1 -0
  49. package/tests/helpers/test-signer.js +185 -0
  50. package/tests/idb-queue.test.js +153 -0
  51. package/tests/idb.test.js +91 -0
  52. package/tests/nip44-v3.test.js +73 -0
  53. package/tests/nip46.test.js +668 -0
  54. package/tests/private-channel.test.js +1899 -0
  55. package/tests/private-message.test.js +460 -0
  56. package/tests/private-messenger.test.js +1715 -0
  57. package/tests/queries.test.js +101 -0
  58. package/tests/queue-parity.test.js +105 -0
  59. package/tests/relay-hll.test.js +32 -0
  60. package/tests/relay-pool.test.js +2067 -0
  61. package/tests/temporary-storage.test.js +89 -0
  62. package/tests/web-storage-queue.test.js +480 -0
  63. package/web-storage-queue/index.js +652 -0
package/index.js ADDED
@@ -0,0 +1,21 @@
1
+ export * from './private-messenger/index.js'
2
+
3
+ export * as base16 from './base16/index.js'
4
+ export * as base64 from './base64/index.js'
5
+ export * as contentKey from './content-key/index.js'
6
+ export * as contentKeyEvent from './content-key/event/index.js'
7
+ export * as doubleDh from './double-dh/index.js'
8
+ export * as ecdh from './ecdh/index.js'
9
+ export * as idb from './idb/index.js'
10
+ export * as idbQueue from './idb-queue/index.js'
11
+ export * as key from './key/index.js'
12
+ export * as network from './network/index.js'
13
+ export * as nip44v3 from './nip44-v3/index.js'
14
+ export * as nip46 from './nip46/index.js'
15
+ export * as privateChannel from './private-channel/index.js'
16
+ export * as privateMessage from './private-message/index.js'
17
+ export * as privateMessenger from './private-messenger/index.js'
18
+ export * as privateMessengerRecovery from './private-messenger/recovery/index.js'
19
+ export * as relay from './relay/index.js'
20
+ export * as temporaryStorage from './temporary-storage/index.js'
21
+ export * as webStorageQueue from './web-storage-queue/index.js'
package/key/index.js ADDED
@@ -0,0 +1,139 @@
1
+ import {
2
+ generateSecretKey,
3
+ getPublicKey,
4
+ finalizeEvent,
5
+ nip19
6
+ } from 'nostr-tools'
7
+
8
+ import { bytesToHex, hexToBytes } from '../base16/index.js'
9
+
10
+ const HEX_SECKEY_REGEX = /^[0-9a-f]{64}$/i
11
+
12
+ export function generateKeypair () {
13
+ const secretKey = generateSecretKey()
14
+ const pubkey = getPublicKey(secretKey)
15
+ return {
16
+ secretKey,
17
+ seckey: bytesToHex(secretKey),
18
+ pubkey,
19
+ nsec: nip19.nsecEncode(secretKey),
20
+ npub: nip19.npubEncode(pubkey)
21
+ }
22
+ }
23
+
24
+ // Accepts either an `nsec1...` bech32 string or a 64-char hex secret key.
25
+ // Returns both encodings so callers can store the hex form and display the
26
+ // nsec form without re-decoding.
27
+ export function keypairFromSeckey (raw) {
28
+ let secretKey
29
+ if (HEX_SECKEY_REGEX.test(raw)) {
30
+ secretKey = hexToBytes(raw.toLowerCase())
31
+ } else {
32
+ const decoded = nip19.decode(raw)
33
+ if (decoded.type !== 'nsec') throw new Error('NOT_A_SECRET_KEY')
34
+ secretKey = decoded.data
35
+ }
36
+ const pubkey = getPublicKey(secretKey)
37
+ return {
38
+ secretKey,
39
+ seckey: bytesToHex(secretKey),
40
+ pubkey,
41
+ nsec: nip19.nsecEncode(secretKey),
42
+ npub: nip19.npubEncode(pubkey)
43
+ }
44
+ }
45
+
46
+ // npub-only: hex pubkeys are not accepted for read-only imports because
47
+ // bech32 includes a checksum that protects against user-typo imports of a
48
+ // pubkey they cannot sign for.
49
+ export function pubkeyFromNpub (npub) {
50
+ const decoded = nip19.decode(npub)
51
+ if (decoded.type !== 'npub') throw new Error('NOT_AN_NPUB')
52
+ return decoded.data
53
+ }
54
+
55
+ export function nsecFromHex (hex) {
56
+ return nip19.nsecEncode(hexToBytes(hex))
57
+ }
58
+
59
+ export function npubFromPubkey (pubkey) {
60
+ return nip19.npubEncode(pubkey)
61
+ }
62
+
63
+ function cleanProfileValue (value) {
64
+ return String(value ?? '').trim()
65
+ }
66
+
67
+ function profileContentFromEvent (event) {
68
+ if (!event?.content) return {}
69
+ try {
70
+ const parsed = JSON.parse(event.content)
71
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
72
+ } catch {}
73
+ return {}
74
+ }
75
+
76
+ export function profileEventTemplate ({ name = '', picture = '', profileEvent = null } = {}) {
77
+ const cleanName = cleanProfileValue(name)
78
+ const cleanPicture = cleanProfileValue(picture)
79
+ const content = profileContentFromEvent(profileEvent)
80
+
81
+ if (cleanName) content.name = cleanName
82
+ else delete content.name
83
+ if (cleanPicture) content.picture = cleanPicture
84
+ else delete content.picture
85
+
86
+ const tags = Array.isArray(profileEvent?.tags)
87
+ ? profileEvent.tags
88
+ .filter(t => Array.isArray(t) && t[0] !== 'name' && t[0] !== 'picture')
89
+ .map(t => t.slice())
90
+ : []
91
+ if (cleanName) tags.push(['name', cleanName])
92
+ if (cleanPicture) tags.push(['picture', cleanPicture])
93
+
94
+ return {
95
+ kind: 0,
96
+ created_at: Math.floor(Date.now() / 1000),
97
+ tags,
98
+ content: JSON.stringify(content)
99
+ }
100
+ }
101
+
102
+ export function signProfileEvent ({ secretKey, name = '', picture, profileEvent = null }) {
103
+ return finalizeEvent(profileEventTemplate({ name, picture, profileEvent }), secretKey)
104
+ }
105
+
106
+ // Signs a NIP-65 kind:10002 relay-list event. Each URL present in both
107
+ // `writeRelays` and `readRelays` is emitted as a bare `['r', url]` tag;
108
+ // otherwise it gets the explicit `'read'` or `'write'` marker.
109
+ export function signRelayListEvent ({ secretKey, writeRelays = [], readRelays = [] }) {
110
+ const write = new Set(writeRelays)
111
+ const read = new Set(readRelays)
112
+ const all = new Set([...write, ...read])
113
+ const tags = []
114
+ for (const url of all) {
115
+ const isWrite = write.has(url)
116
+ const isRead = read.has(url)
117
+ if (isWrite && isRead) tags.push(['r', url])
118
+ else if (isWrite) tags.push(['r', url, 'write'])
119
+ else tags.push(['r', url, 'read'])
120
+ }
121
+ return finalizeEvent({
122
+ kind: 10002,
123
+ created_at: Math.floor(Date.now() / 1000),
124
+ tags,
125
+ content: ''
126
+ }, secretKey)
127
+ }
128
+
129
+ export function parseProfileEvent (event) {
130
+ if (!event || event.kind !== 0) return { name: '', picture: '' }
131
+ let parsed = {}
132
+ try { parsed = JSON.parse(event.content) } catch { parsed = {} }
133
+ const fromTag = name => event.tags.find(t => t[0] === name)?.[1]
134
+ return {
135
+ name: (fromTag('name') || parsed.name || parsed.display_name || '').trim(),
136
+ about: (parsed.about || '').trim(),
137
+ picture: (fromTag('picture') || parsed.picture || '').trim()
138
+ }
139
+ }
@@ -0,0 +1,68 @@
1
+ export async function isOnline () {
2
+ if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
3
+ if (!navigator.onLine) return false
4
+ }
5
+ return hasInternetConnectivity()
6
+ }
7
+
8
+ const CONNECTIVITY_PROBE_URLS = [
9
+ { url: 'https://www.gstatic.com/generate_204' },
10
+ { url: 'https://connectivitycheck.gstatic.com/generate_204' },
11
+ { url: 'https://captive.apple.com/hotspot-detect.html' },
12
+ { method: 'GET', url: 'https://connectivity-check.ubuntu.com' }
13
+ ]
14
+
15
+ async function hasInternetConnectivity () {
16
+ const candidates = shuffle(CONNECTIVITY_PROBE_URLS)
17
+ for (const candidate of candidates) {
18
+ try {
19
+ await ping(candidate.url, { method: candidate.method })
20
+ return true
21
+ } catch (err) {
22
+ console.warn('connectivity probe failed', candidate.url, err?.message ?? err)
23
+ }
24
+ }
25
+ return false
26
+ }
27
+
28
+ function shuffle (list) {
29
+ const copy = list.slice()
30
+ for (let i = copy.length - 1; i > 0; i--) {
31
+ const j = Math.floor(Math.random() * (i + 1))
32
+ ;[copy[i], copy[j]] = [copy[j], copy[i]]
33
+ }
34
+ return copy
35
+ }
36
+
37
+ async function ping (url, { method = 'HEAD', timeout = 5000 } = {}) {
38
+ const abortController = typeof AbortController === 'function' ? new AbortController() : null
39
+ let timerId = null
40
+
41
+ const fetchPromise = fetch(url, {
42
+ method,
43
+ mode: 'no-cors',
44
+ cache: 'no-store',
45
+ redirect: 'follow',
46
+ signal: abortController?.signal
47
+ })
48
+
49
+ const completionPromise = fetchPromise.finally(() => {
50
+ if (timerId != null) clearTimeout(timerId)
51
+ })
52
+
53
+ const timeoutPromise = new Promise((_resolve, reject) => {
54
+ timerId = setTimeout(() => {
55
+ if (abortController) abortController.abort()
56
+ reject(new Error('PING_TIMEOUT'))
57
+ }, timeout)
58
+ })
59
+
60
+ await Promise.race([completionPromise, timeoutPromise])
61
+ return true
62
+ }
63
+
64
+ export function onOnline (handler) {
65
+ const listener = () => handler()
66
+ window.addEventListener('online', listener)
67
+ return () => window.removeEventListener('online', listener)
68
+ }
@@ -0,0 +1,175 @@
1
+ import { extract as hkdfExtract, expand as hkdfExpand } from '@noble/hashes/hkdf.js'
2
+ import { hmac } from '@noble/hashes/hmac.js'
3
+ import { sha256 } from '@noble/hashes/sha2.js'
4
+ import { concatBytes, utf8ToBytes } from '@noble/hashes/utils.js'
5
+ import { chacha20 } from '@noble/ciphers/chacha.js'
6
+ import { bytesToBase64, base64ToBytes } from '../base64/index.js'
7
+ import { sharedXOnlySecret } from '../ecdh/index.js'
8
+
9
+ // NIP-44 v3 — local implementation (spec.nostr.land/nip44v3)
10
+ // Copied from the bunker testbench and verified against the vendored
11
+ // upstream test-vectors.json, including non-standard zero-padding cases.
12
+
13
+ const PAD = { minimum_size: 32, subdivs_small: 4, subdivs_large: 8, large_threshold: 32768 }
14
+ const VERSION = 3
15
+ const ZERO_NONCE = new Uint8Array(12)
16
+ const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
17
+ const fatalTextDecoder = new TextDecoder('utf-8', { fatal: true })
18
+
19
+ export function targetSize (len) {
20
+ if (len <= 0) return PAD.minimum_size
21
+ const nextPower = 2 ** Math.ceil(Math.log2(len))
22
+ const subdivs = nextPower >= PAD.large_threshold ? PAD.subdivs_large : PAD.subdivs_small
23
+ const chunkSize = Math.max(PAD.minimum_size, Math.floor(nextPower / subdivs))
24
+ return chunkSize * Math.ceil(len / chunkSize)
25
+ }
26
+
27
+ function u32be (n) {
28
+ const b = new Uint8Array(4)
29
+ new DataView(b.buffer).setUint32(0, n >>> 0, false)
30
+ return b
31
+ }
32
+
33
+ function readU32be (b, off) {
34
+ return new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(off, false)
35
+ }
36
+
37
+ function equalBytes (a, b) {
38
+ if (a.length !== b.length) return false
39
+ let diff = 0
40
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
41
+ return diff === 0
42
+ }
43
+
44
+ function randomBytes32 () {
45
+ const bytes = new Uint8Array(32)
46
+ crypto.getRandomValues(bytes)
47
+ return bytes
48
+ }
49
+
50
+ export function deriveKeys (seckey, pubkey, nonce) {
51
+ const shared = sharedXOnlySecret(seckey, pubkey)
52
+ const salt = concatBytes(utf8ToBytes('nip44-v3\x00'), nonce)
53
+ const prk = hkdfExtract(sha256, shared, salt)
54
+ return {
55
+ prk,
56
+ encryption_key: hkdfExpand(sha256, prk, utf8ToBytes('encryption_key'), 32),
57
+ mac_key: hkdfExpand(sha256, prk, utf8ToBytes('mac_key'), 32)
58
+ }
59
+ }
60
+
61
+ function chacha (key, data) {
62
+ return chacha20(key, ZERO_NONCE, data)
63
+ }
64
+
65
+ function base64EncodedByteLength (byteLength) {
66
+ return Math.ceil(byteLength / 3) * 4
67
+ }
68
+
69
+ export function payloadByteLength (plaintextByteLength, scopeByteLength = 0) {
70
+ return base64EncodedByteLength(73 + scopeByteLength + targetSize(plaintextByteLength + 4))
71
+ }
72
+
73
+ export function deriveKeysFromConversationKey (conversationKey, nonce) {
74
+ const salt = concatBytes(utf8ToBytes('nip44-v3\x00'), nonce)
75
+ const prk = hkdfExtract(sha256, conversationKey, salt)
76
+ return {
77
+ prk,
78
+ encryption_key: hkdfExpand(sha256, prk, utf8ToBytes('encryption_key'), 32),
79
+ mac_key: hkdfExpand(sha256, prk, utf8ToBytes('mac_key'), 32)
80
+ }
81
+ }
82
+
83
+ // seckey: Uint8Array, pubkey: hex, scope/plaintext: Uint8Array. Returns base64 string.
84
+ export function encryptBytes (seckey, pubkey, kind, scope, plaintext, nonce) {
85
+ return encryptWithConversationKeyBytes(deriveSharedConversationKey(seckey, pubkey), kind, scope, plaintext, nonce)
86
+ }
87
+
88
+ export function encryptWithConversationKeyBytes (conversationKey, kind, scope, plaintext, nonce) {
89
+ nonce ??= randomBytes32()
90
+ const { encryption_key: encryptionKey, mac_key: macKey } = deriveKeysFromConversationKey(conversationKey, nonce)
91
+ const prefixed = concatBytes(u32be(plaintext.length), plaintext)
92
+ const padded = new Uint8Array(targetSize(prefixed.length))
93
+ padded.set(prefixed)
94
+ const ct = chacha(encryptionKey, padded)
95
+ const stuffing = concatBytes(u32be(kind), u32be(scope.length), scope, ct)
96
+ const mac = hmac(sha256, macKey, concatBytes(nonce, stuffing))
97
+ return bytesToBase64(concatBytes(new Uint8Array([VERSION]), nonce, mac, stuffing))
98
+ }
99
+
100
+ export function decryptBytes (seckey, pubkey, expectedKind, expectedScope, ciphertext) {
101
+ return decryptWithConversationKeyBytes(deriveSharedConversationKey(seckey, pubkey), expectedKind, expectedScope, ciphertext)
102
+ }
103
+
104
+ export function decryptWithConversationKeyBytes (conversationKey, expectedKind, expectedScope, ciphertext) {
105
+ if (!ciphertext || ciphertext.length === 0) throw new Error('empty ciphertext')
106
+ if (ciphertext[0] === '#') throw new Error('unsupported future version')
107
+ let decoded
108
+ try { decoded = base64ToBytes(ciphertext) } catch { throw new Error('invalid base64') }
109
+ if (decoded.length < 77) throw new Error('ciphertext too short')
110
+ if (decoded[0] !== VERSION) throw new Error(`unsupported version ${decoded[0]}`)
111
+ const nonce = decoded.subarray(1, 33)
112
+ const mac = decoded.subarray(33, 65)
113
+ const kind = readU32be(decoded, 65)
114
+ const scopeLength = readU32be(decoded, 69)
115
+ if (scopeLength > decoded.length - 73) throw new Error('invalid scope length')
116
+ const scope = decoded.subarray(73, 73 + scopeLength)
117
+ try { fatalTextDecoder.decode(scope) } catch { throw new Error('scope is not valid UTF-8') }
118
+ const ct = decoded.subarray(73 + scopeLength)
119
+ if (ct.length < 4) throw new Error('ciphertext too short')
120
+ if (kind !== expectedKind) throw new Error(`kind mismatch: got ${kind}, expected ${expectedKind}`)
121
+ if (!equalBytes(scope, expectedScope)) throw new Error('scope mismatch')
122
+ const { encryption_key: encryptionKey, mac_key: macKey } = deriveKeysFromConversationKey(conversationKey, nonce)
123
+ const authData = concatBytes(nonce, u32be(kind), u32be(scope.length), scope, ct)
124
+ if (!equalBytes(mac, hmac(sha256, macKey, authData))) throw new Error('invalid MAC')
125
+ const padded = chacha(encryptionKey, ct)
126
+ const plaintextLength = readU32be(padded, 0)
127
+ if (plaintextLength + 4 > padded.length) throw new Error('invalid plaintext length')
128
+ if (plaintextLength > 2 ** 31 - 1) throw new Error('plaintext too long')
129
+ // Only verify the padding is all-zeroes. Per spec, implementations MUST NOT do any
130
+ // other check on the padding length — non-standard zero-padding must decrypt.
131
+ const padding = padded.subarray(4 + plaintextLength)
132
+ if (!equalBytes(padding, new Uint8Array(padding.length))) throw new Error('invalid padding')
133
+ return padded.subarray(4, 4 + plaintextLength)
134
+ }
135
+
136
+ function deriveSharedConversationKey (seckey, pubkey) {
137
+ return sharedXOnlySecret(seckey, pubkey)
138
+ }
139
+
140
+ export function normalizeKind (kind) {
141
+ const n = typeof kind === 'string' && kind.trim() !== '' ? Number(kind) : kind
142
+ if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new Error('INVALID_KIND')
143
+ return n
144
+ }
145
+
146
+ // String-oriented helpers for app-facing methods. Plaintext travels as
147
+ // base64 on the NIP-07/46 wire so callers can encrypt arbitrary bytes.
148
+ export function encrypt (seckey, pubkey, kind, scope, plaintext) {
149
+ return encryptBytes(seckey, pubkey, normalizeKind(kind), utf8ToBytes(scope || ''), utf8ToBytes(plaintext))
150
+ }
151
+
152
+ export function decrypt (seckey, pubkey, kind, scope, ciphertext) {
153
+ return textDecoder.decode(decryptBytes(seckey, pubkey, normalizeKind(kind), utf8ToBytes(scope || ''), ciphertext))
154
+ }
155
+
156
+ export function encryptWithConversationKey (conversationKey, kind, scope, plaintext) {
157
+ return encryptWithConversationKeyBytes(conversationKey, normalizeKind(kind), utf8ToBytes(scope || ''), utf8ToBytes(plaintext))
158
+ }
159
+
160
+ export function decryptWithConversationKey (conversationKey, kind, scope, ciphertext) {
161
+ return textDecoder.decode(decryptWithConversationKeyBytes(conversationKey, normalizeKind(kind), utf8ToBytes(scope || ''), ciphertext))
162
+ }
163
+
164
+ export function nip07Encrypt (seckey, pubkey, kind, scope, plaintextB64) {
165
+ return encryptBytes(seckey, pubkey, normalizeKind(kind), utf8ToBytes(scope || ''), base64ToBytes(plaintextB64))
166
+ }
167
+
168
+ export function nip07Decrypt (seckey, pubkey, kind, scope, ciphertext) {
169
+ return bytesToBase64(decryptBytes(seckey, pubkey, normalizeKind(kind), utf8ToBytes(scope || ''), ciphertext))
170
+ }
171
+
172
+ export const b64encode = bytesToBase64
173
+ export const b64decode = base64ToBytes
174
+ export const toBytes = utf8ToBytes
175
+ export const fromBytes = b => textDecoder.decode(b)
@@ -0,0 +1,5 @@
1
+ // NIP-46 request and response event kind.
2
+ export const NIP46_KIND = 24133
3
+ export const DEFAULT_TIMEOUT = 30_000
4
+ export const DEFAULT_TIMEOUT_AFTER_FIRST_EOSE = 500
5
+ export const RELAY_SWITCH_WAIT_TIMEOUT = 1_000
@@ -0,0 +1,51 @@
1
+ import { finalizeEvent, verifyEvent } from 'nostr-tools'
2
+ import { decrypt, encrypt, getConversationKey } from 'nostr-tools/nip44'
3
+ import { NIP46_KIND } from '../constants/index.js'
4
+
5
+ const PUBKEY = /^[0-9a-f]{64}$/
6
+
7
+ export function validPubkey (value) {
8
+ return typeof value === 'string' && PUBKEY.test(value)
9
+ }
10
+
11
+ export function hasPTag (event, pubkey) {
12
+ return Array.isArray(event?.tags) && event.tags.some(tag => tag?.[0] === 'p' && tag?.[1] === pubkey)
13
+ }
14
+
15
+ export function isNip46EventFor (event, pubkey) {
16
+ return event?.kind === NIP46_KIND &&
17
+ validPubkey(event.pubkey) &&
18
+ hasPTag(event, pubkey) &&
19
+ verifyEvent(event)
20
+ }
21
+
22
+ export function decodeNip46Frame (event, secretKey) {
23
+ try {
24
+ const plaintext = decrypt(event.content, getConversationKey(secretKey, event.pubkey))
25
+ const frame = JSON.parse(plaintext)
26
+ return frame && typeof frame === 'object' && !Array.isArray(frame) ? frame : null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ export function createNip46Event ({ secretKey, recipientPubkey, payload }) {
33
+ return finalizeEvent({
34
+ kind: NIP46_KIND,
35
+ created_at: Math.floor(Date.now() / 1000),
36
+ tags: [['p', recipientPubkey]],
37
+ content: encrypt(JSON.stringify(payload), getConversationKey(secretKey, recipientPubkey))
38
+ }, secretKey)
39
+ }
40
+
41
+ export function validRequestFrame (frame) {
42
+ return typeof frame?.id === 'string' && frame.id &&
43
+ typeof frame.method === 'string' && frame.method &&
44
+ Array.isArray(frame.params) && frame.params.every(param => typeof param === 'string')
45
+ }
46
+
47
+ export function requestError (reason = 'NIP46_REQUEST_REJECTED') {
48
+ return reason instanceof Error
49
+ ? reason
50
+ : new Error(typeof reason === 'string' && reason ? reason : 'NIP46_REQUEST_REJECTED')
51
+ }
@@ -0,0 +1,96 @@
1
+ const PUBKEY = /^[0-9a-f]{64}$/
2
+
3
+ function uniqueStrings (values) {
4
+ if (!Array.isArray(values)) return []
5
+ return [...new Set(values.filter(value => typeof value === 'string' && value))]
6
+ }
7
+
8
+ function validPubkey (value) {
9
+ return typeof value === 'string' && PUBKEY.test(value)
10
+ }
11
+
12
+ export function normalizeBunkerPointer (pointer) {
13
+ if (!pointer || typeof pointer !== 'object') return null
14
+ const remoteSignerPubkey = String(pointer.remoteSignerPubkey || '').toLowerCase()
15
+ const relays = uniqueStrings(pointer.relays)
16
+ if (!validPubkey(remoteSignerPubkey) || !relays.length) return null
17
+ return {
18
+ remoteSignerPubkey,
19
+ relays,
20
+ secret: typeof pointer.secret === 'string' && pointer.secret ? pointer.secret : null
21
+ }
22
+ }
23
+
24
+ // Parses a direct NIP-46 bunker URL without performing a network lookup.
25
+ export function parseBunkerUrl (input) {
26
+ try {
27
+ const url = new URL(input)
28
+ if (url.protocol !== 'bunker:') return null
29
+ return normalizeBunkerPointer({
30
+ remoteSignerPubkey: url.hostname,
31
+ relays: url.searchParams.getAll('relay'),
32
+ secret: url.searchParams.get('secret')
33
+ })
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ // Serializes a direct NIP-46 bunker pointer without any network lookup.
40
+ export function toBunkerUrl (pointer) {
41
+ const normalized = normalizeBunkerPointer(pointer)
42
+ if (!normalized) throw new Error('INVALID_BUNKER_POINTER')
43
+ const url = new URL(`bunker://${normalized.remoteSignerPubkey}`)
44
+ for (const relay of normalized.relays) url.searchParams.append('relay', relay)
45
+ if (normalized.secret) url.searchParams.set('secret', normalized.secret)
46
+ return url.toString()
47
+ }
48
+
49
+ // Builds a NIP-46 client-initiated connection URI.
50
+ export function createNostrConnectURI ({
51
+ clientPubkey,
52
+ relays,
53
+ secret,
54
+ perms = [],
55
+ name,
56
+ url,
57
+ image
58
+ } = {}) {
59
+ const normalizedPubkey = String(clientPubkey || '').toLowerCase()
60
+ const normalizedRelays = uniqueStrings(relays)
61
+ if (!validPubkey(normalizedPubkey) || !normalizedRelays.length || typeof secret !== 'string' || !secret) {
62
+ throw new Error('INVALID_NOSTRCONNECT_URI')
63
+ }
64
+
65
+ const uri = new URL(`nostrconnect://${normalizedPubkey}`)
66
+ for (const relay of normalizedRelays) uri.searchParams.append('relay', relay)
67
+ uri.searchParams.set('secret', secret)
68
+ if (Array.isArray(perms) && perms.length) uri.searchParams.set('perms', perms.join(','))
69
+ if (name) uri.searchParams.set('name', name)
70
+ if (url) uri.searchParams.set('url', url)
71
+ if (image) uri.searchParams.set('image', image)
72
+ return uri.toString()
73
+ }
74
+
75
+ export function parseNostrConnectURI (input) {
76
+ try {
77
+ const url = new URL(input)
78
+ const clientPubkey = url.hostname.toLowerCase()
79
+ const relays = uniqueStrings(url.searchParams.getAll('relay'))
80
+ const secret = url.searchParams.get('secret') || ''
81
+ if (url.protocol !== 'nostrconnect:' || !validPubkey(clientPubkey) || !relays.length || !secret) return null
82
+ return {
83
+ clientPubkey,
84
+ relays,
85
+ secret,
86
+ perms: uniqueStrings((url.searchParams.get('perms') || '').split(',')),
87
+ clientMetadata: {
88
+ ...(url.searchParams.get('name') ? { name: url.searchParams.get('name') } : {}),
89
+ ...(url.searchParams.get('url') ? { url: url.searchParams.get('url') } : {}),
90
+ ...(url.searchParams.get('image') ? { image: url.searchParams.get('image') } : {})
91
+ }
92
+ }
93
+ } catch {
94
+ return null
95
+ }
96
+ }
package/nip46/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { NIP46_KIND } from './constants/index.js'
2
+ export { createNostrConnectURI, parseBunkerUrl, toBunkerUrl } from './helpers/url.js'
3
+ export { BunkerSigner } from './services/bunker-signer.js'
4
+ export { Nip46Client } from './services/client.js'
5
+ export { Nip46ServerSession } from './services/server-session.js'
@@ -0,0 +1,49 @@
1
+ import { verifyEvent } from 'nostr-tools'
2
+ import { validPubkey } from '../helpers/frame.js'
3
+ import { Nip46Client } from './client.js'
4
+
5
+ // A NIP-46 remote signer with the standard Nostr signing commands.
6
+ export class BunkerSigner extends Nip46Client {
7
+ #cachedPubkey = null
8
+
9
+ constructor (...args) {
10
+ super(...args)
11
+ Object.preventExtensions(this)
12
+ }
13
+
14
+ async getPublicKey (options) {
15
+ if (!options?.extension && this.#cachedPubkey) return this.#cachedPubkey
16
+ const pubkey = await this.sendRequest('get_public_key', [], options)
17
+ if (!validPubkey(pubkey)) throw new Error('NIP46_INVALID_PUBLIC_KEY')
18
+ if (!options?.extension) this.#cachedPubkey = pubkey
19
+ return pubkey
20
+ }
21
+
22
+ async signEvent (event, options) {
23
+ const response = await this.sendRequest('sign_event', [JSON.stringify(event)], options)
24
+ let signed
25
+ try {
26
+ signed = JSON.parse(response)
27
+ } catch {
28
+ throw new Error('NIP46_INVALID_SIGNED_EVENT')
29
+ }
30
+ if (!verifyEvent(signed)) throw new Error('NIP46_INVALID_SIGNED_EVENT')
31
+ return signed
32
+ }
33
+
34
+ nip04Encrypt (pubkey, plaintext, options) {
35
+ return this.sendRequest('nip04_encrypt', [pubkey, plaintext], options)
36
+ }
37
+
38
+ nip04Decrypt (pubkey, ciphertext, options) {
39
+ return this.sendRequest('nip04_decrypt', [pubkey, ciphertext], options)
40
+ }
41
+
42
+ nip44Encrypt (pubkey, plaintext, options) {
43
+ return this.sendRequest('nip44_encrypt', [pubkey, plaintext], options)
44
+ }
45
+
46
+ nip44Decrypt (pubkey, ciphertext, options) {
47
+ return this.sendRequest('nip44_decrypt', [pubkey, ciphertext], options)
48
+ }
49
+ }