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.
- package/AGENTS.md +20 -0
- package/README.md +129 -0
- package/base16/index.js +14 -0
- package/base64/index.js +29 -0
- package/content-key/event/index.js +85 -0
- package/content-key/index.js +1 -0
- package/content-key/services/iykc-proof.js +129 -0
- package/double-dh/index.js +166 -0
- package/ecdh/index.js +8 -0
- package/idb/index.js +64 -0
- package/idb-queue/index.js +793 -0
- package/index.js +21 -0
- package/key/index.js +139 -0
- package/network/index.js +68 -0
- package/nip44-v3/index.js +175 -0
- package/nip46/constants/index.js +5 -0
- package/nip46/helpers/frame.js +51 -0
- package/nip46/helpers/url.js +96 -0
- package/nip46/index.js +5 -0
- package/nip46/services/bunker-signer.js +49 -0
- package/nip46/services/client.js +252 -0
- package/nip46/services/server-session.js +225 -0
- package/nip46/services/transport.js +265 -0
- package/package.json +49 -0
- package/private-channel/constants/index.js +5 -0
- package/private-channel/helpers/chunk-size.js +61 -0
- package/private-channel/helpers/chunks.js +282 -0
- package/private-channel/helpers/event.js +68 -0
- package/private-channel/index.js +942 -0
- package/private-channel/services/received-chunks.js +398 -0
- package/private-message/index.js +672 -0
- package/private-messenger/constants/index.js +1 -0
- package/private-messenger/index.js +1431 -0
- package/private-messenger/recovery/index.js +316 -0
- package/relay/constants/index.js +18 -0
- package/relay/helpers/hll.js +62 -0
- package/relay/helpers/publish.js +100 -0
- package/relay/helpers/routing.js +36 -0
- package/relay/helpers/timer.js +4 -0
- package/relay/index.js +4 -0
- package/relay/services/query.js +224 -0
- package/relay/services/relay-connection.js +156 -0
- package/relay/services/relay-pool.js +908 -0
- package/temporary-storage/index.js +85 -0
- package/tests/base16.test.js +19 -0
- package/tests/base64.test.js +18 -0
- package/tests/content-key-event.test.js +48 -0
- package/tests/fixtures/nip44v3-vectors.json +1 -0
- package/tests/helpers/test-signer.js +185 -0
- package/tests/idb-queue.test.js +153 -0
- package/tests/idb.test.js +91 -0
- package/tests/nip44-v3.test.js +73 -0
- package/tests/nip46.test.js +668 -0
- package/tests/private-channel.test.js +1899 -0
- package/tests/private-message.test.js +460 -0
- package/tests/private-messenger.test.js +1715 -0
- package/tests/queries.test.js +101 -0
- package/tests/queue-parity.test.js +105 -0
- package/tests/relay-hll.test.js +32 -0
- package/tests/relay-pool.test.js +2067 -0
- package/tests/temporary-storage.test.js +89 -0
- package/tests/web-storage-queue.test.js +480 -0
- 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
|
+
}
|
package/network/index.js
ADDED
|
@@ -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,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
|
+
}
|