new nostr-tools which abstracts all the relay activity.

This commit is contained in:
fiatjaf 2021-01-09 18:06:26 -03:00
parent 5921ad1080
commit 44edef63f9
11 changed files with 1686 additions and 61 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules
dist

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
## Usage
```js
import {relayPool} from 'nostr-tools'
const pool = relayPool()
pool.setPrivateKey('<hex>') // optional
pool.addRelay('ws://some.relay.com', {read: true, write: true})
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
pool.onEvent((context, event, relay) => {
console.log(`got a relay with context ${context} from ${relay.url} which is already validated.`, event)
})
// subscribing to users and requesting specific users or events:
pool.subKey('<hex>')
pool.subKey('<hex>')
pool.subKey('<hex>')
pool.reqFeed()
pool.reqEvent({id: '<hex>'})
pool.reqKey({key: '<hex>'})
// upon request the events will be received on .onEvent above
// publishing events:
pool.publish(<event object>)
// it will be signed automatically with the key supplied above
// or pass an already signed event to bypass this
// subscribing to a new relay
pool.addRelay('<url>')
// will automatically subscribe to the all the keys called with .subKey above
```
For other utils please read the source (for now).

View File

@ -1,8 +1,6 @@
import shajs from 'sha.js'
import BigInteger from 'bigi'
import schnorr from 'bip-schnorr'
import * as secp256k1 from 'noble-secp256k1'
import {makeRandom32} from './utils'
import {sha256} from './utils'
export function serializeEvent(evt) {
return JSON.stringify([
@ -15,27 +13,22 @@ export function serializeEvent(evt) {
])
}
export function getEventID(event) {
let hash = shajs('sha256').update(serializeEvent(event)).digest()
return hash.toString('hex')
export function getEventHash(event) {
let eventHash = sha256(Buffer.from(serializeEvent(event)))
return Buffer.from(eventHash).toString('hex')
}
export function verifySignature(event) {
try {
schnorr.verify(
Buffer.from(event.pubkey, 'hex'),
Buffer.from(getEventID(event), 'hex'),
Buffer.from(event.sig, 'hex')
)
return true
} catch (err) {
return false
}
export async function verifySignature(event) {
return await secp256k1.schnorr.verify(
event.signature,
getEventHash(event),
event.pubkey
)
}
export function signEvent(event, key) {
let eventHash = shajs('sha256').update(serializeEvent(event)).digest()
schnorr
.sign(new BigInteger(key, 16), eventHash, makeRandom32())
.toString('hex')
export async function signEvent(event, key) {
let eventHash = getEventHash(event)
return Buffer.from(await secp256k1.schnorr.sign(key, eventHash)).toString(
'hex'
)
}

View File

@ -1,4 +1,16 @@
export {relayConnect} from './relay'
export {signEvent, verifySignature, serializeEvent, getEventID} from './event'
export {pubkeyFromPrivate} from './schnorr'
export {makeRandom32} from './utils'
import {relayConnect} from './relay'
import {relayPool} from './pool'
import {signEvent, verifySignature, serializeEvent, getEventHash} from './event'
import {makeRandom32, sha256, getPublicKey} from './utils'
export {
relayConnect,
relayPool,
signEvent,
verifySignature,
serializeEvent,
getEventHash,
makeRandom32,
sha256,
getPublicKey
}

View File

@ -1,13 +1,39 @@
{
"name": "nostr-tools",
"version": "0.0.2",
"version": "0.1.0",
"description": "Tools for making a Nostr client.",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://github.com/fiatjaf/nostr.git"
},
"dependencies": {
"assert": "^2.0.0",
"bigi": "^1.4.2",
"bip-schnorr": "^0.6.2",
"buffer": "^6.0.3",
"ecurve": "^1.0.6",
"pws": "^5.0.2",
"sha.js": "^2.4.11"
"noble-secp256k1": "^1.1.1",
"pws": "^5.0.2"
},
"scripts": {
"prepublish": "rollup -c rollup.config.js"
},
"keywords": [
"decentralization",
"twitter",
"p2p",
"mastodon",
"ssb",
"social",
"unstoppable",
"censorship",
"censorship-resistance",
"client"
],
"devDependencies": {
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@rollup/plugin-babel": "^5.2.2",
"@rollup/plugin-node-resolve": "^11.0.1",
"rollup": "^2.36.1",
"rollup-plugin-ignore": "^1.0.9",
"rollup-plugin-terser": "^7.0.2"
}
}

142
pool.js Normal file
View File

@ -0,0 +1,142 @@
import {getEventHash, signEvent} from './event'
import {relayConnect, normalizeRelayURL} from './relay'
export function relayPool(globalPrivateKey) {
const relays = {}
const globalSub = []
const attemptCallbacks = []
const eventCallbacks = []
const noticeCallbacks = []
function propagateEvent(context, event, relayURL) {
for (let i = 0; i < eventCallbacks.length; i++) {
let {relay} = relays[relayURL]
eventCallbacks[i](context, event, relay)
}
}
function propagateNotice(notice, relayURL) {
for (let i = 0; i < noticeCallbacks.length; i++) {
let {relay} = relays[relayURL]
noticeCallbacks[i](notice, relay)
}
}
function propagateAttempt(eventId, status, relayURL) {
for (let i = 0; i < attemptCallbacks.length; i++) {
let {relay} = relays[relayURL]
attemptCallbacks[i](eventId, status, relay)
}
}
async function relaysEach(fn, policyFilter) {
for (let relayURL in relays) {
let {relay, policy} = relays[relayURL]
if (policyFilter.write && policy.write) {
await fn(relay)
} else if (policyFilter.read && policy.read) {
await fn(relays)
}
}
}
return {
relays,
setPrivateKey(privateKey) {
globalPrivateKey = privateKey
},
addRelay(url, policy = {read: true, write: true}) {
let relayURL = normalizeRelayURL(url)
if (relayURL in relays) return
let relay = relayConnect(
url,
(context, event) => {
propagateEvent(context, event, relayURL)
},
notice => {
propagateNotice(notice, relayURL)
}
)
relays[relayURL] = {relay, policy}
// automatically subscribe to everybody on this
for (let key in globalSub) {
relay.subKey(key)
}
return relay
},
removeRelay(url) {
let relayURL = normalizeRelayURL(url)
let {relay} = relays[relayURL]
if (!relay) return
relay.close()
delete relays[relayURL]
},
onEvent(cb) {
eventCallbacks.push(cb)
},
offEvent(cb) {
let index = eventCallbacks.indexOf(cb)
if (index !== -1) eventCallbacks.splice(index, 1)
},
onNotice(cb) {
noticeCallbacks(cb)
},
offNotice(cb) {
let index = noticeCallbacks.indexOf(cb)
if (index !== -1) noticeCallbacks.splice(index, 1)
},
onAttempt(cb) {
attemptCallbacks(cb)
},
offAttempt(cb) {
let index = attemptCallbacks.indexOf(cb)
if (index !== -1) attemptCallbacks.splice(index, 1)
},
async publish(event) {
if (!event.signature) {
event.tags = event.tags || []
if (globalPrivateKey) {
event.id = getEventHash(event)
event.signature = await signEvent(event, globalPrivateKey)
} else {
throw new Error(
"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."
)
}
}
await relaysEach(
async relay => {
try {
await relay.publish(event)
propagateAttempt(event.id, 'sent', relay.url)
} catch (err) {
propagateAttempt(event.id, 'failed', relay.url)
}
},
{write: true}
)
return event
},
async subKey(key) {
globalSub[key] = true
await relaysEach(async relay => relay.subKey(key), {read: true})
},
async unsubKey(key) {
delete globalSub[key]
await relaysEach(async relay => relay.unsubKey(key), {read: true})
},
async reqFeed(params = {}) {
await relaysEach(async relay => relay.reqFeed(params), {read: true})
},
async reqEvent(params) {
await relaysEach(async relay => relay.reqEvent(params), {read: true})
},
async reqKey(params) {
await relaysEach(async relay => relay.reqKey(params), {read: true})
}
}
}

View File

@ -1,44 +1,81 @@
import PersistentWebSocket from 'pws'
import {verifySignature} from './event'
export function relayConnect(url, onEventCallback) {
if (url.length && url[url.length - 1] === '/') url = url.slice(0, -1)
export function normalizeRelayURL(url) {
let [host, ...qs] = url.split('?')
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
if (host.slice(-3) !== '/ws') host = host + '/ws'
return [host, ...qs].join('?')
}
const ws = new PersistentWebSocket(url + '/ws?session=' + Math.random(), {
export function relayConnect(url, onEvent, onNotice) {
url = normalizeRelayURL(url)
url = url +=
(url.indexOf('?') !== -1 ? '&' : '?') + `session=${Math.random()}`
const ws = new PersistentWebSocket(url, {
pingTimeout: 30 * 1000
})
ws.onopen = () => console.log('connected to ', url)
var isOpen
let untilOpen = new Promise(resolve => {
isOpen = resolve
})
ws.onopen = () => {
console.log('connected to ', url)
isOpen()
}
ws.onerror = err => console.log('error connecting', url, err)
ws.onmessage = e => {
ws.onmessage = async e => {
let data = JSON.parse(e.data)
if (data.length > 1) {
if (data[0] === 'notice') {
console.log('message from relay ' + url + ' :' + data[1])
onNotice(data[1])
} else if (typeof data[0] === 'object') {
onEventCallback(data[0], data[1])
let context = data[0]
let event = data[1]
if (await verifySignature(event)) {
onEvent(context, event)
} else {
console.warn(
'got event with invalid signature from ' + url,
event,
context
)
}
}
}
}
return {
url,
subKey(key) {
async subKey(key) {
await untilOpen
ws.send('sub-key:' + key)
},
unsubKey(key) {
async unsubKey(key) {
await untilOpen
ws.send('unsub-key:' + key)
},
homeFeed(params = {}) {
async reqFeed(params = {}) {
await untilOpen
ws.send('req-feed:' + JSON.stringify(params))
},
reqEvent(params) {
async reqEvent(params) {
await untilOpen
ws.send('req-key:' + JSON.stringify(params))
},
reqKey(params) {
async reqKey(params) {
await untilOpen
ws.send('req-key:' + JSON.stringify(params))
},
sendEvent(event) {
async publish(event) {
await untilOpen
ws.send(JSON.stringify(event))
},
close() {

26
rollup.config.js Normal file
View File

@ -0,0 +1,26 @@
import {nodeResolve} from '@rollup/plugin-node-resolve'
import {terser} from 'rollup-plugin-terser'
import babel from '@rollup/plugin-babel'
import pkg from './package.json'
const input = 'index.js'
export default {
input,
output: {
sourcemap: true,
format: 'umd',
name: 'nostr',
file: `dist/${pkg.name}.min.js`,
exports: 'named',
esModule: false
},
plugins: [
nodeResolve(),
babel({
babelHelpers: 'bundled'
}),
terser()
]
}

View File

@ -1,10 +0,0 @@
import BigInteger from 'bigi'
import ecurve from 'ecurve'
const curve = ecurve.getCurveByName('secp256k1')
const G = curve.G
export function pubkeyFromPrivate(privateHex) {
const privKey = BigInteger.fromHex(privateHex)
return G.multiply(privKey).getEncoded(true).slice(1).toString('hex')
}

View File

@ -1,5 +1,6 @@
export function makeRandom32() {
var array = new Uint32Array(32)
window.crypto.getRandomValues(array)
return Buffer.from(array)
}
import * as secp256k1 from 'noble-secp256k1'
export const makeRandom32 = () => secp256k1.utils.generateRandomPrivateKey()
export const sha256 = m => secp256k1.utils.sha256(Uint8Array.from(m))
export const getPublicKey = privateKey =>
secp256k1.schnorr.getPublicKey(privateKey)

1357
yarn.lock Normal file

File diff suppressed because it is too large Load Diff