mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
33 Commits
v1.0.0-alp
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39ea47660d | ||
|
|
8071e2f4fa | ||
|
|
cc2250da1f | ||
|
|
c37d10bb9d | ||
|
|
97e28fdf9a | ||
|
|
87c0f0d061 | ||
|
|
83c397b839 | ||
|
|
cd7d1cec48 | ||
|
|
613a843838 | ||
|
|
74a0d5454a | ||
|
|
c0d1e41424 | ||
|
|
f7e510e1c8 | ||
|
|
c08bdac7a7 | ||
|
|
c5b64404f6 | ||
|
|
c7b26fdba2 | ||
|
|
ac698ef67d | ||
|
|
8262a81cb2 | ||
|
|
26e6da6ba3 | ||
|
|
8aa31bb437 | ||
|
|
4bd4469357 | ||
|
|
89ae21f796 | ||
|
|
41a1614d89 | ||
|
|
0500415a4e | ||
|
|
cee4357cab | ||
|
|
d5cf5930d1 | ||
|
|
a78e2036aa | ||
|
|
adc1854ac6 | ||
|
|
83148e8bdf | ||
|
|
364c37cac5 | ||
|
|
385cdb4ac6 | ||
|
|
3f1025f551 | ||
|
|
482c5affd4 | ||
|
|
679ac0c133 |
@@ -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,
|
||||||
|
|||||||
8
.github/workflows/npm-publish.yml
vendored
8
.github/workflows/npm-publish.yml
vendored
@@ -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
|
||||||
|
|||||||
113
README.md
113
README.md
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
```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>
|
||||||
|
|||||||
6
build.js
6
build.js
@@ -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'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
event.ts
39
event.ts
@@ -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')
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
index.ts
11
index.ts
@@ -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))
|
||||||
|
|||||||
5
keys.ts
5
keys.ts
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
69
nip04.ts
69
nip04.ts
@@ -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
20
nip05.test.js
Normal 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'])
|
||||||
|
})
|
||||||
31
nip05.ts
31
nip05.ts
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
nip06.ts
11
nip06.ts
@@ -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
36
nip19.test.js
Normal 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
125
nip19.ts
Normal 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
105
nip26.test.js
Normal 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
90
nip26.ts
Normal 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
|
||||||
|
}
|
||||||
11
package.json
11
package.json
@@ -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",
|
||||||
|
|||||||
207
relay.test.js
207
relay.test.js
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-env jest */
|
/* eslint-env jest */
|
||||||
|
|
||||||
|
require('websocket-polyfill')
|
||||||
const {
|
const {
|
||||||
relayInit,
|
relayInit,
|
||||||
generatePrivateKey,
|
generatePrivateKey,
|
||||||
@@ -8,110 +9,106 @@ 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()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await relay.close()
|
await relay.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('connectivity', () => {
|
test('connectivity', () => {
|
||||||
return expect(
|
return expect(
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
relay.on('connect', () => {
|
relay.on('connect', () => {
|
||||||
resolve(true)
|
resolve(true)
|
||||||
})
|
})
|
||||||
relay.on('error', () => {
|
relay.on('error', () => {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
).resolves.toBe(true)
|
).resolves.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('querying', () => {
|
test('querying', () => {
|
||||||
var resolve1
|
var resolve1
|
||||||
var resolve2
|
var resolve2
|
||||||
|
|
||||||
let sub = relay.sub([
|
let sub = relay.sub([
|
||||||
{
|
{
|
||||||
ids: [
|
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
}
|
||||||
]
|
])
|
||||||
}
|
sub.on('event', event => {
|
||||||
])
|
expect(event).toHaveProperty(
|
||||||
sub.on('event', event => {
|
'id',
|
||||||
expect(event).toHaveProperty(
|
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
||||||
'id',
|
)
|
||||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
resolve1(true)
|
||||||
)
|
})
|
||||||
resolve1(true)
|
sub.on('eose', () => {
|
||||||
})
|
resolve2(true)
|
||||||
sub.on('eose', () => {
|
})
|
||||||
resolve2(true)
|
|
||||||
})
|
return expect(
|
||||||
|
Promise.all([
|
||||||
return expect(
|
new Promise(resolve => {
|
||||||
Promise.all([
|
resolve1 = resolve
|
||||||
new Promise(resolve => {
|
}),
|
||||||
resolve1 = resolve
|
new Promise(resolve => {
|
||||||
}),
|
resolve2 = resolve
|
||||||
new Promise(resolve => {
|
})
|
||||||
resolve2 = resolve
|
])
|
||||||
})
|
).resolves.toEqual([true, true])
|
||||||
])
|
})
|
||||||
).resolves.toEqual([true, true])
|
|
||||||
})
|
test('listening (twice) and publishing', async () => {
|
||||||
|
let sk = generatePrivateKey()
|
||||||
test('listening (twice) and publishing', async () => {
|
let pk = getPublicKey(sk)
|
||||||
let sk = generatePrivateKey()
|
var resolve1
|
||||||
let pk = getPublicKey(sk)
|
var resolve2
|
||||||
var resolve1
|
|
||||||
var resolve2
|
let sub = relay.sub([
|
||||||
|
{
|
||||||
let sub = relay.sub([
|
kinds: [27572],
|
||||||
{
|
authors: [pk]
|
||||||
kinds: [27572],
|
}
|
||||||
authors: [pk]
|
])
|
||||||
}
|
|
||||||
])
|
sub.on('event', event => {
|
||||||
|
expect(event).toHaveProperty('pubkey', pk)
|
||||||
sub.on('event', event => {
|
expect(event).toHaveProperty('kind', 27572)
|
||||||
expect(event).toHaveProperty('pubkey', pk)
|
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||||
expect(event).toHaveProperty('kind', 27572)
|
resolve1(true)
|
||||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
})
|
||||||
resolve1(true)
|
sub.on('event', event => {
|
||||||
})
|
expect(event).toHaveProperty('pubkey', pk)
|
||||||
sub.on('event', event => {
|
expect(event).toHaveProperty('kind', 27572)
|
||||||
expect(event).toHaveProperty('pubkey', pk)
|
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
||||||
expect(event).toHaveProperty('kind', 27572)
|
resolve2(true)
|
||||||
expect(event).toHaveProperty('content', 'nostr-tools test suite')
|
})
|
||||||
resolve2(true)
|
|
||||||
})
|
let event = {
|
||||||
|
kind: 27572,
|
||||||
let event = {
|
pubkey: pk,
|
||||||
kind: 27572,
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
pubkey: pk,
|
tags: [],
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
content: 'nostr-tools test suite'
|
||||||
tags: [],
|
}
|
||||||
content: 'nostr-tools test suite'
|
event.id = getEventHash(event)
|
||||||
}
|
event.sig = await signEvent(event, sk)
|
||||||
event.id = getEventHash(event)
|
|
||||||
event.sig = await signEvent(event, sk)
|
relay.publish(event)
|
||||||
|
return expect(
|
||||||
relay.publish(event)
|
Promise.all([
|
||||||
return expect(
|
new Promise(resolve => {
|
||||||
Promise.all([
|
resolve1 = resolve
|
||||||
new Promise(resolve => {
|
}),
|
||||||
resolve1 = resolve
|
new Promise(resolve => {
|
||||||
}),
|
resolve2 = resolve
|
||||||
new Promise(resolve => {
|
})
|
||||||
resolve2 = resolve
|
])
|
||||||
})
|
).resolves.toEqual([true, true])
|
||||||
])
|
|
||||||
).resolves.toEqual([true, true])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
226
relay.ts
226
relay.ts
@@ -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,123 +60,78 @@ 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
|
ws = new WebSocket(url)
|
||||||
|
|
||||||
|
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 && resolveClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = async e => {
|
||||||
|
var data
|
||||||
|
try {
|
||||||
|
data = JSON.parse(e.data)
|
||||||
|
} catch (err) {
|
||||||
|
data = e.data
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectRelay() {
|
|
||||||
ws = new WebSocket(url)
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
listeners.connect.forEach(cb => cb())
|
|
||||||
resolveOpen()
|
|
||||||
isConnected = true
|
|
||||||
|
|
||||||
// restablish old subscriptions
|
|
||||||
if (wasClosed) {
|
|
||||||
wasClosed = false
|
|
||||||
for (let id in openSubs) {
|
|
||||||
let {filters} = openSubs[id]
|
|
||||||
sub(filters, openSubs[id])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.onerror = () => {
|
|
||||||
isConnected = false
|
|
||||||
listeners.error.forEach(cb => cb())
|
|
||||||
}
|
|
||||||
ws.onclose = async () => {
|
|
||||||
isConnected = false
|
|
||||||
listeners.disconnect.forEach(cb => cb())
|
|
||||||
|
|
||||||
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 => {
|
|
||||||
var data
|
|
||||||
try {
|
|
||||||
data = JSON.parse(e.data)
|
|
||||||
} catch (err) {
|
|
||||||
data = e.data
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user