Compare commits

..

11 Commits

Author SHA1 Message Date
fiatjaf
87c0f0d061 tag v1.0.0 2022-12-23 20:51:36 -03:00
fiatjaf
83c397b839 do event signature and verification synchronously. 2022-12-23 17:32:13 -03:00
fiatjaf
cd7d1cec48 implement nip26 delegation. 2022-12-23 17:30:35 -03:00
adamritter
613a843838 Add Kind enum for easier client development (#61) 2022-12-23 16:38:59 -03:00
fiatjaf
74a0d5454a guard against some nonexisting arrays of event listeners. 2022-12-23 15:18:23 -03:00
fiatjaf
c0d1e41424 always recompute the hash when signing.
fixes https://github.com/fiatjaf/nostr-tools/issues/59
2022-12-23 15:06:21 -03:00
fiatjaf
f7e510e1c8 nip05 regex name check. 2022-12-23 15:04:24 -03:00
fiatjaf
c08bdac7a7 catch usage of global fetch for nodejs.
fixes https://github.com/fiatjaf/nostr-tools/issues/53
2022-12-23 11:36:37 -03:00
rkfg
c5b64404f6 Add limit to filter 2022-12-23 11:29:38 -03:00
adamritter
c7b26fdba2 Don't expose external API to hex representation of mnemoic 2022-12-23 11:01:10 -03:00
fiatjaf
ac698ef67d make relay.connect() an awaitable thing. 2022-12-22 08:53:40 -03:00
10 changed files with 345 additions and 78 deletions

View File

@@ -35,10 +35,10 @@ let event = {
event.id = getEventHash(event)
event.pubkey = getPublicKey(privateKey)
event.sig = await signEvent(event, privateKey)
event.sig = signEvent(event, privateKey)
let ok = validateEvent(event)
let veryOk = await verifySignature(event)
let veryOk = verifySignature(event)
```
### Interacting with a relay
@@ -53,7 +53,7 @@ import {
} from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
relay.connect()
await relay.connect()
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
@@ -98,7 +98,7 @@ let event = {
content: 'hello world'
}
event.id = getEventHash(event)
event.sig = await signEvent(event, sk)
event.sig = signEvent(event, sk)
let pub = relay.publish(event)
pub.on('ok', () => {
@@ -202,6 +202,41 @@ sub.on('event', (event) => {
})
```
### Performing and checking for delegation
```js
import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools'
// delegator
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// delegatee
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// generate delegation
let delegation = nip26.createDelegation(sk1, {
pubkey: pk2,
kind: 1,
since: Math.round(Date.now() / 1000),
until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */
})
// the delegatee uses the delegation when building an event
let event = {
pubkey: pk2,
kind: 1,
created_at: Math.round(Date.now() / 1000),
content: 'hello from a delegated key',
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]]
}
// finally any receiver of this event can check for the presence of a valid delegation tag
let delegator = nip26.getDelegator(event)
assert(delegator === pk1) // will be null if there is no delegation tag or if it is invalid
```
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
### Using from the browser (if you don't want to use a bundler)

View File

@@ -3,10 +3,27 @@ import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils'
/* eslint-disable no-unused-vars */
export enum Kind {
Metadata = 0,
Text = 1,
RecommendRelay = 2,
Contacts = 3,
EncryptedDirectMessage = 4,
EventDeletion = 5,
Reaction = 7,
ChannelCreation = 40,
ChannelMetadata = 41,
ChannelMessage = 42,
ChannelHideMessage = 43,
ChannelMuteUser = 44,
}
export type Event = {
id?: string
sig?: string
kind: number
kind: Kind
tags: string[][]
pubkey: string
content: string
@@ -58,12 +75,12 @@ export function validateEvent(event: Event): boolean {
export function verifySignature(
event: Event & {id: string; sig: string}
): Promise<boolean> {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
): boolean {
return secp256k1.schnorr.verifySync(event.sig, event.id, event.pubkey)
}
export async function signEvent(event: Event, key: string): Promise<string> {
export function signEvent(event: Event, key: string): string {
return secp256k1.utils.bytesToHex(
await secp256k1.schnorr.sign(event.id || getEventHash(event), key)
secp256k1.schnorr.signSync(getEventHash(event), key)
)
}

View File

@@ -6,6 +6,7 @@ export type Filter = {
authors?: string[]
since?: number
until?: number
limit?: number
[key: `#${string}`]: string[]
}

View File

@@ -7,3 +7,13 @@ export * as nip04 from './nip04'
export * as nip05 from './nip05'
export * as nip06 from './nip06'
export * as nip19 from './nip19'
export * as nip26 from './nip26'
// monkey patch secp256k1
import * as secp256k1 from '@noble/secp256k1'
import {hmac} from '@noble/hashes/hmac'
import {sha256} from '@noble/hashes/sha256'
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
secp256k1.utils.sha256Sync = (...msgs) =>
sha256(secp256k1.utils.concatBytes(...msgs))

View File

@@ -1,6 +1,10 @@
import {ProfilePointer} from './nip19'
var _fetch = fetch
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
@@ -32,6 +36,8 @@ export async function queryProfile(
name = '_'
}
if (!name.match(/^[a-z0-9-_]+$/)) return null
let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()

View File

@@ -7,17 +7,13 @@ import {
} from '@scure/bip39'
import {HDKey} from '@scure/bip32'
export function privateKeyFromSeed(seed: string): string {
let root = HDKey.fromMasterSeed(secp256k1.utils.hexToBytes(seed))
export function privateKeyFromSeedWords(mnemonic: string): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic))
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key')
return secp256k1.utils.bytesToHex(privateKey)
}
export function seedFromWords(mnemonic: string): string {
return secp256k1.utils.bytesToHex(mnemonicToSeedSync(mnemonic))
}
export function generateSeedWords(): string {
return generateMnemonic(wordlist)
}

105
nip26.test.js Normal file
View File

@@ -0,0 +1,105 @@
/* eslint-env jest */
const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('parse good delegation from NIP', async () => {
expect(
nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
})
test('parse bad delegations', async () => {
expect(
nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
expect(
nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1740995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
expect(
nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
})
test('create and verify delegation', async () => {
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
let delegation = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1})
expect(delegation).toHaveProperty('from', pk1)
expect(delegation).toHaveProperty('to', pk2)
expect(delegation).toHaveProperty('cond', 'kind=1')
let event = {
kind: 1,
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
pubkey: pk2
}
expect(nip26.getDelegator(event)).toEqual(pk1)
})

90
nip26.ts Normal file
View File

@@ -0,0 +1,90 @@
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {Event} from './event'
import {utf8Encoder} from './utils'
import {getPublicKey} from './keys'
export type Parameters = {
pubkey: string // the key to whom the delegation will be given
kind: number | undefined
until: number | undefined // delegation will only be valid until this date
since: number | undefined // delegation will be valid from this date on
}
export type Delegation = {
from: string // the pubkey who signed the delegation
to: string // the pubkey that is allowed to use the delegation
cond: string // the string of conditions as they should be included in the event tag
sig: string
}
export function createDelegation(
privateKey: string,
parameters: Parameters
): Delegation {
let conditions = []
if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`)
if (parameters.until) conditions.push(`created_at<${parameters.until}`)
if (parameters.since) conditions.push(`created_at>${parameters.since}`)
let cond = conditions.join('&')
if (cond === '')
throw new Error('refusing to create a delegation without any conditions')
let sighash = sha256(
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
)
let sig = secp256k1.utils.bytesToHex(
secp256k1.schnorr.signSync(sighash, privateKey)
)
return {
from: getPublicKey(privateKey),
to: parameters.pubkey,
cond,
sig
}
}
export function getDelegator(event: Event): string | null {
// find delegation tag
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
if (!tag) return null
let pubkey = tag[1]
let cond = tag[2]
let sig = tag[3]
// check conditions
let conditions = cond.split('&')
for (let i = 0; i < conditions.length; i++) {
let [key, operator, value] = conditions[i].split(/\b/)
// the supported conditions are just 'kind' and 'created_at' for now
if (key === 'kind' && operator === '=' && event.kind === parseInt(value))
continue
else if (
key === 'created_at' &&
operator === '<' &&
event.created_at < parseInt(value)
)
continue
else if (
key === 'created_at' &&
operator === '>' &&
event.created_at > parseInt(value)
)
continue
else return null // invalid condition
}
// check signature
let sighash = sha256(
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
)
if (!secp256k1.schnorr.verifySync(sig, sighash, pubkey)) return null
return pubkey
}

View File

@@ -1,6 +1,6 @@
{
"name": "nostr-tools",
"version": "1.0.0-beta2",
"version": "1.0.0",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",

129
relay.ts
View File

@@ -59,73 +59,77 @@ export function relayInit(url: string): Relay {
}
} = {}
function connectRelay() {
ws = new WebSocket(url)
async function connectRelay(): Promise<void> {
return new Promise((resolve, reject) => {
ws = new WebSocket(url)
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
}
ws.onerror = () => {
listeners.error.forEach(cb => cb())
}
ws.onclose = async () => {
listeners.disconnect.forEach(cb => cb())
resolveClose()
}
ws.onmessage = async e => {
var data
try {
data = JSON.parse(e.data)
} catch (err) {
data = e.data
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
resolve()
}
ws.onerror = () => {
listeners.error.forEach(cb => cb())
reject()
}
ws.onclose = async () => {
listeners.disconnect.forEach(cb => cb())
resolveClose()
}
if (data.length >= 1) {
switch (data[0]) {
case 'EVENT':
if (data.length !== 3) return // ignore empty or malformed EVENT
ws.onmessage = async e => {
var data
try {
data = JSON.parse(e.data)
} catch (err) {
data = e.data
}
let id = data[1]
let event = data[2]
if (
validateEvent(event) &&
openSubs[id] &&
(openSubs[id].skipVerification || verifySignature(event)) &&
matchFilters(openSubs[id].filters, event)
) {
openSubs[id]
subListeners[id]?.event.forEach(cb => cb(event))
if (data.length >= 1) {
switch (data[0]) {
case 'EVENT':
if (data.length !== 3) return // ignore empty or malformed EVENT
let id = data[1]
let event = data[2]
if (
validateEvent(event) &&
openSubs[id] &&
(openSubs[id].skipVerification || verifySignature(event)) &&
matchFilters(openSubs[id].filters, event)
) {
openSubs[id]
;(subListeners[id]?.event || []).forEach(cb => cb(event))
}
return
case 'EOSE': {
if (data.length !== 2) return // ignore empty or malformed EOSE
let id = data[1]
;(subListeners[id]?.eose || []).forEach(cb => cb())
return
}
return
case 'EOSE': {
if (data.length !== 2) return // ignore empty or malformed EOSE
let id = data[1]
subListeners[id]?.eose.forEach(cb => cb())
return
case 'OK': {
if (data.length < 3) return // ignore empty or malformed OK
let id: string = data[1]
let ok: boolean = data[2]
let reason: string = data[3] || ''
if (ok) pubListeners[id]?.ok.forEach(cb => cb())
else pubListeners[id]?.failed.forEach(cb => cb(reason))
return
}
case 'NOTICE':
if (data.length !== 2) return // ignore empty or malformed NOTICE
let notice = data[1]
listeners.notice.forEach(cb => cb(notice))
return
}
case 'OK': {
if (data.length < 3) return // ignore empty or malformed OK
let id: string = data[1]
let ok: boolean = data[2]
let reason: string = data[3] || ''
if (ok) pubListeners[id]?.ok.forEach(cb => cb())
else pubListeners[id]?.failed.forEach(cb => cb(reason))
return
}
case 'NOTICE':
if (data.length !== 2) return // ignore empty or malformed NOTICE
let notice = data[1]
listeners.notice.forEach(cb => cb(notice))
return
}
}
}
})
}
async function connect(): Promise<void> {
if (ws?.readyState && ws.readyState === 1) return // ws already open
connectRelay()
await connectRelay()
}
async function trySend(params: [string, ...any]) {
@@ -170,8 +174,9 @@ export function relayInit(url: string): Relay {
subListeners[subid][type].push(cb)
},
off: (type: 'event' | 'eose', cb: any): void => {
let idx = subListeners[subid][type].indexOf(cb)
if (idx >= 0) subListeners[subid][type].splice(idx, 1)
let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
@@ -217,14 +222,14 @@ export function relayInit(url: string): Relay {
id: `monitor-${id.slice(0, 5)}`
})
let willUnsub = setTimeout(() => {
pubListeners[id].failed.forEach(cb =>
;(pubListeners[id]?.failed || []).forEach(cb =>
cb('event not seen after 5 seconds')
)
monitor.unsub()
}, 5000)
monitor.on('event', () => {
clearTimeout(willUnsub)
pubListeners[id].seen.forEach(cb => cb())
;(pubListeners[id]?.seen || []).forEach(cb => cb())
})
}
@@ -243,8 +248,10 @@ export function relayInit(url: string): Relay {
}
},
off: (type: 'ok' | 'seen' | 'failed', cb: any) => {
let idx = pubListeners[id][type].indexOf(cb)
if (idx >= 0) pubListeners[id][type].splice(idx, 1)
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
},