mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-09 00:28:51 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32fd25556b | ||
|
|
0925f5db81 | ||
|
|
bce976fecd | ||
|
|
45e479d7aa | ||
|
|
b92407b156 | ||
|
|
2431896921 | ||
|
|
d13eecad4a | ||
|
|
df6f887d7e | ||
|
|
e00362e7c9 | ||
|
|
9efdd16e26 | ||
|
|
de7e128818 | ||
|
|
4978c858e7 | ||
|
|
16c7ae2a70 |
12
event.ts
12
event.ts
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
1
index.ts
1
index.ts
@@ -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
21
nip44.test.ts
Normal 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
40
nip44.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
29
nip98.ts
29
nip98.ts
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
122
pool.ts
@@ -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)
|
||||
|
||||
4
relay.ts
4
relay.ts
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user