Compare commits

...

33 Commits

Author SHA1 Message Date
fiatjaf
39ea47660d use a different relay for tests. 2022-12-25 16:01:31 -03:00
Tristan
8071e2f4fa Make opts arg optional for sub method
In the README and the code, it looks like the second argument for the relay's `sub` method is optional:

```typescript
let sub = relay.sub([
  {
    ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
  }
])
```

In the type definitions it's required however, which leads to an error in editors. Let's mark it as optional in the type definitions too! 👍
2022-12-25 14:32:20 -03:00
Tristan
cc2250da1f Add missing "error" event to on and off type definitions 2022-12-25 14:31:38 -03:00
rkfg
c37d10bb9d Fix resolveClose 2022-12-24 20:41:49 -03:00
rkfg
97e28fdf9a Fix connect/close return types and race condition 2022-12-24 18:49:16 -03:00
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
fiatjaf
8262a81cb2 make crypto available as a global on nip04 test. 2022-12-21 17:12:50 -03:00
fiatjaf
26e6da6ba3 we need websocket polyfill on relay tests. 2022-12-21 17:09:00 -03:00
fiatjaf
8aa31bb437 remove websocket-polyfill, instruct nodejs users to install it manually. 2022-12-21 16:23:47 -03:00
fiatjaf
4bd4469357 remove useless readable-stream dependency. 2022-12-21 16:19:59 -03:00
fiatjaf
89ae21f796 remove buffer usage everywhere. 2022-12-21 16:04:09 -03:00
fiatjaf
41a1614d89 remove browserify-cipher, use crypto.subtle for nip04. 2022-12-21 16:04:00 -03:00
fiatjaf
0500415a4e remove all the auto-reconnection code from relay. 2022-12-21 15:31:57 -03:00
fiatjaf
cee4357cab Merge pull request #50 from mmalmi/patch-1 2022-12-21 08:50:29 -03:00
Sandwich
d5cf5930d1 Fix example code in readme, resolves #47 2022-12-21 08:44:52 -03:00
Martti Malmi
a78e2036aa status code 3 (closed) for un-opened connection 2022-12-21 11:15:36 +02:00
Martti Malmi
adc1854ac6 relay.status() returns 0 when ws not created 2022-12-21 11:08:10 +02:00
fiatjaf
83148e8bdf fix small things in README. 2022-12-20 22:34:19 -03:00
fiatjaf
364c37cac5 fix autopublishing to npm. 2022-12-20 20:15:43 -03:00
fiatjaf
385cdb4ac6 README examples for nip05 and nip19. 2022-12-20 18:42:24 -03:00
fiatjaf
3f1025f551 nip05.queryProfile() and test. 2022-12-20 18:36:49 -03:00
fiatjaf
482c5affd4 add nip19. 2022-12-20 18:26:30 -03:00
fiatjaf
679ac0c133 fix standalone script URL. 2022-12-20 17:01:35 -03:00
21 changed files with 809 additions and 317 deletions

View File

@@ -24,6 +24,7 @@
"document": false, "document": false,
"navigator": false, "navigator": false,
"window": false, "window": false,
"crypto": false,
"location": false, "location": false,
"URL": false, "URL": false,
"URLSearchParams": false, "URLSearchParams": false,

View File

@@ -14,6 +14,8 @@ jobs:
node-version: 18 node-version: 18
- run: yarn --ignore-engines - run: yarn --ignore-engines
- run: node build.js - run: node build.js
- run: npm publish - run: yarn test
env: - uses: JS-DevTools/npm-publish@v1
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} with:
token: ${{ secrets.NPM_TOKEN }}
greater-version-only: true

111
README.md
View File

@@ -2,6 +2,8 @@
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients. Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Very lean on dependencies.
## Usage ## Usage
### Generating a private key and a public key ### Generating a private key and a public key
@@ -9,8 +11,8 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
```js ```js
import {generatePrivateKey, getPublicKey} from 'nostr-tools' import {generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey() # `sk` is a hex string let sk = generatePrivateKey() // `sk` is a hex string
let pk = getPublicKey(sk) # `pk` is a hex string let pk = getPublicKey(sk) // `pk` is a hex string
``` ```
### Creating, signing and verifying events ### Creating, signing and verifying events
@@ -31,12 +33,12 @@ let event = {
content: 'hello' content: 'hello'
} }
event.id = getEventHash(event.id) event.id = getEventHash(event)
event.pubkey = getPublicKey(privateKey) event.pubkey = getPublicKey(privateKey)
event.sig = await signEvent(event, privateKey) event.sig = signEvent(event, privateKey)
let ok = validateEvent(event) let ok = validateEvent(event)
let veryOk = await verifySignature(event) let veryOk = verifySignature(event)
``` ```
### Interacting with a relay ### Interacting with a relay
@@ -51,7 +53,7 @@ import {
} from 'nostr-tools' } from 'nostr-tools'
const relay = relayInit('wss://relay.example.com') const relay = relayInit('wss://relay.example.com')
relay.connect() await relay.connect()
relay.on('connect', () => { relay.on('connect', () => {
console.log(`connected to ${relay.url}`) console.log(`connected to ${relay.url}`)
@@ -96,7 +98,7 @@ let event = {
content: 'hello world' content: 'hello world'
} }
event.id = getEventHash(event) event.id = getEventHash(event)
event.sig = await signEvent(event, sk) event.sig = signEvent(event, sk)
let pub = relay.publish(event) let pub = relay.publish(event)
pub.on('ok', () => { pub.on('ok', () => {
@@ -112,6 +114,59 @@ pub.on('failed', reason => {
await relay.close() await relay.close()
``` ```
To use this on Node.js you first must install `websocket-polyfill` and import it:
```js
import 'websocket-polyfill'
```
### Querying profile data from a NIP-05 address
```js
import {nip05} from 'nostr-tools'
let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey)
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
console.log(profile.relays)
// prints: [wss://relay.damus.io]
```
To use this on Node.js you first must install `node-fetch@2` and call something like this:
```js
nip05.useFetchImplementation(require('node-fetch'))
```
### Encoding and decoding NIP-19 codes
```js
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
let {type, data} = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
let {type, data} = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
let {type, data} = nip19.decode(nprofile)
assert(type === 'nprofile')
assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
### Encrypting and decrypting direct messages ### Encrypting and decrypting direct messages
```js ```js
@@ -127,7 +182,7 @@ let pk2 = getPublicKey(sk2)
// on the sender side // on the sender side
let message = 'hello' let message = 'hello'
let ciphertext = nip04.encrypt(sk1, pk2, 'hello') let ciphertext = await nip04.encrypt(sk1, pk2, 'hello')
let event = { let event = {
kind: 4, kind: 4,
@@ -140,20 +195,54 @@ let event = {
sendEvent(event) sendEvent(event)
// on the receiver side // on the receiver side
sub.on('event', (event) => { sub.on('event', (event) => {
let sender = event.tags.find(([k, v]) => k === 'p' && && v && v !== '')[1] let sender = event.tags.find(([k, v]) => k === 'p' && && v && v !== '')[1]
pk1 === sender pk1 === sender
let plaintext = nip04.decrypt(sk2, pk1, event.content) let plaintext = await nip04.decrypt(sk2, pk1, event.content)
}) })
``` ```
### 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. 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) ### Using from the browser (if you don't want to use a bundler)
```html ```html
<script src="https://unpkg.com/nostr-tools/nostr.bundle.js"></script> <script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script> <script>
window.NostrTools.generatePrivateKey('...') // and so on window.NostrTools.generatePrivateKey('...') // and so on
</script> </script>

View File

@@ -1,16 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
const esbuild = require('esbuild') const esbuild = require('esbuild')
const alias = require('esbuild-plugin-alias')
let common = { let common = {
entryPoints: ['index.ts'], entryPoints: ['index.ts'],
bundle: true, bundle: true,
plugins: [
alias({
stream: require.resolve('readable-stream')
})
],
sourcemap: 'external' sourcemap: 'external'
} }

View File

@@ -1,12 +1,29 @@
import {Buffer} from 'buffer'
// @ts-ignore
import * as secp256k1 from '@noble/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256' 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 = { export type Event = {
id?: string id?: string
sig?: string sig?: string
kind: number kind: Kind
tags: string[][] tags: string[][]
pubkey: string pubkey: string
content: string content: string
@@ -35,8 +52,8 @@ export function serializeEvent(evt: Event): string {
} }
export function getEventHash(event: Event): string { export function getEventHash(event: Event): string {
let eventHash = sha256(Buffer.from(serializeEvent(event))) let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return Buffer.from(eventHash).toString('hex') return secp256k1.utils.bytesToHex(eventHash)
} }
export function validateEvent(event: Event): boolean { export function validateEvent(event: Event): boolean {
@@ -58,12 +75,12 @@ export function validateEvent(event: Event): boolean {
export function verifySignature( export function verifySignature(
event: Event & {id: string; sig: string} event: Event & {id: string; sig: string}
): Promise<boolean> { ): boolean {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey) 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 Buffer.from( return secp256k1.utils.bytesToHex(
await secp256k1.schnorr.sign(event.id || getEventHash(event), key) secp256k1.schnorr.signSync(getEventHash(event), key)
).toString('hex') )
} }

View File

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

View File

@@ -6,3 +6,14 @@ export * from './filter'
export * as nip04 from './nip04' export * as nip04 from './nip04'
export * as nip05 from './nip05' export * as nip05 from './nip05'
export * as nip06 from './nip06' 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,10 +1,9 @@
import * as secp256k1 from '@noble/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {Buffer} from 'buffer'
export function generatePrivateKey(): string { export function generatePrivateKey(): string {
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex') return secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
} }
export function getPublicKey(privateKey: string): string { export function getPublicKey(privateKey: string): string {
return Buffer.from(secp256k1.schnorr.getPublicKey(privateKey)).toString('hex') return secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKey))
} }

View File

@@ -1,14 +1,15 @@
/* eslint-env jest */ /* eslint-env jest */
globalThis.crypto = require('crypto')
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs') const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('encrypt and decrypt message', () => { test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey() let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey() let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1) let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2) let pk2 = getPublicKey(sk2)
expect(nip04.decrypt(sk2, pk1, nip04.encrypt(sk1, pk2, 'hello'))).toEqual( expect(
'hello' await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
) ).toEqual('hello')
}) })

View File

@@ -1,45 +1,66 @@
import {Buffer} from 'buffer'
import {randomBytes} from '@noble/hashes/utils' import {randomBytes} from '@noble/hashes/utils'
import * as secp256k1 from '@noble/secp256k1' import * as secp256k1 from '@noble/secp256k1'
// @ts-ignore import {encode as b64encode, decode as b64decode} from 'base64-arraybuffer'
import aes from 'browserify-cipher'
export function encrypt(privkey: string, pubkey: string, text: string): string { import {utf8Decoder, utf8Encoder} from './utils'
export async function encrypt(
privkey: string,
pubkey: string,
text: string
): Promise<string> {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey) const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key) const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16)) let iv = Uint8Array.from(randomBytes(16))
var cipher = aes.createCipheriv( let plaintext = utf8Encoder.encode(text)
'aes-256-cbc', let cryptoKey = await crypto.subtle.importKey(
Buffer.from(normalizedKey, 'hex'), 'raw',
iv normalizedKey,
{name: 'AES-CBC'},
false,
['encrypt']
) )
let encryptedMessage = cipher.update(text, 'utf8', 'base64') let ciphertext = await crypto.subtle.encrypt(
encryptedMessage += cipher.final('base64') {name: 'AES-CBC', iv},
cryptoKey,
plaintext
)
let ctb64 = b64encode(ciphertext)
let ivb64 = b64encode(iv.buffer)
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}` return `${ctb64}?iv=${ivb64}`
} }
export function decrypt( export async function decrypt(
privkey: string, privkey: string,
pubkey: string, pubkey: string,
ciphertext: string data: string
): string { ): Promise<string> {
let [cip, iv] = ciphertext.split('?iv=') let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey) let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key) let normalizedKey = getNormalizedX(key)
var decipher = aes.createDecipheriv( let cryptoKey = await crypto.subtle.importKey(
'aes-256-cbc', 'raw',
Buffer.from(normalizedKey, 'hex'), normalizedKey,
Buffer.from(iv, 'base64') {name: 'AES-CBC'},
false,
['decrypt']
) )
let decryptedMessage = decipher.update(cip, 'base64', 'utf8') let ciphertext = b64decode(ctb64)
decryptedMessage += decipher.final('utf8') let iv = b64decode(ivb64)
return decryptedMessage let plaintext = await crypto.subtle.decrypt(
{name: 'AES-CBC', iv},
cryptoKey,
ciphertext
)
let text = utf8Decoder.decode(plaintext)
return text
} }
function getNormalizedX(key: Uint8Array): string { function getNormalizedX(key: Uint8Array): Uint8Array {
return Buffer.from(key.slice(1, 33)).toString('hex') return key.slice(1, 33)
} }

20
nip05.test.js Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-env jest */
const fetch = require('node-fetch')
const {nip05} = require('./lib/nostr.cjs')
test('fetch nip05 profiles', async () => {
nip05.useFetchImplementation(fetch)
let p1 = await nip05.queryProfile('jb55.com')
expect(p1.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1.relays).toEqual(['wss://relay.damus.io'])
let p2 = await nip05.queryProfile('jb55@jb55.com')
expect(p2.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2.relays).toEqual(['wss://relay.damus.io'])
})

View File

@@ -1,4 +1,10 @@
var _fetch = fetch import {ProfilePointer} from './nip19'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) { export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation _fetch = fetchImplementation
@@ -19,13 +25,30 @@ export async function searchDomain(
} }
} }
export async function queryName(fullname: string): Promise<string> { export async function queryProfile(
fullname: string
): Promise<ProfilePointer | null> {
let [name, domain] = fullname.split('@') let [name, domain] = fullname.split('@')
if (!domain) throw new Error('invalid identifier, must contain an @')
if (!domain) {
// if there is no @, it is because it is just a domain, so assume the name is "_"
domain = name
name = '_'
}
if (!name.match(/^[a-z0-9-_]+$/)) return null
let res = await ( let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`) await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json() ).json()
return res.names && res.names[name] if (!res?.names?.[name]) return null
let pubkey = res.names[name] as string
let relays = (res.relays?.[pubkey] || []) as string[] // nip35
return {
pubkey,
relays
}
} }

View File

@@ -1,3 +1,4 @@
import * as secp256k1 from '@noble/secp256k1'
import {wordlist} from '@scure/bip39/wordlists/english.js' import {wordlist} from '@scure/bip39/wordlists/english.js'
import { import {
generateMnemonic, generateMnemonic,
@@ -6,15 +7,11 @@ import {
} from '@scure/bip39' } from '@scure/bip39'
import {HDKey} from '@scure/bip32' import {HDKey} from '@scure/bip32'
export function privateKeyFromSeed(seed: string): string { export function privateKeyFromSeedWords(mnemonic: string): string {
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex')) let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic))
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key') if (!privateKey) throw new Error('could not derive private key')
return Buffer.from(privateKey).toString('hex') return secp256k1.utils.bytesToHex(privateKey)
}
export function seedFromWords(mnemonic: string): string {
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
} }
export function generateSeedWords(): string { export function generateSeedWords(): string {

36
nip19.test.js Normal file
View File

@@ -0,0 +1,36 @@
/* eslint-env jest */
const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('encode and decode nsec', () => {
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let {type, data} = nip19.decode(nsec)
expect(type).toEqual('nsec')
expect(data).toEqual(sk)
})
test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let {type, data} = nip19.decode(npub)
expect(type).toEqual('npub')
expect(data).toEqual(pk)
})
test('encode and decode nprofile', () => {
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
expect(nprofile).toMatch(/nprofile1\w+/)
let {type, data} = nip19.decode(nprofile)
expect(type).toEqual('nprofile')
expect(data.pubkey).toEqual(pk)
expect(data.relays).toContain(relays[0])
expect(data.relays).toContain(relays[1])
})

125
nip19.ts Normal file
View File

@@ -0,0 +1,125 @@
import * as secp256k1 from '@noble/secp256k1'
import {bech32} from 'bech32'
import {utf8Decoder, utf8Encoder} from './utils'
export type ProfilePointer = {
pubkey: string // hex
relays?: string[]
}
export type EventPointer = {
id: string // hex
relays?: string[]
}
export function decode(nip19: string): {
type: string
data: ProfilePointer | EventPointer | string
} {
let {prefix, words} = bech32.decode(nip19, 1000)
let data = new Uint8Array(bech32.fromWords(words))
if (prefix === 'nprofile') {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nprofile',
data: {
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1].map(d => utf8Decoder.decode(d))
}
}
}
if (prefix === 'nevent') {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nevent',
data: {
id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1].map(d => utf8Decoder.decode(d))
}
}
}
if (prefix === 'nsec' || prefix === 'npub' || prefix === 'note') {
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
}
throw new Error(`unknown prefix ${prefix}`)
}
type TLV = {[t: number]: Uint8Array[]}
function parseTLV(data: Uint8Array): TLV {
let result: TLV = {}
let rest = data
while (rest.length > 0) {
let t = rest[0]
let l = rest[1]
let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l)
if (v.length < l) continue
result[t] = result[t] || []
result[t].push(v)
}
return result
}
export function nsecEncode(hex: string): string {
return encodeBytes('nsec', hex)
}
export function npubEncode(hex: string): string {
return encodeBytes('npub', hex)
}
export function noteEncode(hex: string): string {
return encodeBytes('note', hex)
}
function encodeBytes(prefix: string, hex: string): string {
let data = secp256k1.utils.hexToBytes(hex)
let words = bech32.toWords(data)
return bech32.encode(prefix, words, 1000)
}
export function nprofileEncode(profile: ProfilePointer): string {
let data = encodeTLV({
0: [secp256k1.utils.hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
})
let words = bech32.toWords(data)
return bech32.encode('nprofile', words, 1000)
}
export function neventEncode(event: EventPointer): string {
let data = encodeTLV({
0: [secp256k1.utils.hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url))
})
let words = bech32.toWords(data)
return bech32.encode('nevent', words, 1000)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv).forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
})
})
return secp256k1.utils.concatBytes(...entries)
}

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", "name": "nostr-tools",
"version": "1.0.0-alpha2", "version": "1.0.1",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -13,9 +13,8 @@
"@noble/secp256k1": "^1.7.0", "@noble/secp256k1": "^1.7.0",
"@scure/bip32": "^1.1.1", "@scure/bip32": "^1.1.1",
"@scure/bip39": "^1.1.0", "@scure/bip39": "^1.1.0",
"browserify-cipher": ">=1", "base64-arraybuffer": "^1.0.2",
"buffer": "^6.0.3", "bech32": "^2.0.0"
"websocket-polyfill": "^0.0.3"
}, },
"keywords": [ "keywords": [
"decentralization", "decentralization",
@@ -35,9 +34,11 @@
"esm-loader-typescript": "^1.0.1", "esm-loader-typescript": "^1.0.1",
"events": "^3.3.0", "events": "^3.3.0",
"jest": "^29.3.1", "jest": "^29.3.1",
"node-fetch": "2",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"tsd": "^0.22.0", "tsd": "^0.22.0",
"typescript": "^4.9.4" "typescript": "^4.9.4",
"websocket-polyfill": "^0.0.3"
}, },
"scripts": { "scripts": {
"build": "node build.js", "build": "node build.js",

View File

@@ -1,5 +1,6 @@
/* eslint-env jest */ /* eslint-env jest */
require('websocket-polyfill')
const { const {
relayInit, relayInit,
generatePrivateKey, generatePrivateKey,
@@ -8,8 +9,7 @@ const {
signEvent signEvent
} = require('./lib/nostr.cjs') } = require('./lib/nostr.cjs')
describe('relay interaction', () => { let relay = relayInit('wss://nostr-dev.wellorder.net/')
let relay = relayInit('wss://nostr-pub.semisol.dev/')
beforeAll(() => { beforeAll(() => {
relay.connect() relay.connect()
@@ -38,9 +38,7 @@ describe('relay interaction', () => {
let sub = relay.sub([ let sub = relay.sub([
{ {
ids: [ ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
]
} }
]) ])
sub.on('event', event => { sub.on('event', event => {
@@ -114,4 +112,3 @@ describe('relay interaction', () => {
]) ])
).resolves.toEqual([true, true]) ).resolves.toEqual([true, true])
}) })
})

112
relay.ts
View File

@@ -1,19 +1,19 @@
/* global WebSocket */ /* global WebSocket */
import 'websocket-polyfill'
import {Event, verifySignature, validateEvent} from './event' import {Event, verifySignature, validateEvent} from './event'
import {Filter, matchFilters} from './filter' import {Filter, matchFilters} from './filter'
type RelayEvent = 'connect' | 'disconnect' | 'error' | 'notice'
export type Relay = { export type Relay = {
url: string url: string
status: number status: number
connect: () => void connect: () => Promise<void>
close: () => void close: () => Promise<void>
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
publish: (event: Event) => Pub publish: (event: Event) => Pub
on: (type: 'connect' | 'disconnect' | 'notice', cb: any) => void on: (type: RelayEvent, cb: any) => void
off: (type: 'connect' | 'disconnect' | 'notice', cb: any) => void off: (type: RelayEvent, cb: any) => void
} }
export type Pub = { export type Pub = {
on: (type: 'ok' | 'seen' | 'failed', cb: any) => void on: (type: 'ok' | 'seen' | 'failed', cb: any) => void
@@ -33,11 +33,8 @@ type SubscriptionOptions = {
export function relayInit(url: string): Relay { export function relayInit(url: string): Relay {
var ws: WebSocket var ws: WebSocket
var resolveOpen: () => void
var resolveClose: () => void var resolveClose: () => void
var untilOpen: Promise<void> var untilOpen: Promise<void>
var wasClosed: boolean
var closed: boolean
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {} var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners: { var listeners: {
connect: Array<() => void> connect: Array<() => void>
@@ -63,64 +60,22 @@ export function relayInit(url: string): Relay {
failed: Array<(reason: string) => void> failed: Array<(reason: string) => void>
} }
} = {} } = {}
let attemptNumber = 1
let nextAttemptSeconds = 1
let isConnected = false
function resetOpenState() { async function connectRelay(): Promise<void> {
untilOpen = new Promise(resolve => { return new Promise((resolve, reject) => {
resolveOpen = resolve
})
}
function connectRelay() {
ws = new WebSocket(url) ws = new WebSocket(url)
ws.onopen = () => { ws.onopen = () => {
listeners.connect.forEach(cb => cb()) listeners.connect.forEach(cb => cb())
resolveOpen() resolve()
isConnected = true
// restablish old subscriptions
if (wasClosed) {
wasClosed = false
for (let id in openSubs) {
let {filters} = openSubs[id]
sub(filters, openSubs[id])
}
}
} }
ws.onerror = () => { ws.onerror = () => {
isConnected = false
listeners.error.forEach(cb => cb()) listeners.error.forEach(cb => cb())
reject()
} }
ws.onclose = async () => { ws.onclose = async () => {
isConnected = false
listeners.disconnect.forEach(cb => cb()) listeners.disconnect.forEach(cb => cb())
resolveClose && resolveClose()
if (closed) {
// we've closed this because we wanted, so end everything
resolveClose()
return
}
// otherwise keep trying to reconnect
resetOpenState()
attemptNumber++
nextAttemptSeconds += attemptNumber ** 3
if (nextAttemptSeconds > 14400) {
nextAttemptSeconds = 14400 // 4 hours
}
console.log(
`relay ${url} connection closed. reconnecting in ${nextAttemptSeconds} seconds.`
)
setTimeout(async () => {
try {
connectRelay()
} catch (err) {}
}, nextAttemptSeconds * 1000)
wasClosed = true
} }
ws.onmessage = async e => { ws.onmessage = async e => {
@@ -145,13 +100,13 @@ export function relayInit(url: string): Relay {
matchFilters(openSubs[id].filters, event) matchFilters(openSubs[id].filters, event)
) { ) {
openSubs[id] openSubs[id]
subListeners[id]?.event.forEach(cb => cb(event)) ;(subListeners[id]?.event || []).forEach(cb => cb(event))
} }
return return
case 'EOSE': { case 'EOSE': {
if (data.length !== 2) return // ignore empty or malformed EOSE if (data.length !== 2) return // ignore empty or malformed EOSE
let id = data[1] let id = data[1]
subListeners[id]?.eose.forEach(cb => cb()) ;(subListeners[id]?.eose || []).forEach(cb => cb())
return return
} }
case 'OK': { case 'OK': {
@@ -171,15 +126,12 @@ export function relayInit(url: string): Relay {
} }
} }
} }
})
} }
resetOpenState()
async function connect(): Promise<void> { async function connect(): Promise<void> {
if (ws?.readyState && ws.readyState === 1) return // ws already open if (ws?.readyState && ws.readyState === 1) return // ws already open
try { await connectRelay()
connectRelay()
} catch (err) {}
} }
async function trySend(params: [string, ...any]) { async function trySend(params: [string, ...any]) {
@@ -224,8 +176,9 @@ export function relayInit(url: string): Relay {
subListeners[subid][type].push(cb) subListeners[subid][type].push(cb)
}, },
off: (type: 'event' | 'eose', cb: any): void => { off: (type: 'event' | 'eose', cb: any): void => {
let idx = subListeners[subid][type].indexOf(cb) let listeners = subListeners[subid]
if (idx >= 0) subListeners[subid][type].splice(idx, 1) let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
} }
} }
} }
@@ -233,13 +186,19 @@ export function relayInit(url: string): Relay {
return { return {
url, url,
sub, sub,
on: (type: 'connect' | 'disconnect' | 'notice', cb: any): void => { on: (
type: RelayEvent,
cb: any
): void => {
listeners[type].push(cb) listeners[type].push(cb)
if (type === 'connect' && isConnected) { if (type === 'connect' && ws?.readyState === 1) {
cb() cb()
} }
}, },
off: (type: 'connect' | 'disconnect' | 'notice', cb: any): void => { off: (
type: RelayEvent,
cb: any
): void => {
let index = listeners[type].indexOf(cb) let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1) if (index !== -1) listeners[type].splice(index, 1)
}, },
@@ -265,14 +224,14 @@ export function relayInit(url: string): Relay {
id: `monitor-${id.slice(0, 5)}` id: `monitor-${id.slice(0, 5)}`
}) })
let willUnsub = setTimeout(() => { let willUnsub = setTimeout(() => {
pubListeners[id].failed.forEach(cb => ;(pubListeners[id]?.failed || []).forEach(cb =>
cb('event not seen after 5 seconds') cb('event not seen after 5 seconds')
) )
monitor.unsub() monitor.unsub()
}, 5000) }, 5000)
monitor.on('event', () => { monitor.on('event', () => {
clearTimeout(willUnsub) clearTimeout(willUnsub)
pubListeners[id].seen.forEach(cb => cb()) ;(pubListeners[id]?.seen || []).forEach(cb => cb())
}) })
} }
@@ -291,21 +250,22 @@ export function relayInit(url: string): Relay {
} }
}, },
off: (type: 'ok' | 'seen' | 'failed', cb: any) => { off: (type: 'ok' | 'seen' | 'failed', cb: any) => {
let idx = pubListeners[id][type].indexOf(cb) let listeners = pubListeners[id]
if (idx >= 0) pubListeners[id][type].splice(idx, 1) if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
} }
} }
}, },
connect, connect,
close(): Promise<void> { close(): Promise<void> {
closed = true // prevent ws from trying to reconnect
ws.close() ws.close()
return new Promise(resolve => { return new Promise<void>(resolve => {
resolveClose = resolve resolveClose = resolve
}) })
}, },
get status() { get status() {
return ws.readyState return ws?.readyState ?? 3
} }
} }
} }

2
utils.ts Normal file
View File

@@ -0,0 +1,2 @@
export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder()