Compare commits

..

1 Commits

Author SHA1 Message Date
fiatjaf
0d0557b9a2 fix buffer import and use rollup for transpiling the package. 2021-12-11 08:03:27 -03:00
14 changed files with 172 additions and 310 deletions

View File

@@ -1,5 +1,4 @@
{ {
"root": true,
"parserOptions": { "parserOptions": {
"ecmaVersion": 9, "ecmaVersion": 9,
"ecmaFeatures": { "ecmaFeatures": {

View File

@@ -67,6 +67,4 @@ pool.addRelay('<url>')
// will automatically subscribe to the all the events called with .sub above // will automatically subscribe to the all the events called with .sub above
``` ```
All functions expect bytearrays as hex strings and output bytearrays as hex strings.
For other utils please read the source (for now). For other utils please read the source (for now).

View File

@@ -1,7 +1,8 @@
import {Buffer} from 'buffer' import Buffer from 'buffer'
import createHash from 'create-hash'
import * as secp256k1 from '@noble/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {sha256} from './utils'
export function getBlankEvent() { export function getBlankEvent() {
return { return {
kind: 255, kind: 255,
@@ -18,39 +19,25 @@ export function serializeEvent(evt) {
evt.pubkey, evt.pubkey,
evt.created_at, evt.created_at,
evt.kind, evt.kind,
evt.tags, evt.tags || [],
evt.content evt.content
]) ])
} }
export function getEventHash(event) { export async function getEventHash(event) {
let eventHash = createHash('sha256') let eventHash = await sha256(Buffer.from(serializeEvent(event)))
.update(Buffer.from(serializeEvent(event)))
.digest()
return Buffer.from(eventHash).toString('hex') return Buffer.from(eventHash).toString('hex')
} }
export function validateEvent(event) { export async function verifySignature(event) {
if (event.id !== getEventHash(event)) return false return await secp256k1.schnorr.verify(
if (typeof event.content !== 'string') return false event.sig,
if (typeof event.created_at !== 'number') return false await getEventHash(event),
event.pubkey
if (!Array.isArray(event.tags)) return false )
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
export function verifySignature(event) {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
} }
export async function signEvent(event, key) { export async function signEvent(event, key) {
return secp256k1.schnorr.sign(getEventHash(event), key) let eventHash = await getEventHash(event)
return await secp256k1.schnorr.sign(eventHash, key)
} }

View File

@@ -1,30 +0,0 @@
export function matchFilter(filter, event) {
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
return false
for (let f in filter) {
if (f[0] === '#') {
if (
filter[f] &&
!event.tags.find(
([t, v]) => t === f.slice(1) && filter[f].indexOf(v) !== -1
)
)
return false
}
}
if (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at >= filter.until) return false
return true
}
export function matchFilters(filters, event) {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}

View File

@@ -1,27 +1,25 @@
import {generatePrivateKey, getPublicKey} from './keys'
import {relayConnect} from './relay' import {relayConnect} from './relay'
import {relayPool} from './pool' import {relayPool} from './pool'
import { import {
getBlankEvent, getBlankEvent,
signEvent, signEvent,
validateEvent,
verifySignature, verifySignature,
serializeEvent, serializeEvent,
getEventHash getEventHash
} from './event' } from './event'
import {matchFilter, matchFilters} from './filter' import {makeRandom32, sha256, getPublicKey} from './utils'
export { export {
generatePrivateKey,
relayConnect, relayConnect,
relayPool, relayPool,
signEvent, signEvent,
validateEvent,
verifySignature, verifySignature,
serializeEvent, serializeEvent,
getEventHash, getEventHash,
makeRandom32,
sha256,
getPublicKey, getPublicKey,
getBlankEvent, getBlankEvent
matchFilter,
matchFilters
} }
export * from './nip04'
export * from './nip05'

View File

@@ -1,9 +0,0 @@
import * as secp256k1 from '@noble/secp256k1'
export function generatePrivateKey() {
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex')
}
export function getPublicKey(privateKey) {
return secp256k1.schnorr.getPublicKey(privateKey)
}

View File

@@ -1,14 +1,12 @@
import aes from 'browserify-cipher' import Buffer from 'buffer'
import {Buffer} from 'buffer'
import {randomBytes} from '@noble/hashes/utils'
import * as secp256k1 from '@noble/secp256k1' import * as secp256k1 from '@noble/secp256k1'
export function encrypt(privkey, pubkey, text) { export function encrypt(privkey, pubkey, text) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey) const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key) const normalizedKey = getOnlyXFromFullSharedSecret(key)
let iv = Uint8Array.from(randomBytes(16)) let iv = crypto.randomFillSync(new Uint8Array(16))
var cipher = aes.createCipheriv( var cipher = crypto.createCipheriv(
'aes-256-cbc', 'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'), Buffer.from(normalizedKey, 'hex'),
iv iv
@@ -16,20 +14,19 @@ export function encrypt(privkey, pubkey, text) {
let encryptedMessage = cipher.update(text, 'utf8', 'base64') let encryptedMessage = cipher.update(text, 'utf8', 'base64')
encryptedMessage += cipher.final('base64') encryptedMessage += cipher.final('base64')
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}` return [encryptedMessage, Buffer.from(iv.buffer).toString('base64')]
} }
export function decrypt(privkey, pubkey, ciphertext) { export function decrypt(privkey, pubkey, ciphertext, iv) {
let [cip, iv] = ciphertext.split('?iv=') const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey) const normalizedKey = getOnlyXFromFullSharedSecret(key)
let normalizedKey = getOnlyXFromFullSharedSecret(key)
var decipher = aes.createDecipheriv( var decipher = crypto.createDecipheriv(
'aes-256-cbc', 'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'), Buffer.from(normalizedKey, 'hex'),
Buffer.from(iv, 'base64') Buffer.from(iv, 'base64')
) )
let decryptedMessage = decipher.update(cip, 'base64') let decryptedMessage = decipher.update(ciphertext, 'base64')
decryptedMessage += decipher.final('utf8') decryptedMessage += decipher.final('utf8')
return decryptedMessage return decryptedMessage

View File

@@ -1,32 +1,52 @@
import fetch from 'cross-fetch' import Buffer from 'buffer'
import dnsPacket from 'dns-packet'
const dohProviders = [
'cloudflare-dns.com',
'fi.doh.dns.snopyta.org',
'basic.bravedns.com',
'hydra.plan9-ns1.com',
'doh.pl.ahadns.net',
'dns.flatuslifir.is',
'doh.dns.sb',
'doh.li'
]
let counter = 0
export async function keyFromDomain(domain) {
let host = dohProviders[counter % dohProviders.length]
let buf = dnsPacket.encode({
type: 'query',
id: Math.floor(Math.random() * 65534),
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
type: 'TXT',
name: `_nostrkey.${domain}`
}
]
})
let fetching = fetch(`https://${host}/dns-query`, {
method: 'POST',
headers: {
'Content-Type': 'application/dns-message',
'Content-Length': Buffer.byteLength(buf)
},
body: buf
})
counter++
export async function searchDomain(domain, query = '') {
try { try {
let res = await ( let response = Buffer.from(await (await fetching).arrayBuffer())
await fetch(`https://${domain}/.well-known/nostr.json?name=${query}`) let {answers} = dnsPacket.decode(response)
).json() if (answers.length === 0) return null
return Buffer.from(answers[0].data[0]).toString()
return res.names } catch (err) {
} catch (_) { console.log(`error querying DNS for ${domain} on ${host}`, err)
return []
}
}
export async function queryName(fullname) {
try {
let [name, domain] = fullname.split('@')
if (!domain) {
domain = name
name = '_'
}
let res = await (
await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
return res.names && res.names[name]
} catch (_) {
return null return null
} }
} }

View File

@@ -1,26 +0,0 @@
import {wordlist} from 'micro-bip39/wordlists/english'
import {
generateMnemonic,
mnemonicToSeedSync,
validateMnemonic
} from 'micro-bip39'
import {HDKey} from 'micro-bip32'
export function privateKeyFromSeed(seed) {
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex'))
return Buffer.from(root.derive(`m/44'/1237'/0'/0/0`).privateKey).toString(
'hex'
)
}
export function seedFromWords(mnemonic) {
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
}
export function generateSeedWords() {
return generateMnemonic(wordlist)
}
export function validateWords(words) {
return validateMnemonic(words, wordlist)
}

View File

@@ -1,20 +1,18 @@
{ {
"name": "nostr-tools", "name": "nostr-tools",
"version": "0.21.0", "version": "0.6.3",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"main": "dist/nostr-tools.esm.min.js",
"module": "dist/nostr-tools.esm.min.js",
"browser": "dist/nostr-tools.umd.min.js",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/fiatjaf/nostr-tools.git" "url": "https://github.com/fiatjaf/nostr-tools.git"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^0.5.7",
"@noble/secp256k1": "^1.3.0", "@noble/secp256k1": "^1.3.0",
"browserify-cipher": ">=1", "buffer": "^6.0.3",
"buffer": ">=5", "dns-packet": "^5.2.4",
"create-hash": "^1.2.0",
"cross-fetch": "^3.1.4",
"micro-bip32": "^0.1.0",
"micro-bip39": "^0.1.3",
"websocket-polyfill": "^0.0.3" "websocket-polyfill": "^0.0.3"
}, },
"keywords": [ "keywords": [
@@ -30,7 +28,9 @@
"client" "client"
], ],
"devDependencies": { "devDependencies": {
"eslint": "^8.5.0", "rollup": "^2.61.1"
"eslint-plugin-babel": "^5.3.1" },
"scripts": {
"prepublish": "rollup -c"
} }
} }

130
pool.js
View File

@@ -1,20 +1,9 @@
import {getEventHash, verifySignature, signEvent} from './event' import {getEventHash, signEvent} from './event'
import {relayConnect, normalizeRelayURL} from './relay' import {relayConnect, normalizeRelayURL} from './relay'
export function relayPool() { export function relayPool(globalPrivateKey) {
var globalPrivateKey
var globalSigningFunction
const poolPolicy = {
// setting this to a number will cause events to be published to a random
// set of relays only, instead of publishing to all relays all the time
randomChoice: null,
// setting this to true will cause .publish() calls to wait until the event has
// been published -- or at least attempted to be published -- to all relays
wait: false
}
const relays = {} const relays = {}
const globalSub = []
const noticeCallbacks = [] const noticeCallbacks = []
function propagateNotice(notice, relayURL) { function propagateNotice(notice, relayURL) {
@@ -32,41 +21,32 @@ export function relayPool() {
.filter(({policy}) => policy.read) .filter(({policy}) => policy.read)
.map(({relay}) => [ .map(({relay}) => [
relay.url, relay.url,
relay.sub({filter, cb: event => cb(event, relay.url)}, id) relay.sub({filter, cb: event => cb(event, relay.url)})
]) ])
) )
const activeCallback = cb const activeCallback = cb
const activeFilters = filter const activeFilters = filter
const unsub = () => { activeSubscriptions[id] = {
Object.values(subControllers).forEach(sub => sub.unsub()) sub: ({cb = activeCallback, filter = activeFilters}) =>
delete activeSubscriptions[id]
}
const sub = ({cb = activeCallback, filter = activeFilters}) => {
Object.entries(subControllers).map(([relayURL, sub]) => [ Object.entries(subControllers).map(([relayURL, sub]) => [
relayURL, relayURL,
sub.sub({cb, filter}, id) sub.sub({cb, filter}, id)
]) ]),
return activeSubscriptions[id] addRelay: relay => {
} subControllers[relay.url] = relay.sub({cb, filter})
const addRelay = relay => { },
subControllers[relay.url] = relay.sub({cb, filter}, id) removeRelay: relayURL => {
return activeSubscriptions[id]
}
const removeRelay = relayURL => {
if (relayURL in subControllers) { if (relayURL in subControllers) {
subControllers[relayURL].unsub() subControllers[relayURL].unsub()
if (Object.keys(subControllers).length === 0) unsub() if (Object.keys(subControllers).length === 0) unsub()
} }
return activeSubscriptions[id] },
unsub: () => {
Object.values(subControllers).forEach(sub => sub.unsub())
delete activeSubscriptions[id]
} }
activeSubscriptions[id] = {
sub,
unsub,
addRelay,
removeRelay
} }
return activeSubscriptions[id] return activeSubscriptions[id]
@@ -78,35 +58,25 @@ export function relayPool() {
setPrivateKey(privateKey) { setPrivateKey(privateKey) {
globalPrivateKey = privateKey globalPrivateKey = privateKey
}, },
registerSigningFunction(fn) { async addRelay(url, policy = {read: true, write: true}) {
globalSigningFunction = fn
},
setPolicy(key, value) {
poolPolicy[key] = value
},
addRelay(url, policy = {read: true, write: true}) {
let relayURL = normalizeRelayURL(url) let relayURL = normalizeRelayURL(url)
if (relayURL in relays) return if (relayURL in relays) return
let relay = relayConnect(url, notice => { let relay = await relayConnect(url, notice => {
propagateNotice(notice, relayURL) propagateNotice(notice, relayURL)
}) })
relays[relayURL] = {relay, policy} relays[relayURL] = {relay, policy}
if (policy.read) {
Object.values(activeSubscriptions).forEach(subscription => Object.values(activeSubscriptions).forEach(subscription =>
subscription.addRelay(relay) subscription.addRelay(relay)
) )
}
return relay return relay
}, },
removeRelay(url) { removeRelay(url) {
let relayURL = normalizeRelayURL(url) let relayURL = normalizeRelayURL(url)
let data = relays[relayURL] let {relay} = relays[relayURL]
if (!data) return if (!relay) return
let {relay} = data
Object.values(activeSubscriptions).forEach(subscription => Object.values(activeSubscriptions).forEach(subscription =>
subscription.removeRelay(relay) subscription.removeRelay(relay)
) )
@@ -120,76 +90,32 @@ export function relayPool() {
let index = noticeCallbacks.indexOf(cb) let index = noticeCallbacks.indexOf(cb)
if (index !== -1) noticeCallbacks.splice(index, 1) if (index !== -1) noticeCallbacks.splice(index, 1)
}, },
async publish(event, statusCallback) { async publish(event, statusCallback = (status, relayURL) => {}) {
event.id = getEventHash(event)
if (!event.sig) { if (!event.sig) {
event.tags = event.tags || [] event.tags = event.tags || []
if (globalPrivateKey) { if (globalPrivateKey) {
event.id = await getEventHash(event)
event.sig = await signEvent(event, globalPrivateKey) event.sig = await signEvent(event, globalPrivateKey)
} else if (globalSigningFunction) {
event.sig = await globalSigningFunction(event)
if (!event.sig) {
// abort here
return
} else {
// check
if (!(await verifySignature(event)))
throw new Error(
'signature provided by custom signing function is invalid.'
)
}
} else { } else {
throw new Error( throw new Error(
"can't publish unsigned event. either sign this event beforehand, provide a signing function or pass a private key while initializing this relay pool so it can be signed automatically." "can't publish unsigned event. either sign this event beforehand or pass a private key while initializing this relay pool so it can be signed automatically."
) )
} }
} }
let writeable = Object.values(relays) Object.values(relays)
.filter(({policy}) => policy.write) .filter(({policy}) => policy.write)
.sort(() => Math.random() - 0.5) // random .map(async ({relay}) => {
let maxTargets = poolPolicy.randomChoice
? poolPolicy.randomChoice
: writeable.length
let successes = 0
if (poolPolicy.wait) {
for (let i = 0; i < writeable.length; i++) {
let {relay} = writeable[i]
try { try {
await new Promise(async (resolve, reject) => { await relay.publish(event, status =>
try { statusCallback(status, relay.url)
await relay.publish(event, status => { )
if (statusCallback) statusCallback(status, relay.url)
resolve()
})
} catch (err) { } catch (err) {
if (statusCallback) statusCallback(-1, relay.url) statusCallback(-1, relay.url)
} }
}) })
successes++
if (successes >= maxTargets) {
break
}
} catch (err) {
/***/
}
}
} else {
writeable.forEach(async ({relay}) => {
let callback = statusCallback
? status => statusCallback(status, relay.url)
: null
relay.publish(event, callback)
})
}
return event return event
} }
} }

View File

@@ -1,22 +1,19 @@
/* global WebSocket */
import 'websocket-polyfill' import 'websocket-polyfill'
import {verifySignature, validateEvent} from './event' import {verifySignature} from './event'
import {matchFilters} from './filter'
export function normalizeRelayURL(url) { export function normalizeRelayURL(url) {
let [host, ...qs] = url.trim().split('?') let [host, ...qs] = url.split('?')
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4) if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
if (host.slice(0, 2) !== 'ws') host = 'wss://' + host if (host.slice(0, 2) !== 'ws') host = 'wss://' + host
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1) if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
return [host, ...qs].join('?') return [host, ...qs].join('?')
} }
export function relayConnect(url, onNotice = () => {}, onError = () => {}) { export function relayConnect(url, onNotice) {
url = normalizeRelayURL(url) url = normalizeRelayURL(url)
var ws, resolveOpen, untilOpen, wasClosed var ws, resolveOpen, untilOpen
var openSubs = {} var openSubs = {}
let attemptNumber = 1 let attemptNumber = 1
let nextAttemptSeconds = 1 let nextAttemptSeconds = 1
@@ -37,26 +34,19 @@ export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
resolveOpen() resolveOpen()
// restablish old subscriptions // restablish old subscriptions
if (wasClosed) {
wasClosed = false
for (let channel in openSubs) { for (let channel in openSubs) {
let filters = openSubs[channel] let filters = openSubs[channel]
let cb = channels[channel] let cb = channels[channel]
sub({cb, filter: filters}, channel) sub({cb, filter: filters}, channel)
} }
} }
} ws.onerror = () => {
ws.onerror = err => {
console.log('error connecting to relay', url) console.log('error connecting to relay', url)
onError(err)
} }
ws.onclose = () => { ws.onclose = () => {
resetOpenState() resetOpenState()
attemptNumber++ attemptNumber++
nextAttemptSeconds += attemptNumber ** 3 nextAttemptSeconds += attemptNumber
if (nextAttemptSeconds > 14400) {
nextAttemptSeconds = 14400 // 4 hours
}
console.log( console.log(
`relay ${url} connection closed. reconnecting in ${nextAttemptSeconds} seconds.` `relay ${url} connection closed. reconnecting in ${nextAttemptSeconds} seconds.`
) )
@@ -65,8 +55,6 @@ export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
connect() connect()
} catch (err) {} } catch (err) {}
}, nextAttemptSeconds * 1000) }, nextAttemptSeconds * 1000)
wasClosed = true
} }
ws.onmessage = async e => { ws.onmessage = async e => {
@@ -92,14 +80,13 @@ export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
let channel = data[1] let channel = data[1]
let event = data[2] let event = data[2]
if ( if (await verifySignature(event)) {
validateEvent(event) && if (channels[channel]) {
verifySignature(event) &&
channels[channel] &&
matchFilters(openSubs[channel], event)
) {
channels[channel](event) channels[channel](event)
} }
} else {
console.warn('got event with invalid signature from ' + url, event)
}
return return
} }
} }
@@ -148,26 +135,19 @@ export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
return { return {
url, url,
sub, sub,
async publish(event, statusCallback) { async publish(event, statusCallback = status => {}) {
try { try {
await trySend(['EVENT', event]) await trySend(['EVENT', event])
if (statusCallback) {
statusCallback(0) statusCallback(0)
let {unsub} = sub( let {unsub} = relay.sub({
{
cb: () => { cb: () => {
statusCallback(1) statusCallback(1)
unsub()
clearTimeout(willUnsub)
}, },
filter: {id: event.id} filter: {id: event.id}
}, })
`monitor-${event.id.slice(0, 5)}` setTimeout(unsub, 5000)
)
let willUnsub = setTimeout(unsub, 5000)
}
} catch (err) { } catch (err) {
if (statusCallback) statusCallback(-1) statusCallback(-1)
} }
}, },
close() { close() {

16
rollup.config.js Normal file
View File

@@ -0,0 +1,16 @@
import pkg from './package.json'
export default {
input: 'index.js',
output: [
{
name: 'nostrtools',
file: pkg.browser,
format: 'umd'
},
{
file: pkg.module,
format: 'es'
}
]
}

6
utils.js Normal file
View File

@@ -0,0 +1,6 @@
import * as secp256k1 from '@noble/secp256k1'
export const makeRandom32 = () => secp256k1.utils.randomPrivateKey()
export const sha256 = m => secp256k1.utils.sha256(Uint8Array.from(m))
export const getPublicKey = privateKey =>
secp256k1.schnorr.getPublicKey(privateKey)