Compare commits

...

13 Commits

Author SHA1 Message Date
ffaex
32fd25556b added new event kind 1063
see https://github.com/nostr-protocol/nips/blob/master/94.md
2023-08-21 15:03:23 -03:00
Sepehr Safari
0925f5db81 add batchedList method to SimplePool 2023-08-21 10:44:33 -03:00
fiatjaf
bce976fecd get rid of httpmethod enum. 2023-08-16 14:07:26 -03:00
fiatjaf
45e479d7aa let it throw. 2023-08-16 13:59:31 -03:00
Jon Staab
b92407b156 nip44 updates (#278)
Co-authored-by: Jonathan Staab <shtaab@gmail.com>
2023-08-16 13:53:37 -03:00
Pierre Buyle
2431896921 fix(nip98): Add support for HEAD, PUT, CONNECT, OPTIONS, TRACE and PATCH http methods
This PR adds common HTTP methods (as listed on https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods)
2023-08-15 11:03:47 -03:00
Jonathan Staab
d13eecad4a Add support for nip44 2023-08-12 20:46:32 -03:00
Alex Gleason
df6f887d7e Event, Filter: allow any kind number
Fixes https://github.com/nbd-wtf/nostr-tools/issues/275
2023-08-12 20:13:53 -03:00
Alex Gleason
e00362e7c9 Filter: let tag queries be undefined 2023-08-12 16:30:24 -03:00
fiatjaf
9efdd16e26 fix check for undefined ws
fixes https://github.com/nbd-wtf/nostr-tools/issues/271
2023-08-11 07:09:40 -03:00
Alex Gleason
de7e128818 Merge pull request #267 from Airtune/nip98-extract-pubkey
+nip98.unpackEventFromToken +nip98.validateEvent
2023-08-08 08:48:29 -05:00
Airtune
4978c858e7 Update nip98.ts examples 2023-08-08 02:45:23 -04:00
Airtune
16c7ae2a70 +nip98.unpackEventFromToken +nip98.validateEvent 2023-08-07 22:16:23 -04:00
11 changed files with 847 additions and 792 deletions

View File

@@ -5,6 +5,7 @@ import {bytesToHex} from '@noble/hashes/utils'
import {getPublicKey} from './keys.ts'
import {utf8Encoder} from './utils.ts'
/** @deprecated Use numbers instead. */
/* eslint-disable no-unused-vars */
export enum Kind {
Metadata = 0,
@@ -30,21 +31,22 @@ export enum Kind {
HttpAuth = 27235,
ProfileBadge = 30008,
BadgeDefinition = 30009,
Article = 30023
Article = 30023,
FileMetadata = 1063
}
export type EventTemplate<K extends number = Kind> = {
export type EventTemplate<K extends number = number> = {
kind: K
tags: string[][]
content: string
created_at: number
}
export type UnsignedEvent<K extends number = Kind> = EventTemplate<K> & {
export type UnsignedEvent<K extends number = number> = EventTemplate<K> & {
pubkey: string
}
export type Event<K extends number = Kind> = UnsignedEvent<K> & {
export type Event<K extends number = number> = UnsignedEvent<K> & {
id: string
sig: string
}
@@ -60,7 +62,7 @@ export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
}
}
export function finishEvent<K extends number = Kind>(
export function finishEvent<K extends number = number>(
t: EventTemplate<K>,
privateKey: string
): Event<K> {

View File

@@ -1,6 +1,6 @@
import {Event, type Kind} from './event.ts'
export type Filter<K extends number = Kind> = {
export type Filter<K extends number = number> = {
ids?: string[]
kinds?: K[]
authors?: string[]
@@ -8,7 +8,7 @@ export type Filter<K extends number = Kind> = {
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[]
[key: `#${string}`]: string[] | undefined
}
export function matchFilter(
@@ -34,7 +34,7 @@ export function matchFilter(
if (
values &&
!event.tags.find(
([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1
)
)
return false

View File

@@ -19,6 +19,7 @@ export * as nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts'
export * as nip57 from './nip57.ts'
export * as nip98 from './nip98.ts'

21
nip44.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import crypto from 'node:crypto'
import {hexToBytes} from '@noble/hashes/utils'
import {encrypt, decrypt, getSharedSecret} from './nip44.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
let sharedKey1 = getSharedSecret(sk1, pk2)
let sharedKey2 = getSharedSecret(sk2, pk1)
expect(decrypt(hexToBytes(sk1), encrypt(hexToBytes(sk1), 'hello'))).toEqual('hello')
expect(decrypt(sharedKey2, encrypt(sharedKey1, 'hello'))).toEqual('hello')
})

40
nip44.ts Normal file
View File

@@ -0,0 +1,40 @@
import {base64} from '@scure/base'
import {randomBytes} from '@noble/hashes/utils'
import {secp256k1} from '@noble/curves/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {xchacha20} from '@noble/ciphers/chacha'
import {utf8Decoder, utf8Encoder} from './utils.ts'
export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array =>
sha256(secp256k1.getSharedSecret(privkey, '02' + pubkey).subarray(1, 33))
export function encrypt(key: Uint8Array, text: string, v = 1) {
if (v !== 1) {
throw new Error('NIP44: unknown encryption version')
}
const nonce = randomBytes(24)
const plaintext = utf8Encoder.encode(text)
const ciphertext = xchacha20(key, nonce, plaintext)
const payload = new Uint8Array(25 + ciphertext.length)
payload.set([v], 0)
payload.set(nonce, 1)
payload.set(ciphertext, 25)
return base64.encode(payload)
}
export function decrypt(key: Uint8Array, payload: string) {
let data = base64.decode(payload)
if (data[0] !== 1) {
throw new Error(`NIP44: unknown encryption version: ${data[0]}`)
}
const nonce = data.slice(1, 25)
const ciphertext = data.slice(25)
const plaintext = xchacha20(key, nonce, ciphertext)
return utf8Decoder.decode(plaintext)
}

View File

@@ -1,7 +1,5 @@
import {base64} from '@scure/base'
import {getToken, validateToken} from './nip98.ts'
import {getToken, unpackEventFromToken, validateEvent, validateToken} from './nip98.ts'
import {Event, Kind, finishEvent} from './event.ts'
import {utf8Decoder} from './utils.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts'
const sk = generatePrivateKey()
@@ -12,9 +10,7 @@ describe('getToken', () => {
finishEvent(e, sk)
)
const decodedResult: Event = JSON.parse(
utf8Decoder.decode(base64.decode(result))
)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
@@ -31,9 +27,7 @@ describe('getToken', () => {
finishEvent(e, sk)
)
const decodedResult: Event = JSON.parse(
utf8Decoder.decode(base64.decode(result))
)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
@@ -57,9 +51,7 @@ describe('getToken', () => {
expect(result.startsWith(authorizationScheme)).toBe(true)
const decodedResult: Event = JSON.parse(
utf8Decoder.decode(base64.decode(result.replace(authorizationScheme, '')))
)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
@@ -136,4 +128,43 @@ describe('validateToken', () => {
const result = validateToken(validToken, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
const validToken = await getToken(
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateEvent throws an error for a wrong url', async () => {
const validToken = await getToken(
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateEvent throws an error for a wrong method', async () => {
const validToken = await getToken(
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
})
})

View File

@@ -8,11 +8,6 @@ import {
} from './event'
import {utf8Decoder, utf8Encoder} from './utils'
enum HttpMethod {
Get = 'get',
Post = 'post'
}
const _authorizationScheme = 'Nostr '
/**
@@ -20,11 +15,11 @@ const _authorizationScheme = 'Nostr '
*
* @example
* const sign = window.nostr.signEvent
* await getToken('https://example.com/login', 'post', sign, true)
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
*/
export async function getToken(
loginUrl: string,
httpMethod: HttpMethod | string,
httpMethod: string,
sign: <K extends number = number>(
e: EventTemplate<K>
) => Promise<Event<K>> | Event<K>,
@@ -32,8 +27,6 @@ export async function getToken(
): Promise<string> {
if (!loginUrl || !httpMethod)
throw new Error('Missing loginUrl or httpMethod')
if (httpMethod !== HttpMethod.Get && httpMethod !== HttpMethod.Post)
throw new Error('Unknown httpMethod')
const event = getBlankEvent(Kind.HttpAuth)
@@ -58,13 +51,20 @@ export async function getToken(
* Validate token for NIP-98 flow.
*
* @example
* await validateToken('Nostr base64token', 'https://example.com/login', 'post')
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
*/
export async function validateToken(
token: string,
url: string,
method: string
): Promise<boolean> {
const event = await unpackEventFromToken(token).catch((error) => { throw(error) })
const valid = await validateEvent(event, url, method).catch((error) => { throw(error) })
return valid
}
export async function unpackEventFromToken(token: string): Promise<Event> {
if (!token) {
throw new Error('Missing token')
}
@@ -76,6 +76,15 @@ export async function validateToken(
}
const event = JSON.parse(eventB64) as Event
return event
}
export async function validateEvent(
event: Event,
url: string,
method: string
): Promise<boolean> {
if (!event) {
throw new Error('Invalid nostr event')
}

View File

@@ -1,6 +1,6 @@
{
"name": "nostr-tools",
"version": "1.14.0",
"version": "1.14.2",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -21,6 +21,7 @@
"dependencies": {
"@noble/curves": "1.1.0",
"@noble/hashes": "1.3.1",
"@noble/ciphers": "^0.2.0",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"

122
pool.ts
View File

@@ -7,26 +7,38 @@ import {
import {normalizeURL} from './utils.ts'
import type {Event} from './event.ts'
import type {Filter} from './filter.ts'
import {matchFilters, type Filter} from './filter.ts'
type BatchedRequest = {
filters: Filter<any>[]
relays: string[]
resolve: (events: Event<any>[]) => void
events: Event<any>[]
}
export class SimplePool {
private _conn: {[url: string]: Relay}
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
private batchedByKey: {[batchKey: string]: BatchedRequest[]} = {}
private eoseSubTimeout: number
private getTimeout: number
private seenOnEnabled: boolean = true
private batchInterval: number = 100
constructor(
options: {
eoseSubTimeout?: number
getTimeout?: number
seenOnEnabled?: boolean
batchInterval?: number
} = {}
) {
this._conn = {}
this.eoseSubTimeout = options.eoseSubTimeout || 3400
this.getTimeout = options.getTimeout || 3400
this.seenOnEnabled = options.seenOnEnabled !== false
this.batchInterval = options.batchInterval || 100
}
close(relays: string[]): void {
@@ -81,34 +93,36 @@ export class SimplePool {
for (let cb of eoseListeners.values()) cb()
}, this.eoseSubTimeout)
relays.forEach(async relay => {
let r
try {
r = await this.ensureRelay(relay)
} catch (err) {
handleEose()
return
}
if (!r) return
let s = r.sub(filters, modifiedOpts)
s.on('event', event => {
_knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event)
})
s.on('eose', () => {
if (eoseSent) return
handleEose()
})
subs.push(s)
function handleEose() {
eosesMissing--
if (eosesMissing === 0) {
clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) cb()
relays
.filter((r, i, a) => a.indexOf(r) == i)
.forEach(async relay => {
let r
try {
r = await this.ensureRelay(relay)
} catch (err) {
handleEose()
return
}
}
})
if (!r) return
let s = r.sub(filters, modifiedOpts)
s.on('event', event => {
_knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event)
})
s.on('eose', () => {
if (eoseSent) return
handleEose()
})
subs.push(s)
function handleEose() {
eosesMissing--
if (eosesMissing === 0) {
clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) cb()
}
}
})
let greaterSub: Sub = {
sub(filters, opts) {
@@ -176,6 +190,58 @@ export class SimplePool {
})
}
batchedList<K extends number = number>(
batchKey: string,
relays: string[],
filters: Filter<K>[]
): Promise<Event<K>[]> {
return new Promise(resolve => {
if (!this.batchedByKey[batchKey]) {
this.batchedByKey[batchKey] = [
{
filters,
relays,
resolve,
events: []
}
]
setTimeout(() => {
Object.keys(this.batchedByKey).forEach(async batchKey => {
const batchedRequests = this.batchedByKey[batchKey]
const filters = [] as Filter[]
const relays = [] as string[]
batchedRequests.forEach(br => {
filters.push(...br.filters)
relays.push(...br.relays)
})
const sub = this.sub(relays, filters)
sub.on('event', event => {
batchedRequests.forEach(
br => matchFilters(br.filters, event) && br.events.push(event)
)
})
sub.on('eose', () => {
sub.unsub()
batchedRequests.forEach(br => br.resolve(br.events))
})
delete this.batchedByKey[batchKey]
})
}, this.batchInterval)
} else {
this.batchedByKey[batchKey].push({
filters,
relays,
resolve,
events: []
})
}
})
}
publish(relays: string[], event: Event<number>): Promise<void>[] {
return relays.map(async relay => {
let r = await this.ensureRelay(relay)

View File

@@ -386,8 +386,8 @@ export function relayInit(
listeners = newListeners()
subListeners = {}
pubListeners = {}
if (ws.readyState === WebSocket.OPEN) {
ws?.close()
if (ws?.readyState === WebSocket.OPEN) {
ws.close()
}
},
get status() {

1346
yarn.lock

File diff suppressed because it is too large Load Diff