Compare commits

..

2 Commits

Author SHA1 Message Date
Alex Gleason
2fccf0ae48 Update event.ts
Co-authored-by: WilliamConnatser <43946230+WilliamConnatser@users.noreply.github.com>
2023-11-06 08:23:35 +09:00
fiatjaf
842cba25f3 turn Kind enum into a simple object with numbers. 2023-10-30 08:03:42 -03:00
110 changed files with 7232 additions and 10076 deletions

View File

@@ -1,19 +0,0 @@
FROM node:20
RUN npm install typescript eslint prettier -g
# Install bun
RUN curl -fsSL https://bun.sh/install | bash
# Install just
WORKDIR /usr/bin
RUN wget https://github.com/casey/just/releases/download/1.26.0/just-1.26.0-x86_64-unknown-linux-musl.tar.gz
RUN tar -xzf just-1.26.0-x86_64-unknown-linux-musl.tar.gz
RUN chmod +x ./just
RUN rm just-1.26.0-x86_64-unknown-linux-musl.tar.gz
WORKDIR /nostr-tools
ENV LANG C.UTF-8
# The run the start script
CMD [ "/bin/bash" ]

View File

@@ -1,19 +0,0 @@
{
"name": "Nostr Tools",
"dockerComposeFile": [
"docker-compose.yml"
],
"service": "nostr-tools-dev",
"workspaceFolder": "/nostr-tools",
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"eamodio.gitlens",
"dbaeumer.vscode-eslint",
"manishsencha.readme-preview",
"wix.vscode-import-cost"
]
}
}
}

View File

@@ -1,13 +0,0 @@
version: '3.9'
services:
nostr-tools-dev:
image: nostr-tools-dev
container_name: nostr-tools-dev
build:
context: ../.
dockerfile: ./.devcontainer/Dockerfile
working_dir: /nostr-tools
volumes:
- ..:/nostr-tools:cached
tty: true

View File

@@ -3,7 +3,7 @@
"extends": ["prettier"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"plugins": ["@typescript-eslint", "babel"],
"parserOptions": {
"ecmaVersion": 9,
@@ -45,6 +45,7 @@
"curly": [0, "multi-line"],
"dot-location": [2, "property"],
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"handle-callback-err": [2, "^(err|error)$"],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],
@@ -116,8 +117,7 @@
"no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unreachable": 2,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
"no-useless-call": 2,
"no-useless-constructor": 2,
"no-with": 2,
@@ -139,5 +139,13 @@
"wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"],
"yoda": [0]
}
},
"overrides": [
{
"files": ["**/*.test.ts"],
"env": { "jest/globals": true },
"plugins": ["jest"],
"extends": ["plugin:jest/recommended"]
}
]
}

23
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: publish npm package
on:
push:
tags: [v*]
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: extractions/setup-just@v1
- run: just install-dependencies
- run: just build
- run: just test
- run: just emit-types
- uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}
greater-version-only: true

View File

@@ -10,15 +10,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: extractions/setup-just@v1
- run: bun i
- run: just install-dependencies
- run: just test
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: extractions/setup-just@v1
- run: bun i
- run: just install-dependencies
- run: just lint

1
.gitignore vendored
View File

@@ -5,4 +5,3 @@ package-lock.json
.envrc
lib
test.html
bench.js

401
README.md
View File

@@ -1,115 +1,94 @@
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) [![JSR](https://jsr.io/badges/@nostr/tools)](https://jsr.io/@nostr/tools) nostr-tools
# nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages.
This package is only providing lower-level functionality. If you want higher-level features, take a look at [@nostr/gadgets](https://jsr.io/@nostr/gadgets) which is based on this library and expands upon it and has other goodies (it's only available on jsr).
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
## Installation
```bash
# npm
npm install --save nostr-tools
# jsr
npx jsr add @nostr/tools
npm install nostr-tools # or yarn add nostr-tools
```
If using TypeScript, this package requires TypeScript >= 5.0.
## Documentation
https://jsr.io/@nostr/tools/doc
## Usage
### Generating a private key and a public key
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { generatePrivateKey, getPublicKey } from 'nostr-tools'
let sk = generateSecretKey() // `sk` is a Uint8Array
let sk = generatePrivateKey() // `sk` is a hex string
let pk = getPublicKey(sk) // `pk` is a hex string
```
To get the secret key in hex format, use
```js
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency
let skHex = bytesToHex(sk)
let backToBytes = hexToBytes(skHex)
```
### Creating, signing and verifying events
```js
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
import { validateEvent, verifySignature, getSignature, getEventHash, getPublicKey } from 'nostr-tools'
let event = finalizeEvent({
let event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
}, sk)
pubkey: getPublicKey(privateKey),
}
let isGood = verifyEvent(event)
event.id = getEventHash(event)
event.sig = getSignature(event, privateKey)
let ok = validateEvent(event)
let veryOk = verifySignature(event)
```
### Interacting with one or multiple relays
Doesn't matter what you do, you always should be using a `SimplePool`:
### Interacting with a relay
```js
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { SimplePool } from 'nostr-tools/pool'
import { relayInit, finishEvent, generatePrivateKey, getPublicKey } from 'nostr-tools'
const pool = new SimplePool()
const relay = relayInit('wss://relay.example.com')
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
})
relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`)
})
const relays = ['wss://relay.example.com', 'wss://relay.example2.com']
await relay.connect()
// let's query for one event that exists
const event = pool.get(
relays,
// let's query for an event that exists
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
},
)
if (event) {
console.log('it exists indeed on this relay:', event)
}
// let's query for more than one event that exists
const events = pool.querySync(
relays,
{
kinds: [1],
limit: 10
},
)
if (events) {
console.log('it exists indeed on this relay:', events)
}
])
sub.on('event', event => {
console.log('we got the event we wanted:', event)
})
sub.on('eose', () => {
sub.unsub()
})
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generateSecretKey()
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
pool.subscribe(
['wss://a.com', 'wss://b.com', 'wss://c.com'],
let sub = relay.sub([
{
kinds: [1],
authors: [pk],
},
{
onevent(event) {
console.log('got event:', event)
}
}
)
])
let eventTemplate = {
sub.on('event', event => {
console.log('got event:', event)
})
let event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
@@ -117,141 +96,93 @@ let eventTemplate = {
}
// this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(eventTemplate, sk)
await Promise.any(pool.publish(['wss://a.com', 'wss://b.com'], signedEvent))
const signedEvent = finishEvent(event, sk)
await relay.publish(signedEvent)
let events = await relay.list([{ kinds: [0, 1] }])
let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
relay.close()
```
To use this on Node.js you first must install `ws` and call something like this:
To use this on Node.js you first must install `websocket-polyfill` and import it:
```js
import { useWebSocketImplementation } from 'nostr-tools/pool'
// or import { useWebSocketImplementation } from 'nostr-tools/relay' if you're using the Relay directly
import WebSocket from 'ws'
useWebSocketImplementation(WebSocket)
import 'websocket-polyfill'
```
### Parsing references (mentions) from a content based on NIP-27
### Interacting with multiple relays
```js
import * as nip27 from '@nostr/tools/nip27'
import { SimplePool } from 'nostr-tools'
for (let block of nip27.parse(evt.content)) {
switch (block.type) {
case 'text':
console.log(block.text)
break
case 'reference': {
if ('id' in block.pointer) {
console.log("it's a nevent1 uri", block.pointer)
} else if ('identifier' in block.pointer) {
console.log("it's a naddr1 uri", block.pointer)
} else {
console.log("it's an npub1 or nprofile1 uri", block.pointer)
}
break
}
case 'url': {
console.log("it's a normal url:", block.url)
break
}
case 'image':
case 'video':
case 'audio':
console.log("it's a media url:", block.url)
case 'relay':
console.log("it's a websocket url, probably a relay address:", block.url)
default:
break
}
}
```
### Connecting to a bunker using NIP-46
```js
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool'
// the client needs a local secret key (which is generally persisted) for communicating with the bunker
const localSecretKey = generateSecretKey()
// parse a bunker URI
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
if (!bunkerPointer) {
throw new Error('Invalid bunker input')
}
// create the bunker instance
const pool = new SimplePool()
const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool })
await bunker.connect()
// and use it
const pubkey = await bunker.getPublicKey()
const event = await bunker.signEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello from bunker!'
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
let sub = pool.sub(
[...relays, 'wss://relay.example3.com'],
[
{
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
},
],
)
sub.on('event', event => {
// this will only be called once the first time the event is received
// ...
})
// cleanup
await signer.close()
pool.close([])
let pubs = pool.publish(relays, newEvent)
await Promise.all(pubs)
let events = await pool.list(relays, [{ kinds: [0, 1] }])
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
let batchedEvents = await pool.batchedList('notes', relays, [{ kinds: [1] }])
// `batchedList` will wait for other function calls with the same `batchKey`
// (e.g. 'notes', 'authors', etc) within a fixed amount of time (default: `100ms`) before sending
// next ws request, and batch all requests with similar `batchKey`s together in a single request.
let relaysForEvent = pool.seenOn('44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
// relaysForEvent will be an array of URLs from relays a given event was seen on
pool.close()
```
### Parsing thread from any note based on NIP-10
read more details about `batchedList` on this pr: [https://github.com/nbd-wtf/nostr-tools/pull/279](https://github.com/nbd-wtf/nostr-tools/pull/279#issue-1859315757)
### Parsing references (mentions) from a content using NIP-10 and NIP-27
```js
import * as nip10 from '@nostr/tools/nip10'
import { parseReferences } from 'nostr-tools'
// event is a nostr event with tags
const refs = nip10.parse(event)
// get the root event of the thread
if (refs.root) {
console.log('root event:', refs.root.id)
console.log('root event relay hints:', refs.root.relays)
console.log('root event author:', refs.root.author)
}
// get the immediate parent being replied to
if (refs.reply) {
console.log('reply to:', refs.reply.id)
console.log('reply relay hints:', refs.reply.relays)
console.log('reply author:', refs.reply.author)
}
// get any mentioned events
for (let mention of refs.mentions) {
console.log('mentioned event:', mention.id)
console.log('mention relay hints:', mention.relays)
console.log('mention author:', mention.author)
}
// get any quoted events
for (let quote of refs.quotes) {
console.log('quoted event:', quote.id)
console.log('quote relay hints:', quote.relays)
}
// get any referenced profiles
for (let profile of refs.profiles) {
console.log('referenced profile:', profile.pubkey)
console.log('profile relay hints:', profile.relays)
let references = parseReferences(event)
let simpleAugmentedContent = event.content
for (let i = 0; i < references.length; i++) {
let { text, profile, event, address } = references[i]
let augmentedReference = profile
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
: event
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
: address
? `<a href="${text}">[link]</a>`
: text
simpleAugmentedContent.replaceAll(text, augmentedReference)
}
```
### Querying profile data from a NIP-05 address
```js
import { queryProfile } from 'nostr-tools/nip05'
import { nip05 } from 'nostr-tools'
let profile = await queryProfile('jb55.com')
let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey)
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
console.log(profile.relays)
@@ -261,40 +192,27 @@ console.log(profile.relays)
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
```js
import { useFetchImplementation } from 'nostr-tools/nip05'
useFetchImplementation(require('node-fetch'))
```
### Including NIP-07 types
```js
import type { WindowNostr } from 'nostr-tools/nip07'
declare global {
interface Window {
nostr?: WindowNostr;
}
}
nip05.useFetchImplementation(require('node-fetch'))
```
### Encoding and decoding NIP-19 codes
```js
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
import { nip19, generatePrivateKey, getPublicKey } from 'nostr-tools'
let sk = generateSecretKey()
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
let { type, data } = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generateSecretKey())
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
let { type, data } = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
let pk = getPublicKey(generateSecretKey())
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)
@@ -303,73 +221,94 @@ assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
### Using it with `nostr-wasm`
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
### Encrypting and decrypting direct messages
```js
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
import { initNostrWasm } from 'nostr-wasm'
import {nip44, getPublicKey, generatePrivateKey} from 'nostr-tools'
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
initNostrWasm().then(setNostrWasm)
// sender
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
// see https://www.npmjs.com/package/nostr-wasm for options
// receiver
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// on the sender side
let message = 'hello'
let key = nip44.getSharedSecret(sk1, pk2)
let ciphertext = nip44.encrypt(key, message)
let event = {
kind: 4,
pubkey: pk1,
tags: [['p', pk2]],
content: ciphertext,
...otherProperties,
}
sendEvent(event)
// on the receiver side
sub.on('event', async event => {
let sender = event.pubkey
// pk1 === sender
let _key = nip44.getSharedSecret(sk2, pk1)
let plaintext = nip44.decrypt(_key, event.content)
})
```
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
### Performing and checking for delegation
```js
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
import { initNostrWasm } from 'nostr-wasm'
import { nip26, getPublicKey, generatePrivateKey } from 'nostr-tools'
initNostrWasm().then(setNostrWasm)
// delegator
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
const pool = new AbstractSimplePool({ verifyEvent })
// 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
```
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks:
```
benchmark time (avg) (min … max) p75 p99 p995
------------------------------------------------- -----------------------------
• relay read message and verify event (many events)
------------------------------------------------- -----------------------------
wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms
pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms
trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs
summary for relay read message and verify event
wasm
86.77x slower than trusted
6.86x faster than pure js
```
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)
```html
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
window.NostrTools.generateSecretKey('...') // and so on
window.NostrTools.generatePrivateKey('...') // and so on
</script>
```
## Plumbing
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
1. Install [`just`](https://just.systems/)
2. `just -l`
## License
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
## Contributing to this repository
Use NIP-34 to send your patches to:
```
naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq
```

View File

@@ -1,299 +0,0 @@
/* global WebSocket */
import {
AbstractRelay as AbstractRelay,
SubscriptionParams,
Subscription,
type AbstractRelayConstructorOptions,
} from './abstract-relay.ts'
import { normalizeURL } from './utils.ts'
import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
import { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts'
export type SubCloser = { close: (reason?: string) => void }
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number
onclose?: (reasons: string[]) => void
onauth?: (event: EventTemplate) => Promise<VerifiedEvent>
// Deprecated: use onauth instead
doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string
label?: string
}
export class AbstractSimplePool {
protected relays: Map<string, AbstractRelay> = new Map()
public seenOn: Map<string, Set<AbstractRelay>> = new Map()
public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent']
public trustedRelayURLs: Set<string> = new Set()
private _WebSocket?: typeof WebSocket
constructor(opts: AbstractPoolConstructorOptions) {
this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation
}
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
url = normalizeURL(url)
let relay = this.relays.get(url)
if (!relay) {
relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
websocketImplementation: this._WebSocket,
})
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay)
}
await relay.connect()
return relay
}
close(relays: string[]) {
relays.map(normalizeURL).forEach(url => {
this.relays.get(url)?.close()
})
}
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
return this.subscribeMap(
relays.map(url => ({ url, filter })),
params,
)
}
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
return this.subscribeMap(
relays.flatMap(url => filters.map(filter => ({ url, filter }))),
params,
)
}
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth
if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id)
if (!set) {
set = new Set()
this.seenOn.set(id, set)
}
set.add(relay)
}
}
const _knownIds = new Set<string>()
const subs: Subscription[] = []
// batch all EOSEs into a single
const eosesReceived: boolean[] = []
let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === requests.length) {
params.oneose?.()
handleEose = () => {}
}
}
// batch all closes into a single
const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i)
closesReceived[i] = reason
if (closesReceived.filter(a => a).length === requests.length) {
params.onclose?.(closesReceived)
handleClose = () => {}
}
}
const localAlreadyHaveEventHandler = (id: string) => {
if (params.alreadyHaveEvent?.(id)) {
return true
}
const have = _knownIds.has(id)
_knownIds.add(id)
return have
}
// open a subscription in all given relays
const allOpened = Promise.all(
requests.map(async ({ url, filter }, i) => {
url = normalizeURL(url)
let relay: AbstractRelay
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
})
} catch (err) {
handleClose(i, (err as any)?.message || String(err))
return
}
let subscription = relay.subscribe([filter], {
...params,
oneose: () => handleEose(i),
onclose: reason => {
if (reason.startsWith('auth-required: ') && params.onauth) {
relay
.auth(params.onauth)
.then(() => {
relay.subscribe([filter], {
...params,
oneose: () => handleEose(i),
onclose: reason => {
handleClose(i, reason) // the second time we won't try to auth anymore
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
})
.catch(err => {
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
})
} else {
handleClose(i, reason)
}
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
subs.push(subscription)
}),
)
return {
async close(reason?: string) {
await allOpened
subs.forEach(sub => {
sub.close(reason)
})
},
}
}
subscribeEose(
relays: string[],
filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser {
params.onauth = params.onauth || params.doauth
const subcloser = this.subscribe(relays, filter, {
...params,
oneose() {
subcloser.close('closed automatically on eose')
},
})
return subcloser
}
subscribeManyEose(
relays: string[],
filters: Filter[],
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser {
params.onauth = params.onauth || params.doauth
const subcloser = this.subscribeMany(relays, filters, {
...params,
oneose() {
subcloser.close('closed automatically on eose')
},
})
return subcloser
}
async querySync(
relays: string[],
filter: Filter,
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event[]> {
return new Promise(async resolve => {
const events: Event[] = []
this.subscribeEose(relays, filter, {
...params,
onevent(event: Event) {
events.push(event)
},
onclose(_: string[]) {
resolve(events)
},
})
})
}
async get(
relays: string[],
filter: Filter,
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event | null> {
filter.limit = 1
const events = await this.querySync(relays, filter, params)
events.sort((a, b) => b.created_at - a.created_at)
return events[0] || null
}
publish(
relays: string[],
event: Event,
options?: { onauth?: (evt: EventTemplate) => Promise<VerifiedEvent> },
): Promise<string>[] {
return relays.map(normalizeURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) {
// duplicate
return Promise.reject('duplicate url')
}
let r = await this.ensureRelay(url)
return r
.publish(event)
.catch(async err => {
if (err instanceof Error && err.message.startsWith('auth-required: ') && options?.onauth) {
await r.auth(options.onauth)
return r.publish(event) // retry
}
throw err
})
.then(reason => {
if (this.trackRelays) {
let set = this.seenOn.get(event.id)
if (!set) {
set = new Set()
this.seenOn.set(event.id, set)
}
set.add(r)
}
return reason
})
})
}
listConnectionStatus(): Map<string, boolean> {
const map = new Map<string, boolean>()
this.relays.forEach((relay, url) => map.set(url, relay.connected))
return map
}
destroy(): void {
this.relays.forEach(conn => conn.close())
this.relays = new Map()
}
}

View File

@@ -1,423 +0,0 @@
/* global WebSocket */
import type { Event, EventTemplate, VerifiedEvent, Nostr, NostrEvent } from './core.ts'
import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts'
import { Queue, normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts'
export type AbstractRelayConstructorOptions = {
verifyEvent: Nostr['verifyEvent']
websocketImplementation?: typeof WebSocket
}
export class SendingOnClosedConnection extends Error {
constructor(message: string, relay: string) {
super(`Tried to send message '${message} on a closed connection to ${relay}.`)
this.name = 'SendingOnClosedConnection'
}
}
export class AbstractRelay {
public readonly url: string
private _connected: boolean = false
public onclose: (() => void) | null = null
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400
public publishTimeout: number = 4400
public openSubs: Map<string, Subscription> = new Map()
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private connectionPromise: Promise<void> | undefined
private openCountRequests = new Map<string, CountResolver>()
private openEventPublishes = new Map<string, EventPublishResolver>()
private ws: WebSocket | undefined
private incomingMessageQueue = new Queue<string>()
private queueRunning = false
private challenge: string | undefined
private authPromise: Promise<string> | undefined
private serial: number = 0
private verifyEvent: Nostr['verifyEvent']
private _WebSocket: typeof WebSocket
constructor(url: string, opts: AbstractRelayConstructorOptions) {
this.url = normalizeURL(url)
this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation || WebSocket
}
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
const relay = new AbstractRelay(url, opts)
await relay.connect()
return relay
}
private closeAllSubscriptions(reason: string) {
for (let [_, sub] of this.openSubs) {
sub.close(reason)
}
this.openSubs.clear()
for (let [_, ep] of this.openEventPublishes) {
ep.reject(new Error(reason))
}
this.openEventPublishes.clear()
for (let [_, cr] of this.openCountRequests) {
cr.reject(new Error(reason))
}
this.openCountRequests.clear()
}
public get connected(): boolean {
return this._connected
}
public async connect(): Promise<void> {
if (this.connectionPromise) return this.connectionPromise
this.challenge = undefined
this.authPromise = undefined
this.connectionPromise = new Promise((resolve, reject) => {
this.connectionTimeoutHandle = setTimeout(() => {
reject('connection timed out')
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection timed out')
}, this.connectionTimeout)
try {
this.ws = new this._WebSocket(this.url)
} catch (err) {
clearTimeout(this.connectionTimeoutHandle)
reject(err)
return
}
this.ws.onopen = () => {
clearTimeout(this.connectionTimeoutHandle)
this._connected = true
resolve()
}
this.ws.onerror = ev => {
clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket error')
if (this._connected) {
this._connected = false
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection errored')
}
}
this.ws.onclose = ev => {
clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket closed')
if (this._connected) {
this._connected = false
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection closed')
}
}
this.ws.onmessage = this._onmessage.bind(this)
})
return this.connectionPromise
}
private async runQueue() {
this.queueRunning = true
while (true) {
if (false === this.handleNext()) {
break
}
await yieldThread()
}
this.queueRunning = false
}
private handleNext(): undefined | false {
const json = this.incomingMessageQueue.dequeue()
if (!json) {
return false
}
const subid = getSubscriptionId(json)
if (subid) {
const so = this.openSubs.get(subid as string)
if (!so) {
// this is an EVENT message, but for a subscription we don't have, so just stop here
return
}
// this will be called only when this message is a EVENT message for a subscription we have
// we do this before parsing the JSON to not have to do that for duplicate events
// since JSON parsing is slow
const id = getHex64(json, 'id')
const alreadyHave = so.alreadyHaveEvent?.(id)
// notify any interested client that the relay has this event
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
so.receivedEvent?.(this, id)
if (alreadyHave) {
// if we had already seen this event we can just stop here
return
}
}
try {
let data = JSON.parse(json)
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
// will naturally be caught by the encompassing try..catch block
switch (data[0]) {
case 'EVENT': {
const so = this.openSubs.get(data[1] as string) as Subscription
const event = data[2] as NostrEvent
if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
so.onevent(event)
}
return
}
case 'COUNT': {
const id: string = data[1]
const payload = data[2] as { count: number }
const cr = this.openCountRequests.get(id) as CountResolver
if (cr) {
cr.resolve(payload.count)
this.openCountRequests.delete(id)
}
return
}
case 'EOSE': {
const so = this.openSubs.get(data[1] as string)
if (!so) return
so.receivedEose()
return
}
case 'OK': {
const id: string = data[1]
const ok: boolean = data[2]
const reason: string = data[3]
const ep = this.openEventPublishes.get(id) as EventPublishResolver
if (ep) {
clearTimeout(ep.timeout)
if (ok) ep.resolve(reason)
else ep.reject(new Error(reason))
this.openEventPublishes.delete(id)
}
return
}
case 'CLOSED': {
const id: string = data[1]
const so = this.openSubs.get(id)
if (!so) return
so.closed = true
so.close(data[2] as string)
return
}
case 'NOTICE':
this.onnotice(data[1] as string)
return
case 'AUTH': {
this.challenge = data[1] as string
return
}
}
} catch (err) {
return
}
}
public async send(message: string) {
if (!this.connectionPromise) throw new SendingOnClosedConnection(message, this.url)
this.connectionPromise.then(() => {
this.ws?.send(message)
})
}
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
const challenge = this.challenge
if (!challenge) throw new Error("can't perform auth, no challenge was received")
if (this.authPromise) return this.authPromise
this.authPromise = new Promise<string>(async (resolve, reject) => {
try {
let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
let timeout = setTimeout(() => {
let ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('auth timed out'))
this.openEventPublishes.delete(evt.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
this.send('["AUTH",' + JSON.stringify(evt) + ']')
} catch (err) {
console.warn('subscribe auth function failed:', err)
}
})
return this.authPromise
}
public async publish(event: Event): Promise<string> {
const ret = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('publish timed out'))
this.openEventPublishes.delete(event.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
})
this.send('["EVENT",' + JSON.stringify(event) + ']')
return ret
}
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
this.serial++
const id = params?.id || 'count:' + this.serial
const ret = new Promise<number>((resolve, reject) => {
this.openCountRequests.set(id, { resolve, reject })
})
this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1))
return ret
}
public subscribe(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
const subscription = this.prepareSubscription(filters, params)
subscription.fire()
return subscription
}
public prepareSubscription(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
this.serial++
const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
const subscription = new Subscription(this, id, filters, params)
this.openSubs.set(id, subscription)
return subscription
}
public close() {
this.closeAllSubscriptions('relay connection closed by us')
this._connected = false
this.ws?.close()
}
// this is the function assigned to this.ws.onmessage
// it's exposed for testing and debugging purposes
public _onmessage(ev: MessageEvent<any>) {
this.incomingMessageQueue.enqueue(ev.data as string)
if (!this.queueRunning) {
this.runQueue()
}
}
}
export class Subscription {
public readonly relay: AbstractRelay
public readonly id: string
public closed: boolean = false
public eosed: boolean = false
public filters: Filter[]
public alreadyHaveEvent: ((id: string) => boolean) | undefined
public receivedEvent: ((relay: AbstractRelay, id: string) => void) | undefined
public onevent: (evt: Event) => void
public oneose: (() => void) | undefined
public onclose: ((reason: string) => void) | undefined
public eoseTimeout: number
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) {
this.relay = relay
this.filters = filters
this.id = id
this.alreadyHaveEvent = params.alreadyHaveEvent
this.receivedEvent = params.receivedEvent
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
this.oneose = params.oneose
this.onclose = params.onclose
this.onevent =
params.onevent ||
(event => {
console.warn(
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
event,
)
})
}
public fire() {
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
// only now we start counting the eoseTimeout
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
}
public receivedEose() {
if (this.eosed) return
clearTimeout(this.eoseTimeoutHandle)
this.eosed = true
this.oneose?.()
}
public close(reason: string = 'closed by caller') {
if (!this.closed && this.relay.connected) {
// if the connection was closed by the user calling .close() we will send a CLOSE message
// otherwise this._open will be already set to false so we will skip this
try {
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
} catch (err) {
if (err instanceof SendingOnClosedConnection) {
/* doesn't matter, it's ok */
} else {
throw err
}
}
this.closed = true
}
this.relay.openSubs.delete(this.id)
this.onclose?.(reason)
}
}
export type SubscriptionParams = {
onevent?: (evt: Event) => void
oneose?: () => void
onclose?: (reason: string) => void
alreadyHaveEvent?: (id: string) => boolean
receivedEvent?: (relay: AbstractRelay, id: string) => void
eoseTimeout?: number
}
export type CountResolver = {
resolve: (count: number) => void
reject: (err: Error) => void
}
export type EventPublishResolver = {
resolve: (reason: string) => void
reject: (err: Error) => void
timeout: ReturnType<typeof setTimeout>
}

View File

@@ -1,61 +0,0 @@
import { run, bench, group, baseline } from 'mitata'
import { initNostrWasm } from 'nostr-wasm'
import { NostrEvent } from './core.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
import { setNostrWasm, verifyEvent } from './wasm.ts'
import { AbstractRelay } from './abstract-relay.ts'
import { Relay as PureRelay } from './relay.ts'
import { alwaysTrue } from './helpers.ts'
// benchmarking relay reads with verifyEvent
const EVENTS = 200
let messages: string[] = []
let baseContent = ''
for (let i = 0; i < EVENTS; i++) {
baseContent += 'a'
}
const secretKey = generateSecretKey()
for (let i = 0; i < EVENTS; i++) {
const tags = []
for (let t = 0; t < i; t++) {
tags.push(['t', 'nada'])
}
const event = { created_at: Math.round(Date.now()) / 1000, kind: 1, content: baseContent.slice(0, EVENTS - i), tags }
const signed = finalizeEvent(event, secretKey)
messages.push(JSON.stringify(['EVENT', '_', signed]))
}
setNostrWasm(await initNostrWasm())
const pureRelay = new PureRelay('wss://pure.com/')
const trustedRelay = new AbstractRelay('wss://trusted.com/', { verifyEvent: alwaysTrue })
const wasmRelay = new AbstractRelay('wss://wasm.com/', { verifyEvent })
const runWith = (relay: AbstractRelay) => async () => {
return new Promise<void>(resolve => {
let received = 0
let sub = relay.prepareSubscription([{}], {
id: '_',
onevent(_: NostrEvent) {
received++
if (received === messages.length - 1) {
resolve()
sub.closed = true
sub.close()
}
},
})
for (let e = 0; e < messages.length; e++) {
relay._onmessage({ data: messages[e] } as any)
}
})
}
group(`relay read ${EVENTS} messages and verify its events`, () => {
baseline('wasm', runWith(wasmRelay))
bench('pure js', runWith(pureRelay))
bench('trusted', runWith(trustedRelay))
})
// actually running the thing
await run()

View File

@@ -1,19 +1,15 @@
const fs = require('node:fs')
const esbuild = require('esbuild')
const { join } = require('path')
#!/usr/bin/env node
const entryPoints = fs
.readdirSync(process.cwd())
const fs = require('fs')
const esbuild = require('esbuild')
const { join } = require('path');
const entryPoints = fs.readdirSync(process.cwd())
.filter(
file =>
file.endsWith('.ts') &&
file !== 'core.ts' &&
file !== 'test-helpers.ts' &&
file !== 'helpers.ts' &&
file !== 'benchmarks.ts' &&
!file.endsWith('.test.ts') &&
fs.statSync(join(process.cwd(), file)).isFile(),
)
(file) =>
file.endsWith(".ts") && !file.endsWith("test.ts") &&
fs.statSync(join(process.cwd(), file)).isFile()
);
let common = {
entryPoints,
@@ -28,7 +24,12 @@ esbuild
format: 'esm',
packages: 'external',
})
.then(() => console.log('esm build success.'))
.then(() => {
const packageJson = JSON.stringify({ type: 'module' })
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.')
})
esbuild
.build({
@@ -37,12 +38,7 @@ esbuild
format: 'cjs',
packages: 'external',
})
.then(() => {
const packageJson = JSON.stringify({ type: 'commonjs' })
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
console.log('cjs build success.')
})
.then(() => console.log('cjs build success.'))
esbuild
.build({

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,18 +0,0 @@
import { test, expect } from 'bun:test'
import { sortEvents } from './core.ts'
test('sortEvents', () => {
const events = [
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
]
const sortedEvents = sortEvents(events)
expect(sortedEvents).toEqual([
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
])
})

65
core.ts
View File

@@ -1,65 +0,0 @@
export interface Nostr {
generateSecretKey(): Uint8Array
getPublicKey(secretKey: Uint8Array): string
finalizeEvent(event: EventTemplate, secretKey: Uint8Array): VerifiedEvent
verifyEvent(event: Event): event is VerifiedEvent
}
/** Designates a verified event signature. */
export const verifiedSymbol = Symbol('verified')
export interface Event {
kind: number
tags: string[][]
content: string
created_at: number
pubkey: string
id: string
sig: string
[verifiedSymbol]?: boolean
}
export type NostrEvent = Event
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
/** An event whose signature has been verified. */
export interface VerifiedEvent extends Event {
[verifiedSymbol]: true
}
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
if (!isRecord(event)) return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] !== 'string') return false
}
}
return true
}
/**
* Sort events in reverse-chronological order by the `created_at` timestamp,
* and then by the event `id` (lexicographically) in case of ties.
* This mutates the array.
*/
export function sortEvents(events: Event[]): Event[] {
return events.sort((a: NostrEvent, b: NostrEvent): number => {
if (a.created_at !== b.created_at) {
return b.created_at - a.created_at
}
return a.id.localeCompare(b.id)
})
}

360
event.test.ts Normal file
View File

@@ -0,0 +1,360 @@
import {
getBlankEvent,
finishEvent,
serializeEvent,
getEventHash,
validateEvent,
verifySignature,
getSignature,
Kind,
verifiedSymbol,
} from './event.ts'
import { getPublicKey } from './keys.ts'
describe('Event', () => {
describe('getBlankEvent', () => {
it('should return a blank event object', () => {
expect(getBlankEvent(255)).toEqual({
kind: 255,
content: '',
tags: [],
created_at: 0,
})
})
it('should return a blank event object with defined kind', () => {
expect(getBlankEvent(Kind.Text)).toEqual({
kind: 1,
content: '',
tags: [],
created_at: 0,
})
})
})
describe('finishEvent', () => {
it('should create a signed event from a template', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const template = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
}
const event = finishEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
it('should serialize a valid event object', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content,
]),
)
})
it('should throw an error for an invalid event object', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115,
pubkey: publicKey, // missing content
}
expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
it('should return the correct event hash', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
it('should return true for a valid event object', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
it('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
it('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115, // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid properties', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey',
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid tags', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifySignature', () => {
it('should return true for a valid event signature', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
const isValid = verifySignature(event)
expect(isValid).toEqual(true)
})
it('should return false for an invalid event signature', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const { [verifiedSymbol]: _, ...event } = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the signature
event.sig = event.sig.replace(/^.{3}/g, '666')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
it('should return false when verifying an event with a different private key', () => {
const privateKey1 = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey2 = '5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
const publicKey2 = getPublicKey(privateKey2)
const { [verifiedSymbol]: _, ...event } = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey1,
)
// verify with different private key
const isValid = verifySignature({
...event,
pubkey: publicKey2,
})
expect(isValid).toEqual(false)
})
it('should return false for an invalid event id', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const { [verifiedSymbol]: _, ...event } = finishEvent(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the id
event.id = event.id.replace(/^.{3}/g, '666')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
})
describe('getSignature', () => {
it('should produce the correct signature for an event object', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const sig = getSignature(unsignedEvent, privateKey)
// verify the signature
const isValid = verifySignature({
...unsignedEvent,
id: getEventHash(unsignedEvent),
sig,
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(true)
})
it('should not sign an event with different private key', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const wrongPrivateKey = 'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const sig = getSignature(unsignedEvent, wrongPrivateKey)
// verify the signature
// @ts-expect-error
const isValid = verifySignature({
...unsignedEvent,
sig,
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(false)
})
})
})

140
event.ts Normal file
View File

@@ -0,0 +1,140 @@
import { schnorr } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { getPublicKey } from './keys.ts'
import { utf8Encoder } from './utils.ts'
/** Designates a verified event signature. */
export const verifiedSymbol = Symbol('verified')
export const Kind = {
Metadata: 0,
Text: 1,
RecommendRelay: 2,
Contacts: 3,
EncryptedDirectMessage: 4,
EventDeletion: 5,
Repost: 6,
Reaction: 7,
BadgeAward: 8,
ChannelCreation: 40,
ChannelMetadata: 41,
ChannelMessage: 42,
ChannelHideMessage: 43,
ChannelMuteUser: 44,
Blank: 255,
Report: 1984,
ZapRequest: 9734,
Zap: 9735,
RelayList: 10002,
ClientAuth: 22242,
NwcRequest: 23194,
HttpAuth: 27235,
ProfileBadge: 30008,
BadgeDefinition: 30009,
Article: 30023,
FileMetadata: 1063,
} as const
export interface Event<K extends number = number> {
kind: K
tags: string[][]
content: string
created_at: number
pubkey: string
id: string
sig: string
[verifiedSymbol]?: boolean
}
export type EventTemplate<K extends number = number> = Pick<Event<K>, 'kind' | 'tags' | 'content' | 'created_at'>
export type UnsignedEvent<K extends number = number> = Pick<
Event<K>,
'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'
>
/** An event whose signature has been verified. */
export interface VerifiedEvent<K extends number = number> extends Event<K> {
[verifiedSymbol]: true
}
export function getBlankEvent(kind: number = 255): EventTemplate {
return {
kind,
content: '',
tags: [],
created_at: 0,
}
}
export function finishEvent<K extends number = number>(t: EventTemplate<K>, privateKey: string): VerifiedEvent<K> {
const event = t as VerifiedEvent<K>
event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event)
event.sig = getSignature(event, privateKey)
event[verifiedSymbol] = true
return event
}
export function serializeEvent(evt: UnsignedEvent<number>): string {
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
}
export function getEventHash(event: UnsignedEvent<number>): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash)
}
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
if (!isRecord(event)) return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
/** Verify the event's signature. This function mutates the event with a `verified` symbol, making it idempotent. */
export function verifySignature<K extends number>(event: Event<K>): event is VerifiedEvent<K> {
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
const hash = getEventHash(event)
if (hash !== event.id) {
return (event[verifiedSymbol] = false)
}
try {
return (event[verifiedSymbol] = schnorr.verify(event.sig, hash, event.pubkey))
} catch (err) {
return (event[verifiedSymbol] = false)
}
}
/** @deprecated Use `getSignature` instead. */
export function signEvent(event: UnsignedEvent<number>, key: string): string {
console.warn(
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.',
)
return getSignature(event, key)
}
/** Calculate the signature for an event. */
export function getSignature(event: UnsignedEvent<number>, key: string): string {
return bytesToHex(schnorr.sign(getEventHash(event), key))
}

View File

@@ -1,4 +1,3 @@
import { test, expect } from 'bun:test'
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
test('match id', () => {

View File

@@ -1,10 +1,9 @@
import { describe, test, expect } from 'bun:test'
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
import { buildEvent } from './test-helpers.ts'
describe('Filter', () => {
describe('matchFilter', () => {
test('should return true when all filter conditions are met', () => {
it('should return true when all filter conditions are met', () => {
const filter = {
ids: ['123', '456'],
kinds: [1, 2, 3],
@@ -13,6 +12,7 @@ describe('Filter', () => {
until: 200,
'#tag': ['value'],
}
const event = buildEvent({
id: '123',
kind: 1,
@@ -20,25 +20,43 @@ describe('Filter', () => {
created_at: 150,
tags: [['tag', 'value']],
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
test('should return false when the event id is not in the filter', () => {
it('should return false when the event id is not in the filter', () => {
const filter = { ids: ['123', '456'] }
const event = buildEvent({ id: '789' })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return false when the event kind is not in the filter', () => {
it('should return true when the event id starts with a prefix', () => {
const filter = { ids: ['22', '00'] }
const event = buildEvent({ id: '001' })
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event kind is not in the filter', () => {
const filter = { kinds: [1, 2, 3] }
const event = buildEvent({ kind: 4 })
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
test('should return false when the event author is not in the filter', () => {
it('should return false when the event author is not in the filter', () => {
const filter = { authors: ['abc', 'def'] }
const event = buildEvent({ pubkey: 'ghi' })
@@ -48,7 +66,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
test('should return false when a tag is not present in the event', () => {
it('should return false when a tag is not present in the event', () => {
const filter = { '#tag': ['value1', 'value2'] }
const event = buildEvent({ tags: [['not_tag', 'value1']] })
@@ -58,7 +76,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
test('should return false when a tag value is not present in the event', () => {
it('should return false when a tag value is not present in the event', () => {
const filter = { '#tag': ['value1', 'value2'] }
const event = buildEvent({ tags: [['tag', 'value3']] })
@@ -68,7 +86,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
test('should return true when filter has tags that is present in the event', () => {
it('should return true when filter has tags that is present in the event', () => {
const filter = { '#tag1': ['foo'] }
const event = buildEvent({
@@ -87,7 +105,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
test('should return false when the event is before the filter since value', () => {
it('should return false when the event is before the filter since value', () => {
const filter = { since: 100 }
const event = buildEvent({ created_at: 50 })
@@ -97,7 +115,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
test('should return true when the timestamp of event is equal to the filter since value', () => {
it('should return true when the timestamp of event is equal to the filter since value', () => {
const filter = { since: 100 }
const event = buildEvent({ created_at: 100 })
@@ -107,7 +125,7 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
test('should return false when the event is after the filter until value', () => {
it('should return false when the event is after the filter until value', () => {
const filter = { until: 100 }
const event = buildEvent({ created_at: 150 })
@@ -117,7 +135,7 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
test('should return true when the timestamp of event is equal to the filter until value', () => {
it('should return true when the timestamp of event is equal to the filter until value', () => {
const filter = { until: 100 }
const event = buildEvent({ created_at: 100 })
@@ -129,18 +147,35 @@ describe('Filter', () => {
})
describe('matchFilters', () => {
test('should return true when at least one filter matches the event', () => {
it('should return true when at least one filter matches the event', () => {
const filters = [
{ ids: ['123'], kinds: [1], authors: ['abc'] },
{ ids: ['456'], kinds: [2], authors: ['def'] },
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
]
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
test('should return true when event matches one or more filters and some have limit set', () => {
it('should return true when at least one prefix matches the event', () => {
const filters = [
{ ids: ['1'], kinds: [1], authors: ['a'] },
{ ids: ['4'], kinds: [2], authors: ['d'] },
{ ids: ['9'], kinds: [3], authors: ['g'] },
]
const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return true when event matches one or more filters and some have limit set', () => {
const filters = [
{ ids: ['123'], limit: 1 },
{ kinds: [1], limit: 2 },
@@ -159,18 +194,21 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
test('should return false when no filters match the event', () => {
it('should return false when no filters match the event', () => {
const filters = [
{ ids: ['123'], kinds: [1], authors: ['abc'] },
{ ids: ['456'], kinds: [2], authors: ['def'] },
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
]
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
test('should return false when event matches none of the filters and some have limit set', () => {
it('should return false when event matches none of the filters and some have limit set', () => {
const filters = [
{ ids: ['123'], limit: 1 },
{ kinds: [1], limit: 2 },
@@ -182,13 +220,15 @@ describe('Filter', () => {
pubkey: 'def',
created_at: 200,
})
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
})
describe('mergeFilters', () => {
test('should merge filters', () => {
it('should merge filters', () => {
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
ids: ['a', 'b', 'c'],
limit: 3,
@@ -200,41 +240,4 @@ describe('Filter', () => {
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
})
})
describe('getFilterLimit', () => {
test('should handle ids', () => {
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
})
test('should count the authors times replaceable kinds', () => {
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
})
test('should handle parameterized replaceable events', () => {
expect(getFilterLimit({ kinds: [30078], authors: ['alex'] })).toEqual(Infinity)
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto'] })).toEqual(1)
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto', 'soapbox'] })).toEqual(2)
expect(getFilterLimit({ kinds: [30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] })).toEqual(4)
expect(
getFilterLimit({ kinds: [30000, 30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] }),
).toEqual(8)
})
test('should return Infinity for authors with regular kinds', () => {
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
})
test('should return Infinity for empty filters', () => {
expect(getFilterLimit({})).toEqual(Infinity)
})
test('empty tags return 0', () => {
expect(getFilterLimit({ '#p': [] })).toEqual(0)
})
})
})

View File

@@ -1,9 +1,8 @@
import { Event } from './core.ts'
import { isAddressableKind, isReplaceableKind } from './kinds.ts'
import { Event } from './event.ts'
export type Filter = {
export type Filter<K extends number = number> = {
ids?: string[]
kinds?: number[]
kinds?: K[]
authors?: string[]
since?: number
until?: number
@@ -12,15 +11,17 @@ export type Filter = {
[key: `#${string}`]: string[] | undefined
}
export function matchFilter(filter: Filter, event: Event): boolean {
export function matchFilter(filter: Filter<number>, event: Event<number>): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
return false
}
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) {
return false
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
return false
}
}
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
return false
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
return false
}
}
for (let f in filter) {
@@ -37,17 +38,15 @@ export function matchFilter(filter: Filter, event: Event): boolean {
return true
}
export function matchFilters(filters: Filter[], event: Event): boolean {
export function matchFilters(filters: Filter<number>[], event: Event<number>): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) {
return true
}
if (matchFilter(filters[i], event)) return true
}
return false
}
export function mergeFilters(...filters: Filter[]): Filter {
let result: Filter = {}
export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
let result: Filter<number> = {}
for (let i = 0; i < filters.length; i++) {
let filter = filters[i]
Object.entries(filter).forEach(([property, values]) => {
@@ -71,35 +70,3 @@ export function mergeFilters(...filters: Filter[]): Filter {
return result
}
/**
* Calculate the intrinsic limit of a filter.
* This function returns a positive integer, or `Infinity` if there is no intrinsic limit.
*/
export function getFilterLimit(filter: Filter): number {
if (filter.ids && !filter.ids.length) return 0
if (filter.kinds && !filter.kinds.length) return 0
if (filter.authors && !filter.authors.length) return 0
for (const [key, value] of Object.entries(filter)) {
if (key[0] === '#' && Array.isArray(value) && !value.length) return 0
}
return Math.min(
// The `limit` property creates an artificial limit.
Math.max(0, filter.limit ?? Infinity),
// There can only be one event per `id`.
filter.ids?.length ?? Infinity,
// Replaceable events are limited by the number of authors and kinds.
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
? filter.authors.length * filter.kinds.length
: Infinity,
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
filter.authors?.length && filter.kinds?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
? filter.authors.length * filter.kinds.length * filter['#d'].length
: Infinity,
)
}

View File

@@ -1,21 +0,0 @@
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
export async function yieldThread() {
return new Promise<void>(resolve => {
const ch = new MessageChannel()
const handler = () => {
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
ch.port1.removeEventListener('message', handler)
resolve()
}
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
ch.port1.addEventListener('message', handler)
ch.port2.postMessage(0)
ch.port1.start()
})
}
export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
t[verifiedSymbol] = true
return true
}

View File

@@ -1,31 +1,28 @@
export * from './pure.ts'
export { Relay } from './relay.ts'
export * from './keys.ts'
export * from './relay.ts'
export * from './event.ts'
export * from './filter.ts'
export { SimplePool } from './pool.ts'
export * from './pool.ts'
export * from './references.ts'
export * as nip04 from './nip04.ts'
export * as nip05 from './nip05.ts'
export * as nip06 from './nip06.ts'
export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.ts'
export * as nip13 from './nip13.ts'
export * as nip17 from './nip17.ts'
export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts'
export * as nip25 from './nip25.ts'
export * as nip26 from './nip26.ts'
export * as nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
export * as nip30 from './nip30.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts'
export * as nip47 from './nip47.ts'
export * as nip54 from './nip54.ts'
export * as nip57 from './nip57.ts'
export * as nip59 from './nip59.ts'
export * as nip98 from './nip98.ts'
export * as kinds from './kinds.ts'
export * as fj from './fakejson.ts'
export * as utils from './utils.ts'

5
jest.config.js Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

View File

@@ -1,50 +0,0 @@
{
"name": "@nostr/tools",
"version": "2.15.0",
"exports": {
".": "./index.ts",
"./core": "./core.ts",
"./pure": "./pure.ts",
"./wasm": "./wasm.ts",
"./kinds": "./kinds.ts",
"./filter": "./filter.ts",
"./abstract-relay": "./abstract-relay.ts",
"./relay": "./relay.ts",
"./abstract-pool": "./abstract-pool.ts",
"./pool": "./pool.ts",
"./references": "./references.ts",
"./nip04": "./nip04.ts",
"./nip05": "./nip05.ts",
"./nip06": "./nip06.ts",
"./nip07": "./nip07.ts",
"./nip10": "./nip10.ts",
"./nip11": "./nip11.ts",
"./nip13": "./nip13.ts",
"./nip17": "./nip17.ts",
"./nip18": "./nip18.ts",
"./nip19": "./nip19.ts",
"./nip21": "./nip21.ts",
"./nip25": "./nip25.ts",
"./nip27": "./nip27.ts",
"./nip28": "./nip28.ts",
"./nip29": "./nip29.ts",
"./nip30": "./nip30.ts",
"./nip39": "./nip39.ts",
"./nip42": "./nip42.ts",
"./nip44": "./nip44.ts",
"./nip46": "./nip46.ts",
"./nip49": "./nip49.ts",
"./nip54": "./nip54.ts",
"./nip57": "./nip57.ts",
"./nip58": "./nip58.ts",
"./nip59": "./nip59.ts",
"./nip75": "./nip75.ts",
"./nip94": "./nip94.ts",
"./nip98": "./nip98.ts",
"./nip99": "./nip99.ts",
"./nipb7": "./nipb7.ts",
"./fakejson": "./fakejson.ts",
"./utils": "./utils.ts"
"./signer": "./signer.ts"
}
}

View File

@@ -1,36 +1,28 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
install-dependencies:
yarn --ignore-engines
build:
rm -rf lib
bun run build.js
tsc
rm -rf lib
node build.js
test:
bun test --timeout 20000
jest
test-only file:
bun test {{file}}
jest {{file}}
publish: build
# publish to jsr first because it is more strict and will catch some errors
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
jsr publish --allow-dirty
git checkout -- package.json
emit-types:
tsc # see tsconfig.json
# then to npm
npm publish
publish: build emit-types
npm publish
format:
eslint --ext .ts --fix *.ts
prettier --write *.ts
eslint --ext .ts --fix *.ts
prettier --write *.ts
lint:
eslint --ext .ts *.ts
prettier --check *.ts
benchmark:
bun build --target=node --outfile=bench.js benchmarks.ts
timeout 60s deno run --allow-read bench.js || true
timeout 60s node bench.js || true
timeout 60s bun run benchmarks.ts || true
rm bench.js
eslint --ext .ts *.ts
prettier --check *.ts

18
keys.test.ts Normal file
View File

@@ -0,0 +1,18 @@
import { generatePrivateKey, getPublicKey } from './keys.ts'
test('private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
})
test('public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key from private key deterministic', () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})

10
keys.ts Normal file
View File

@@ -0,0 +1,10 @@
import { schnorr } from '@noble/curves/secp256k1'
import { bytesToHex } from '@noble/hashes/utils'
export function generatePrivateKey(): string {
return bytesToHex(schnorr.utils.randomPrivateKey())
}
export function getPublicKey(privateKey: string): string {
return bytesToHex(schnorr.getPublicKey(privateKey))
}

View File

@@ -1,6 +1,4 @@
import { expect, test } from 'bun:test'
import { classifyKind, isKind, Repost, ShortTextNote } from './kinds.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
import { classifyKind } from './kinds.ts'
test('kind classification', () => {
expect(classifyKind(1)).toBe('regular')
@@ -20,22 +18,3 @@ test('kind classification', () => {
expect(classifyKind(40000)).toBe('unknown')
expect(classifyKind(255)).toBe('unknown')
})
test('kind type guard', () => {
const privateKey = generateSecretKey()
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
expect(isKind(repostedEvent, ShortTextNote)).toBeTrue()
expect(isKind(repostedEvent, Repost)).toBeFalse()
})

185
kinds.ts
View File

@@ -1,193 +1,40 @@
import { NostrEvent, validateEvent } from './pure.ts'
/** Events are **regular**, which means they're all expected to be stored by relays. */
export function isRegularKind(kind: number): boolean {
function isRegularKind(kind: number) {
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
}
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
export function isReplaceableKind(kind: number): boolean {
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
function isReplaceableKind(kind: number) {
return (10000 <= kind && kind < 20000) || [0, 3].includes(kind)
}
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
export function isEphemeralKind(kind: number): boolean {
function isEphemeralKind(kind: number) {
return 20000 <= kind && kind < 30000
}
/** Events are **addressable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
export function isAddressableKind(kind: number): boolean {
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
function isParameterizedReplaceableKind(kind: number) {
return 30000 <= kind && kind < 40000
}
/** Classification of the event kind. */
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
/** Determine the classification of this kind of event if known, or `unknown`. */
export function classifyKind(kind: number): KindClassification {
function classifyKind(kind: number): KindClassification {
if (isRegularKind(kind)) return 'regular'
if (isReplaceableKind(kind)) return 'replaceable'
if (isEphemeralKind(kind)) return 'ephemeral'
if (isAddressableKind(kind)) return 'parameterized'
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
return 'unknown'
}
export function isKind<T extends number>(event: unknown, kind: T | Array<T>): event is NostrEvent & { kind: T } {
const kindAsArray: number[] = kind instanceof Array ? kind : [kind]
return (validateEvent(event) && kindAsArray.includes(event.kind)) || false
export {
classifyKind,
isEphemeralKind,
isParameterizedReplaceableKind,
isRegularKind,
isReplaceableKind,
type KindClassification,
}
export const Metadata = 0
export type Metadata = typeof Metadata
export const ShortTextNote = 1
export type ShortTextNote = typeof ShortTextNote
export const RecommendRelay = 2
export type RecommendRelay = typeof RecommendRelay
export const Contacts = 3
export type Contacts = typeof Contacts
export const EncryptedDirectMessage = 4
export type EncryptedDirectMessage = typeof EncryptedDirectMessage
export const EventDeletion = 5
export type EventDeletion = typeof EventDeletion
export const Repost = 6
export type Repost = typeof Repost
export const Reaction = 7
export type Reaction = typeof Reaction
export const BadgeAward = 8
export type BadgeAward = typeof BadgeAward
export const Seal = 13
export type Seal = typeof Seal
export const PrivateDirectMessage = 14
export type PrivateDirectMessage = typeof PrivateDirectMessage
export const GenericRepost = 16
export type GenericRepost = typeof GenericRepost
export const ChannelCreation = 40
export type ChannelCreation = typeof ChannelCreation
export const ChannelMetadata = 41
export type ChannelMetadata = typeof ChannelMetadata
export const ChannelMessage = 42
export type ChannelMessage = typeof ChannelMessage
export const ChannelHideMessage = 43
export type ChannelHideMessage = typeof ChannelHideMessage
export const ChannelMuteUser = 44
export type ChannelMuteUser = typeof ChannelMuteUser
export const OpenTimestamps = 1040
export type OpenTimestamps = typeof OpenTimestamps
export const GiftWrap = 1059
export type GiftWrap = typeof GiftWrap
export const FileMetadata = 1063
export type FileMetadata = typeof FileMetadata
export const LiveChatMessage = 1311
export type LiveChatMessage = typeof LiveChatMessage
export const ProblemTracker = 1971
export type ProblemTracker = typeof ProblemTracker
export const Report = 1984
export type Report = typeof Report
export const Reporting = 1984
export type Reporting = typeof Reporting
export const Label = 1985
export type Label = typeof Label
export const CommunityPostApproval = 4550
export type CommunityPostApproval = typeof CommunityPostApproval
export const JobRequest = 5999
export type JobRequest = typeof JobRequest
export const JobResult = 6999
export type JobResult = typeof JobResult
export const JobFeedback = 7000
export type JobFeedback = typeof JobFeedback
export const ZapGoal = 9041
export type ZapGoal = typeof ZapGoal
export const ZapRequest = 9734
export type ZapRequest = typeof ZapRequest
export const Zap = 9735
export type Zap = typeof Zap
export const Highlights = 9802
export type Highlights = typeof Highlights
export const Mutelist = 10000
export type Mutelist = typeof Mutelist
export const Pinlist = 10001
export type Pinlist = typeof Pinlist
export const RelayList = 10002
export type RelayList = typeof RelayList
export const BookmarkList = 10003
export type BookmarkList = typeof BookmarkList
export const CommunitiesList = 10004
export type CommunitiesList = typeof CommunitiesList
export const PublicChatsList = 10005
export type PublicChatsList = typeof PublicChatsList
export const BlockedRelaysList = 10006
export type BlockedRelaysList = typeof BlockedRelaysList
export const SearchRelaysList = 10007
export type SearchRelaysList = typeof SearchRelaysList
export const InterestsList = 10015
export type InterestsList = typeof InterestsList
export const UserEmojiList = 10030
export type UserEmojiList = typeof UserEmojiList
export const DirectMessageRelaysList = 10050
export type DirectMessageRelaysList = typeof DirectMessageRelaysList
export const FileServerPreference = 10096
export type FileServerPreference = typeof FileServerPreference
export const NWCWalletInfo = 13194
export type NWCWalletInfo = typeof NWCWalletInfo
export const LightningPubRPC = 21000
export type LightningPubRPC = typeof LightningPubRPC
export const ClientAuth = 22242
export type ClientAuth = typeof ClientAuth
export const NWCWalletRequest = 23194
export type NWCWalletRequest = typeof NWCWalletRequest
export const NWCWalletResponse = 23195
export type NWCWalletResponse = typeof NWCWalletResponse
export const NostrConnect = 24133
export type NostrConnect = typeof NostrConnect
export const HTTPAuth = 27235
export type HTTPAuth = typeof HTTPAuth
export const Followsets = 30000
export type Followsets = typeof Followsets
export const Genericlists = 30001
export type Genericlists = typeof Genericlists
export const Relaysets = 30002
export type Relaysets = typeof Relaysets
export const Bookmarksets = 30003
export type Bookmarksets = typeof Bookmarksets
export const Curationsets = 30004
export type Curationsets = typeof Curationsets
export const ProfileBadges = 30008
export type ProfileBadges = typeof ProfileBadges
export const BadgeDefinition = 30009
export type BadgeDefinition = typeof BadgeDefinition
export const Interestsets = 30015
export type Interestsets = typeof Interestsets
export const CreateOrUpdateStall = 30017
export type CreateOrUpdateStall = typeof CreateOrUpdateStall
export const CreateOrUpdateProduct = 30018
export type CreateOrUpdateProduct = typeof CreateOrUpdateProduct
export const LongFormArticle = 30023
export type LongFormArticle = typeof LongFormArticle
export const DraftLong = 30024
export type DraftLong = typeof DraftLong
export const Emojisets = 30030
export type Emojisets = typeof Emojisets
export const Application = 30078
export type Application = typeof Application
export const LiveEvent = 30311
export type LiveEvent = typeof LiveEvent
export const UserStatuses = 30315
export type UserStatuses = typeof UserStatuses
export const ClassifiedListing = 30402
export type ClassifiedListing = typeof ClassifiedListing
export const DraftClassifiedListing = 30403
export type DraftClassifiedListing = typeof DraftClassifiedListing
export const Date = 31922
export type Date = typeof Date
export const Time = 31923
export type Time = typeof Time
export const Calendar = 31924
export type Calendar = typeof Calendar
export const CalendarEventRSVP = 31925
export type CalendarEventRSVP = typeof CalendarEventRSVP
export const Handlerrecommendation = 31989
export type Handlerrecommendation = typeof Handlerrecommendation
export const Handlerinformation = 31990
export type Handlerinformation = typeof Handlerinformation
export const CommunityDefinition = 34550
export type CommunityDefinition = typeof CommunityDefinition

View File

@@ -1,41 +1,17 @@
import { test, expect } from 'bun:test'
import crypto from 'node:crypto'
import { encrypt, decrypt } from './nip04.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
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 = generateSecretKey()
let sk2 = generateSecretKey()
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
let ciphertext = await encrypt(bytesToHex(sk1), pk2, 'hello')
expect(await decrypt(bytesToHex(sk2), pk1, ciphertext)).toEqual('hello')
})
test('decrypt message from go-nostr', async () => {
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
let pk1 = getPublicKey(hexToBytes(sk1))
let ciphertext = 'zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ=='
expect(await decrypt(sk2, pk1, ciphertext)).toEqual('nanana')
})
test('decrypt big payload from go-nostr', async () => {
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
let pk1 = getPublicKey(hexToBytes(sk1))
let ciphertext =
'6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=='
let plaintext = ''
for (let i = 0; i < 800; i++) {
plaintext += 'z'
}
expect(await decrypt(sk2, pk1, ciphertext)).toEqual(plaintext)
expect(await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))).toEqual('hello')
})

View File

@@ -1,38 +1,42 @@
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
import { randomBytes } from '@noble/hashes/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { cbc } from '@noble/ciphers/aes'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
// @ts-ignore
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
// @ts-ignore
crypto.subtle = crypto.webcrypto.subtle
}
export async function encrypt(privkey: string, pubkey: string, text: string): Promise<string> {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
let plaintext = utf8Encoder.encode(text)
let ciphertext = cbc(normalizedKey, iv).encrypt(plaintext)
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
let ctb64 = base64.encode(new Uint8Array(ciphertext))
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}`
}
export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
export async function decrypt(privkey: string, pubkey: string, data: string): Promise<string> {
let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)
let iv = base64.decode(ivb64)
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
let ciphertext = base64.decode(ctb64)
let iv = base64.decode(ivb64)
let plaintext = cbc(normalizedKey, iv).decrypt(ciphertext)
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
return utf8Decoder.decode(plaintext)
let text = utf8Decoder.decode(plaintext)
return text
}
function getNormalizedX(key: Uint8Array): Uint8Array {

View File

@@ -1,46 +1,26 @@
import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
test('validate NIP05_REGEX', () => {
expect(NIP05_REGEX.test('_@bob.com.br')).toBeTrue()
expect(NIP05_REGEX.test('bob@bob.com.br')).toBeTrue()
expect(NIP05_REGEX.test('b&b@bob.com.br')).toBeFalse()
expect('b&b@bob.com.br'.match(NIP05_REGEX)).toBeNull()
expect(Array.from('bob@bob.com.br'.match(NIP05_REGEX) || [])).toEqual(['bob@bob.com.br', 'bob', 'bob.com.br', '.br'])
expect(isNip05('bob@bob.com.br')).toBeTrue()
expect(isNip05('b&b@bob.com.br')).toBeFalse()
})
import { useFetchImplementation, queryProfile } from './nip05.ts'
test('fetch nip05 profiles', async () => {
const fetchStub = async (url: string) => ({
status: 200,
async json() {
return {
'https://compile-error.net/.well-known/nostr.json?name=_': {
names: { _: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc' },
},
'https://fiatjaf.com/.well-known/nostr.json?name=_': {
names: { _: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
relays: {
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d': [
'wss://pyramid.fiatjaf.com',
'wss://nos.lol',
],
},
},
}[url]
},
})
useFetchImplementation(fetch)
useFetchImplementation(fetchStub)
let p1 = await queryProfile('jb55.com')
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
let p2 = await queryProfile('compile-error.net')
expect(p2!.pubkey).toEqual('2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc')
let p2 = await queryProfile('jb55@jb55.com')
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
expect(p3!.relays).toEqual([
'wss://relay.nostr.bg',
'wss://nos.lol',
'wss://nostr-verified.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://eden.nostr.land',
'wss://nostr.milou.lol',
])
})

View File

@@ -1,7 +1,5 @@
import { ProfilePointer } from './nip19.ts'
export type Nip05 = `${string}@${string}`
/**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
*
@@ -9,31 +7,23 @@ export type Nip05 = `${string}@${string}`
* - 1: name (optional)
* - 2: domain
*/
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _fetch: any
var _fetch: any
try {
_fetch = fetch
} catch (_) {
null
}
} catch {}
export function useFetchImplementation(fetchImplementation: unknown) {
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
try {
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json()
return json.names
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
return res.names
} catch (_) {
return {}
}
@@ -43,24 +33,49 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
const match = fullname.match(NIP05_REGEX)
if (!match) return null
const [, name = '_', domain] = match
const [_, name = '_', domain] = match
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json()
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
const { names, relays } = parseNIP05Result(await res.json())
const pubkey = json.names[name]
return pubkey ? { pubkey, relays: json.relays?.[pubkey] } : null
const pubkey = names[name]
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
} catch (_e) {
return null
}
}
export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
const res = await queryProfile(nip05)
return res ? res.pubkey === pubkey : false
/** nostr.json result. */
export interface NIP05Result {
names: {
[name: string]: string
}
relays?: {
[pubkey: string]: string[]
}
}
/** Parse the nostr.json and throw if it's not valid. */
function parseNIP05Result(json: any): NIP05Result {
const result: NIP05Result = {
names: {},
}
for (const [name, pubkey] of Object.entries(json.names)) {
if (typeof name === 'string' && typeof pubkey === 'string') {
result.names[name] = pubkey
}
}
if (json.relays) {
result.relays = {}
for (const [pubkey, relays] of Object.entries(json.relays)) {
if (typeof pubkey === 'string' && Array.isArray(relays)) {
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
}
}
}
return result
}

View File

@@ -1,77 +1,14 @@
import { test, expect } from 'bun:test'
import {
privateKeyFromSeedWords,
accountFromSeedWords,
extendedKeysFromSeedWords,
accountFromExtendedKey,
} from './nip06.ts'
import { hexToBytes } from '@noble/hashes/utils'
import { privateKeyFromSeedWords } from './nip06.ts'
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual(hexToBytes('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'))
})
test('generate private key for account 1 from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
expect(privateKey).toEqual(hexToBytes('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b'))
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
})
test('generate private key from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual(hexToBytes('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'))
})
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
})
test('generate private and public key for account 1 from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const { privateKey, publicKey } = accountFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
expect(publicKey).toEqual('13f55f4f01576570ea342eb7d2b611f9dc78f8dc601aeb512011e4e73b90cf0a')
})
test('generate extended keys from mnemonic', () => {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const passphrase = ''
const extendedAccountIndex = 0
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(
mnemonic,
passphrase,
extendedAccountIndex,
)
expect(privateExtendedKey).toBe(
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH',
)
expect(publicExtendedKey).toBe(
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN',
)
})
test('generate account from extended private key', () => {
const xprv =
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH'
const { privateKey, publicKey } = accountFromExtendedKey(xprv)
expect(privateKey).toEqual(hexToBytes('5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731'))
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
})
test('generate account from extended public key', () => {
const xpub =
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN'
const { publicKey } = accountFromExtendedKey(xpub)
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
})

View File

@@ -3,67 +3,11 @@ import { wordlist } from '@scure/bip39/wordlists/english'
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
import { HDKey } from '@scure/bip32'
const DERIVATION_PATH = `m/44'/1237'`
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): Uint8Array {
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key')
return privateKey
}
export function accountFromSeedWords(
mnemonic: string,
passphrase?: string,
accountIndex = 0,
): {
privateKey: Uint8Array
publicKey: string
} {
const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`)
const publicKey = bytesToHex(seed.publicKey!.slice(1))
const privateKey = seed.privateKey
if (!privateKey || !publicKey) {
throw new Error('could not derive key pair')
}
return { privateKey, publicKey }
}
export function extendedKeysFromSeedWords(
mnemonic: string,
passphrase?: string,
extendedAccountIndex = 0,
): {
privateExtendedKey: string
publicExtendedKey: string
} {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let seed = root.derive(`${DERIVATION_PATH}/${extendedAccountIndex}'`)
let privateExtendedKey = seed.privateExtendedKey
let publicExtendedKey = seed.publicExtendedKey
if (!privateExtendedKey && !publicExtendedKey) throw new Error('could not derive extended key pair')
return { privateExtendedKey, publicExtendedKey }
}
export function accountFromExtendedKey(
base58key: string,
accountIndex = 0,
): {
privateKey?: Uint8Array
publicKey: string
} {
let extendedKey = HDKey.fromExtendedKey(base58key)
let version = base58key.slice(0, 4)
let child = extendedKey.deriveChild(0).deriveChild(accountIndex)
let publicKey = bytesToHex(child.publicKey!.slice(1))
if (!publicKey) throw new Error('could not derive public key')
if (version === 'xprv') {
let privateKey = child.privateKey!
if (!privateKey) throw new Error('could not derive private key')
return { privateKey, publicKey }
}
return { publicKey }
return bytesToHex(privateKey)
}
export function generateSeedWords(): string {

View File

@@ -1,14 +0,0 @@
import { EventTemplate, VerifiedEvent } from './core.ts'
export interface WindowNostr {
getPublicKey(): Promise<string>
signEvent(event: EventTemplate): Promise<VerifiedEvent>
nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
}

View File

@@ -1,25 +1,23 @@
import { describe, test, expect } from 'bun:test'
import { parse } from './nip10.ts'
describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
@@ -56,80 +54,33 @@ describe('parse NIP10-referenced events', () => {
relays: [],
},
],
root: {
reply: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: [],
},
reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('modern', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('modern, inverted, author hint', () => {
test('legacy + 3 events', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
'wss://banana.com',
'root',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
],
@@ -144,80 +95,102 @@ describe('parse NIP10-referenced events', () => {
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: ['wss://banana.com', 'wss://goiaba.com'],
relays: [],
},
],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: ['wss://banana.com', 'wss://goiaba.com'],
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
},
reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [],
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('1 event, relay hint from author', () => {
test('legacy + 2 events', () => {
let event = {
tags: [
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
[
'e',
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
'',
'root',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [],
profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: ['wss://banana.com'],
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
reply: {
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: ['wss://banana.com'],
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
root: {
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: ['wss://banana.com'],
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('many p 1 reply', () => {
test('legacy + 1 event', () => {
let event = {
tags: [
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
],
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
],
reply: undefined,
root: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: [],
},
})
})
test.todo('recommended + a lot of events')
test.todo('recommended + 3 events')
test.todo('recommended + 2 events')
test('recommended + 1 event', () => {
let event = {
tags: [
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
[
'e',
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
'wss://relay.mostr.pub',
'reply',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [],
profiles: [
{
@@ -252,13 +225,8 @@ describe('parse NIP10-referenced events', () => {
reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
root: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
root: undefined,
})
})
})

150
nip10.ts
View File

@@ -1,7 +1,7 @@
import type { Event } from './core.ts'
import type { Event } from './event.ts'
import type { EventPointer, ProfilePointer } from './nip19.ts'
export function parse(event: Pick<Event, 'tags'>): {
export type NIP10Result = {
/**
* Pointer to the root of the thread.
*/
@@ -13,80 +13,29 @@ export function parse(event: Pick<Event, 'tags'>): {
reply: EventPointer | undefined
/**
* Pointers to events that may or may not be in the reply chain.
* Pointers to events which may or may not be in the reply chain.
*/
mentions: EventPointer[]
/**
* Pointers to events that were directly quoted.
*/
quotes: EventPointer[]
/**
* List of pubkeys that are involved in the thread in no particular order.
*/
profiles: ProfilePointer[]
} {
const result: ReturnType<typeof parse> = {
}
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
const result: NIP10Result = {
reply: undefined,
root: undefined,
mentions: [],
profiles: [],
quotes: [],
}
let maybeParent: EventPointer | undefined
let maybeRoot: EventPointer | undefined
for (let i = event.tags.length - 1; i >= 0; i--) {
const tag = event.tags[i]
const eTags: string[][] = []
for (const tag of event.tags) {
if (tag[0] === 'e' && tag[1]) {
const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
string,
string,
undefined | string,
undefined | string,
undefined | string,
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
author: eTagAuthor,
}
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (!maybeParent) {
maybeParent = eventPointer
} else {
maybeRoot = eventPointer
}
result.mentions.push(eventPointer)
continue
}
if (tag[0] === 'q' && tag[1]) {
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
result.quotes.push({
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
})
eTags.push(tag)
}
if (tag[0] === 'p' && tag[1]) {
@@ -94,54 +43,49 @@ export function parse(event: Pick<Event, 'tags'>): {
pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [],
})
}
}
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
const eTag = eTags[eTagIndex]
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
}
const isFirstETag = eTagIndex === 0
const isLastETag = eTagIndex === eTags.length - 1
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
}
// get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
if (!result.root) {
result.root = maybeRoot || maybeParent || result.reply
}
if (!result.reply) {
result.reply = maybeParent || result.root
}
// remove root and reply from mentions, inherit relay hints from authors if any
;[result.reply, result.root].forEach(ref => {
if (!ref) return
let idx = result.mentions.indexOf(ref)
if (idx !== -1) {
result.mentions.splice(idx, 1)
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (ref.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
result.mentions.forEach(ref => {
if (ref!.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
})
if (isFirstETag) {
result.root = eventPointer
continue
}
if (isLastETag) {
result.reply = eventPointer
continue
}
result.mentions.push(eventPointer)
}
return result
}

View File

@@ -1,18 +0,0 @@
import { describe, test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, fetchRelayInformation } from './nip11.ts'
// TODO: replace with a mock
describe('requesting relay as for NIP11', () => {
useFetchImplementation(fetch)
test('testing a relay', async () => {
const info = await fetchRelayInformation('wss://nos.lol')
expect(info.name).toEqual('nos.lol')
expect(info.description).toContain('Generally accepts notes, except spammy ones.')
expect(info.supported_nips).toContain(1)
expect(info.supported_nips).toContain(11)
expect(info.supported_nips).toContain(70)
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
})
})

294
nip11.ts
View File

@@ -1,294 +0,0 @@
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any): void {
_fetch = fetchImplementation
}
export async function fetchRelayInformation(url: string): Promise<RelayInformation> {
return (await (
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
headers: { Accept: 'application/nostr+json' },
})
).json()) as RelayInformation
}
/**
* ## Relay Information Document
* Relays may provide server metadata to clients to inform
* them of capabilities, administrative contacts, and
* various server attributes. This is made available as a
* JSON document over HTTP, on the same URI as the relay's
* websocket.
* Any field may be omitted, and clients MUST ignore any
* additional fields they do not understand. Relays MUST
* accept CORS requests by sending
* `Access-Control-Allow-Origin`,
* `Access-Control-Allow-Headers`, and
* `Access-Control-Allow-Methods` headers.
* @param name string identifying relay
* @param description string with detailed information
* @param pubkey administrative contact pubkey
* @param contact: administrative alternate contact
* @param supported_nips a list of NIP numbers supported by
* the relay
* @param software identifying relay software URL
* @param version string version identifier
*/
export interface BasicRelayInformation {
// string identifying relay
name: string
description: string
pubkey: string
contact: string
supported_nips: number[]
software: string
version: string
// limitation?: Limitations<A, P>
}
/**
* * ## Extra Fields
* * ### Server Limitations
* These are limitations imposed by the relay on clients.
* Your client should expect that requests which exceed
* these practical_ limitations are rejected or fail immediately.
* @param max_message_length this is the maximum number of
* bytes for incoming JSON that the relay will attempt to
* decode and act upon. When you send large subscriptions,
* you will be limited by this value. It also effectively
* limits the maximum size of any event. Value is calculated
* from `[` to `]` and is after UTF-8 serialization (so some
* unicode characters will cost 2-3 bytes). It is equal to
* the maximum size of the WebSocket message frame.
* @param max_subscriptions total number of subscriptions
* that may be active on a single websocket connection to
* this relay. It's possible that authenticated clients with
* a (paid) relationship to the relay may have higher limits.
* @param max_filters maximum number of filter values in
* each subscription. Must be one or higher.
* @param max_limit the relay server will clamp each
* filter's `limit` value to this number.
* This means the client won't be able to get more than this
* number of events from a single subscription filter. This
* clamping is typically done silently by the relay, but
* with this number, you can know that there are additional
* results if you narrowed your filter's time range or other
* parameters.
* @param max_subid_length maximum length of subscription id as a
* string.
* @param min_prefix for `authors` and `ids` filters which
* are to match against a hex prefix, you must provide at
* least this many hex digits in the prefix.
* @param max_event_tags in any event, this is the maximum
* number of elements in the `tags` list.
* @param max_content_length maximum number of characters in
* the `content` field of any event. This is a count of
* unicode characters. After serializing into JSON it may be
* larger (in bytes), and is still subject to the
* max_message_length`, if defined.
* @param min_pow_difficulty new events will require at
* least this difficulty of PoW, based on [NIP-13](13.md),
* or they will be rejected by this server.
* @param auth_required this relay requires [NIP-42](42.md)
* authentication to happen before a new connection may
* perform any other action. Even if set to False,
* authentication may be required for specific actions.
* @param restricted_writes: this relay requires some kind
* of condition to be fulfilled in order to accept events
* (not necessarily, but including
* @param payment_required this relay requires payment
* before a new connection may perform any action.
* @param created_at_lower_limit: 'created_at' lower limit
* @param created_at_upper_limit: 'created_at' upper limit
*/
export interface Limitations {
max_message_length: number
max_subscriptions: number
max_filters: number
max_limit: number
max_subid_length: number
min_prefix: number
max_event_tags: number
max_content_length: number
min_pow_difficulty: number
auth_required: boolean
payment_required: boolean
created_at_lower_limit: number
created_at_upper_limit: number
restricted_writes: boolean
}
export interface RetentionDetails {
kinds: (number | number[])[]
time?: number | null
count?: number | null
}
type AnyRetentionDetails = RetentionDetails
/**
* ### Event Retention
* There may be a cost associated with storing data forever,
* so relays may wish to state retention times. The values
* stated here are defaults for unauthenticated users and
* visitors. Paid users would likely have other policies.
* Retention times are given in seconds, with `null`
* indicating infinity. If zero is provided, this means the
* event will not be stored at all, and preferably an error
* will be provided when those are received.
* ```json
{
...
"retention": [
{ "kinds": [0, 1, [5, 7], [40, 49]], "time": 3600 },
{ "kinds": [[40000, 49999]], "time": 100 },
{ "kinds": [[30000, 39999]], "count": 1000 },
{ "time": 3600, "count": 10000 }
]
...
}
```
* @param retention is a list of specifications: each will
* apply to either all kinds, or a subset of kinds. Ranges
* may be specified for the kind field as a tuple of
* inclusive start and end values. Events of indicated kind
* (or all) are then limited to a `count` and/or time
* period.
* It is possible to effectively blacklist Nostr-based
* protocols that rely on a specific `kind` number, by
* giving a retention time of zero for those `kind` values.
* While that is unfortunate, it does allow clients to
* discover servers that will support their protocol quickly
* via a single HTTP fetch.
* There is no need to specify retention times for
* _ephemeral events_ as defined in [NIP-16](16.md) since
* they are not retained.
*/
export interface Retention {
retention: AnyRetentionDetails[]
}
/**
* Some relays may be governed by the arbitrary laws of a
* nation state. This may limit what content can be stored
* in cleartext on those relays. All clients are encouraged
* to use encryption to work around this limitation.
* It is not possible to describe the limitations of each
* country's laws and policies which themselves are
* typically vague and constantly shifting.
* Therefore, this field allows the relay operator to
* indicate which countries' laws might end up being
* enforced on them, and then indirectly on their users'
* content.
* Users should be able to avoid relays in countries they
* don't like, and/or select relays in more favourable
* zones. Exposing this flexibility is up to the client
* software.
* @param relay_countries a list of two-level ISO country
* codes (ISO 3166-1 alpha-2) whose laws and policies may
* affect this relay. `EU` may be used for European Union
* countries.
* Remember that a relay may be hosted in a country which is
* not the country of the legal entities who own the relay,
* so it's very likely a number of countries are involved.
*/
export interface ContentLimitations {
relay_countries: string[]
}
/**
* ### Community Preferences
* For public text notes at least, a relay may try to foster
* a local community. This would encourage users to follow
* the global feed on that relay, in addition to their usual
* individual follows. To support this goal, relays MAY
* specify some of the following values.
* @param language_tags is an ordered list of [IETF
* language
* tags](https://en.wikipedia.org/wiki/IETF_language_tag
* indicating the major languages spoken on the relay.
* @param tags is a list of limitations on the topics to be
* discussed. For example `sfw-only` indicates that only
* "Safe For Work" content is encouraged on this relay. This
* relies on assumptions of what the "work" "community"
* feels "safe" talking about. In time, a common set of tags
* may emerge that allow users to find relays that suit
* their needs, and client software will be able to parse
* these tags easily. The `bitcoin-only` tag indicates that
* any _altcoin_, _"crypto"_ or _blockchain_ comments will
* be ridiculed without mercy.
* @param posting_policy is a link to a human-readable page
* which specifies the community policies for the relay. In
* cases where `sfw-only` is True, it's important to link to
* a page which gets into the specifics of your posting
* policy.
* The `description` field should be used to describe your
* community goals and values, in brief. The
* `posting_policy` is for additional detail and legal
* terms. Use the `tags` field to signify limitations on
* content, or topics to be discussed, which could be
* machine processed by appropriate client software.
*/
export interface CommunityPreferences {
language_tags: string[]
tags: string[]
posting_policy: string
}
export interface Amount {
amount: number
unit: 'msat'
}
export interface PublicationAmount extends Amount {
kinds: number[]
}
export interface Subscription extends Amount {
period: number
}
export interface Fees {
admission: Amount[]
subscription: Subscription[]
publication: PublicationAmount[]
}
/**
* Relays that require payments may want to expose their fee
* schedules.
*/
export interface PayToRelay {
payments_url: string
fees: Fees
}
/**
* A URL pointing to an image to be used as an icon for the
* relay. Recommended to be squared in shape.
*/
export interface Icon {
icon: string
}
export type RelayInformation = BasicRelayInformation &
Partial<Retention> & {
limitation?: Partial<Limitations>
} & Partial<ContentLimitations> &
Partial<CommunityPreferences> &
Partial<PayToRelay> &
Partial<Icon>

View File

@@ -1,15 +1,10 @@
import { test, expect } from 'bun:test'
import { getPow, minePow } from './nip13.ts'
import { Kind } from './event.ts'
test('identifies proof-of-work difficulty', async () => {
;[
['000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358', 21],
['6bf5b4f434813c64b523d2b0e6efe18f3bd0cbbd0a5effd8ece9e00fd2531996', 1],
['00003479309ecdb46b1c04ce129d2709378518588bed6776e60474ebde3159ae', 18],
['01a76167d41add96be4959d9e618b7a35f26551d62c43c11e5e64094c6b53c83', 7],
['ac4f44bae06a45ebe88cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 0],
['0000000000000000006cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 73],
].forEach(([id, diff]) => expect(getPow(id as string)).toEqual(diff as number))
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
const difficulty = getPow(id)
expect(difficulty).toEqual(21)
})
test('mines POW for an event', async () => {
@@ -17,7 +12,7 @@ test('mines POW for an event', async () => {
const event = minePow(
{
kind: 1,
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 0,

View File

@@ -1,19 +1,15 @@
import { bytesToHex } from '@noble/hashes/utils'
import { type UnsignedEvent, type Event } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
import { type UnsignedEvent, type Event, getEventHash } from './event.ts'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(hex: string): number {
let count = 0
for (let i = 0; i < 64; i += 8) {
const nibble = parseInt(hex.substring(i, i + 8), 16)
for (let i = 0; i < hex.length; i++) {
const nibble = parseInt(hex[i], 16)
if (nibble === 0) {
count += 32
count += 4
} else {
count += Math.clz32(nibble)
count += Math.clz32(nibble) - 28
break
}
}
@@ -24,11 +20,13 @@ export function getPow(hex: string): number {
/**
* Mine an event with the desired POW. This function mutates the event.
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
*
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
*/
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
export function minePow<K extends number>(unsigned: UnsignedEvent<K>, difficulty: number): Omit<Event<K>, 'sig'> {
let count = 0
const event = unsigned as Omit<Event, 'sig'>
const event = unsigned as Omit<Event<K>, 'sig'>
const tag = ['nonce', count.toString(), difficulty.toString()]
event.tags.push(tag)
@@ -43,7 +41,7 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
tag[1] = (++count).toString()
event.id = fastEventHash(event)
event.id = getEventHash(event)
if (getPow(event.id) >= difficulty) {
break
@@ -52,9 +50,3 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
return event
}
export function fastEventHash(evt: UnsignedEvent): string {
return bytesToHex(
sha256(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))),
)
}

View File

@@ -1,97 +0,0 @@
import { test, expect } from 'bun:test'
import { getPublicKey } from './pure.ts'
import { decode } from './nip19.ts'
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')
const recipients = [
{ publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' },
{ publicKey: getPublicKey(sk2) }, // No relay URL for this recipient
]
const message = 'Hello, this is a direct message!'
const conversationTitle = 'Private Group Conversation' // Optional
const replyTo = { eventId: 'previousEventId123' } // Optional, for replies
const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
[Symbol('verified')]: true,
}
expect(wrappedEvent.kind).toEqual(expected.kind)
expect(wrappedEvent.tags).toEqual(expected.tags)
})
test('wrapManyEvents', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729560014,
tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo)
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 14,
content: 'Hello, this is a direct message!',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [
['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'],
['e', 'previousEventId123', '', 'reply'],
['subject', 'Private Group Conversation'],
],
}
const result = unwrapEvent(wrappedEvent, sk1)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})

View File

@@ -1,77 +0,0 @@
import { PrivateDirectMessage } from './kinds.ts'
import { EventTemplate, NostrEvent, getPublicKey } from './pure.ts'
import * as nip59 from './nip59.ts'
type Recipient = {
publicKey: string
relayUrl?: string
}
type ReplyTo = {
eventId: string
relayUrl?: string
}
function createEvent(
recipients: Recipient | Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): EventTemplate {
const baseEvent: EventTemplate = {
created_at: Math.ceil(Date.now() / 1000),
kind: PrivateDirectMessage,
tags: [],
content: message,
}
const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]
recipientsArray.forEach(({ publicKey, relayUrl }) => {
baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey])
})
if (replyTo) {
baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply'])
}
if (conversationTitle) {
baseEvent.tags.push(['subject', conversationTitle])
}
return baseEvent
}
export function wrapEvent(
senderPrivateKey: Uint8Array,
recipient: Recipient,
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent {
const event = createEvent(recipient, message, conversationTitle, replyTo)
return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey)
}
export function wrapManyEvents(
senderPrivateKey: Uint8Array,
recipients: Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent[] {
if (!recipients || recipients.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
// wrap the event for the sender and then for each recipient
return [{ publicKey: senderPublicKey }, ...recipients].map(recipient =>
wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo),
)
}
export const unwrapEvent = nip59.unwrapEvent
export const unwrapManyEvents = nip59.unwrapManyEvents

View File

@@ -1,19 +1,18 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
import { finishEvent, Kind } from './event.ts'
import { getPublicKey } from './keys.ts'
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
import { buildEvent } from './test-helpers.ts'
const relayUrl = 'https://relay.example.com'
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const repostedEvent = finalizeEvent(
const repostedEvent = finishEvent(
{
kind: ShortTextNote,
kind: Kind.Text,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
@@ -24,14 +23,14 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
privateKey,
)
test('should create a signed event from a minimal template', () => {
it('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(Repost)
expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
@@ -53,7 +52,7 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
expect(repostedEventFromContent).toEqual(repostedEvent)
})
test('should create a signed event from a filled template', () => {
it('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '' as const,
@@ -62,7 +61,7 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(Repost)
expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', repostedEvent.id, relayUrl],
@@ -82,89 +81,21 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toBeUndefined()
})
})
describe('GenericRepost', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const eventTemplate: EventTemplate = {
content: '',
created_at: 1617932114,
kind: BadgeDefinitionKind,
tags: [
['d', 'badge-id'],
['name', 'Badge Name'],
['description', 'Badge Description'],
['image', 'https://example.com/badge.png', '1024x1024'],
['thumb', 'https://example.com/thumb.png', '100x100'],
['thumb', 'https://example.com/thumb2.png', '200x200'],
],
}
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
test('should create a generic reposted event', () => {
const template = { created_at: 1617932115 }
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(GenericRepost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
['k', '30009'],
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(repostedEvent)
expect(repostedEventFromContent).toEqual(undefined)
})
})
describe('getRepostedEventPointer', () => {
test('should parse an event with only an `e` tag', () => {
it('should parse an event with only an `e` tag', () => {
const event = buildEvent({
kind: Repost,
kind: Kind.Repost,
tags: [['e', 'reposted event id', relayUrl]],
})
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual('reposted event id')
expect(repostedEventPointer!.author).toBeUndefined()
expect(repostedEventPointer!.author).toEqual(undefined)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
})
})
describe('finishRepostEvent', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
test('should create an event with empty content if the reposted event is protected', () => {
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [['-']],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
const template = {
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.content).toBe('')
})
})

View File

@@ -1,6 +1,5 @@
import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
import { Event, finishEvent, Kind, verifySignature } from './event.ts'
import { EventPointer } from './nip19.ts'
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
export type RepostEventTemplate = {
/**
@@ -21,32 +20,23 @@ export type RepostEventTemplate = {
export function finishRepostEvent(
t: RepostEventTemplate,
reposted: Event,
reposted: Event<number>,
relayUrl: string,
privateKey: Uint8Array,
privateKey: string,
): Event {
let kind: Repost | GenericRepost
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
if (reposted.kind === ShortTextNote) {
kind = Repost
} else {
kind = GenericRepost
tags.push(['k', String(reposted.kind)])
}
return finalizeEvent(
return finishEvent(
{
kind,
tags,
content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
kind: Kind.Repost,
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
content: t.content === '' ? '' : JSON.stringify(reposted),
created_at: t.created_at,
},
privateKey,
)
}
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
if (![Repost, GenericRepost].includes(event.kind)) {
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Repost) {
return undefined
}
@@ -79,17 +69,20 @@ export type GetRepostedEventOptions = {
skipVerification?: boolean
}
export function getRepostedEvent(event: Event, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event {
export function getRepostedEvent(
event: Event<number>,
{ skipVerification }: GetRepostedEventOptions = {},
): undefined | Event<number> {
const pointer = getRepostedEventPointer(event)
if (pointer === undefined || event.content === '') {
return undefined
}
let repostedEvent: undefined | Event
let repostedEvent: undefined | Event<number>
try {
repostedEvent = JSON.parse(event.content) as Event
repostedEvent = JSON.parse(event.content) as Event<number>
} catch (error) {
return undefined
}
@@ -98,7 +91,7 @@ export function getRepostedEvent(event: Event, { skipVerification }: GetReposted
return undefined
}
if (!skipVerification && !verifyEvent(repostedEvent)) {
if (!skipVerification && !verifySignature(repostedEvent)) {
return undefined
}

View File

@@ -1,17 +1,19 @@
import { describe, expect, test } from 'bun:test'
import { generatePrivateKey, getPublicKey } from './keys.ts'
import {
decode,
naddrEncode,
neventEncode,
NostrTypeGuard,
nprofileEncode,
npubEncode,
nsecEncode
nrelayEncode,
nsecEncode,
neventEncode,
type AddressPointer,
type ProfilePointer,
EventPointer,
} from './nip19.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
test('encode and decode nsec', () => {
let sk = generateSecretKey()
let sk = generatePrivateKey()
let nsec = nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let { type, data } = decode(nsec)
@@ -20,7 +22,7 @@ test('encode and decode nsec', () => {
})
test('encode and decode npub', () => {
let pk = getPublicKey(generateSecretKey())
let pk = getPublicKey(generatePrivateKey())
let npub = npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let { type, data } = decode(npub)
@@ -29,13 +31,13 @@ test('encode and decode npub', () => {
})
test('encode and decode nprofile', () => {
let pk = getPublicKey(generateSecretKey())
let pk = getPublicKey(generatePrivateKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nprofile = nprofileEncode({ pubkey: pk, relays })
expect(nprofile).toMatch(/nprofile1\w+/)
let { type, data } = decode(nprofile)
expect(type).toEqual('nprofile')
const pointer = data
const pointer = data as ProfilePointer
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
@@ -53,7 +55,7 @@ test('decode nprofile without relays', () => {
})
test('encode and decode naddr', () => {
let pk = getPublicKey(generateSecretKey())
let pk = getPublicKey(generatePrivateKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
pubkey: pk,
@@ -64,7 +66,7 @@ test('encode and decode naddr', () => {
expect(naddr).toMatch(/naddr1\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
@@ -73,64 +75,45 @@ test('encode and decode naddr', () => {
})
test('encode and decode nevent', () => {
let pk = getPublicKey(generateSecretKey())
let pk = getPublicKey(generatePrivateKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nevent = neventEncode({
let naddr = neventEncode({
id: pk,
relays,
kind: 30023,
})
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(naddr).toMatch(/nevent1\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('nevent')
const pointer = data
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(30023)
})
test('encode and decode nevent with kind 0', () => {
let pk = getPublicKey(generateSecretKey())
let pk = getPublicKey(generatePrivateKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let nevent = neventEncode({
let naddr = neventEncode({
id: pk,
relays,
kind: 0,
})
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(naddr).toMatch(/nevent1\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('nevent')
const pointer = data
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(0)
})
test('encode and decode naddr with empty "d"', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
identifier: '',
pubkey: pk,
relays,
kind: 3,
})
expect(naddr).toMatch(/naddr\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data
expect(pointer.identifier).toEqual('')
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(3)
expect(pointer.pubkey).toEqual(pk)
})
test('decode naddr from habla.news', () => {
let { type, data } = decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
)
expect(type).toEqual('naddr')
const pointer = data
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('references')
@@ -142,7 +125,7 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
)
expect(type).toEqual('naddr')
const pointer = data
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
expect(pointer.relays).toContain('wss://nostr.banana.com')
@@ -150,134 +133,11 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
expect(pointer.identifier).toEqual('banana')
})
describe('NostrTypeGuard', () => {
test('isNProfile', () => {
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeTrue()
})
test('isNProfile invalid nprofile', () => {
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxãg')
expect(is).toBeFalse()
})
test('isNProfile with invalid nprofile', () => {
const is = NostrTypeGuard.isNProfile('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNEvent', () => {
const is = NostrTypeGuard.isNEvent(
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
)
expect(is).toBeTrue()
})
test('isNEvent with invalid nevent', () => {
const is = NostrTypeGuard.isNEvent(
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8ãrnc9',
)
expect(is).toBeFalse()
})
test('isNEvent with invalid nevent', () => {
const is = NostrTypeGuard.isNEvent('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeFalse()
})
test('isNAddr', () => {
const is = NostrTypeGuard.isNAddr(
'naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld',
)
expect(is).toBeTrue()
})
test('isNAddr with invalid nadress', () => {
const is = NostrTypeGuard.isNAddr('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNSec', () => {
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeTrue()
})
test('isNSec with invalid nsec', () => {
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juã')
expect(is).toBeFalse()
})
test('isNSec with invalid nsec', () => {
const is = NostrTypeGuard.isNSec('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeFalse()
})
test('isNPub', () => {
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
expect(is).toBeTrue()
})
test('isNPub with invalid npub', () => {
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzãsv8xeh5q92fv33sjgqy4pats')
expect(is).toBeFalse()
})
test('isNPub with invalid npub', () => {
const is = NostrTypeGuard.isNPub('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNote', () => {
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
expect(is).toBeTrue()
})
test('isNote with invalid note', () => {
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
expect(is).toBeFalse()
})
test('isNote with invalid note', () => {
const is = NostrTypeGuard.isNote('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
expect(is).toBeFalse()
})
test('isNcryptsec', () => {
const is = NostrTypeGuard.isNcryptsec(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
)
expect(is).toBeTrue()
})
test('isNcryptsec with invalid ncrytpsec', () => {
const is = NostrTypeGuard.isNcryptsec(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsã8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
)
expect(is).toBeFalse()
})
test('isNcryptsec with invalid ncrytpsec', () => {
const is = NostrTypeGuard.isNcryptsec('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
expect(is).toBeFalse()
})
test('encode and decode nrelay', () => {
let url = 'wss://relay.nostr.example'
let nrelay = nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/)
let { type, data } = decode(nrelay)
expect(type).toEqual('nrelay')
expect(data).toEqual(url)
})

146
nip19.ts
View File

@@ -3,25 +3,7 @@ import { bech32 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
export type NProfile = `nprofile1${string}`
export type NEvent = `nevent1${string}`
export type NAddr = `naddr1${string}`
export type NSec = `nsec1${string}`
export type NPub = `npub1${string}`
export type Note = `note1${string}`
export type Ncryptsec = `ncryptsec1${string}`
export const NostrTypeGuard = {
isNProfile: (value?: string | null): value is NProfile => /^nprofile1[a-z\d]+$/.test(value || ''),
isNEvent: (value?: string | null): value is NEvent => /^nevent1[a-z\d]+$/.test(value || ''),
isNAddr: (value?: string | null): value is NAddr => /^naddr1[a-z\d]+$/.test(value || ''),
isNSec: (value?: string | null): value is NSec => /^nsec1[a-z\d]{58}$/.test(value || ''),
isNPub: (value?: string | null): value is NPub => /^npub1[a-z\d]{58}$/.test(value || ''),
isNote: (value?: string | null): value is Note => /^note1[a-z\d]+$/.test(value || ''),
isNcryptsec: (value?: string | null): value is Ncryptsec => /^ncryptsec1[a-z\d]+$/.test(value || ''),
}
export const Bech32MaxSize = 5000
const Bech32MaxSize = 5000
/**
* Bech32 regex.
@@ -61,56 +43,29 @@ export type AddressPointer = {
relays?: string[]
}
export function decodeNostrURI(nip19code: string): ReturnType<typeof decode> | { type: 'invalid'; data: null } {
try {
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
return decode(nip19code)
} catch (_err) {
return { type: 'invalid', data: null }
}
type Prefixes = {
nprofile: ProfilePointer
nrelay: string
nevent: EventPointer
naddr: AddressPointer
nsec: string
npub: string
note: string
}
export type DecodedNevent = {
type: 'nevent'
data: EventPointer
type DecodeValue<Prefix extends keyof Prefixes> = {
type: Prefix
data: Prefixes[Prefix]
}
export type DecodedNprofile = {
type: 'nprofile'
data: ProfilePointer
}
export type DecodeResult = {
[P in keyof Prefixes]: DecodeValue<P>
}[keyof Prefixes]
export type DecodedNaddr = {
type: 'naddr'
data: AddressPointer
}
export type DecodedNsec = {
type: 'nsec'
data: Uint8Array
}
export type DecodedNpub = {
type: 'npub'
data: string
}
export type DecodedNote = {
type: 'note'
data: string
}
export type DecodedResult = DecodedNevent | DecodedNprofile | DecodedNaddr | DecodedNpub | DecodedNsec | DecodedNote
export function decode(nip19: NEvent): DecodedNevent
export function decode(nip19: NProfile): DecodedNprofile
export function decode(nip19: NAddr): DecodedNaddr
export function decode(nip19: NSec): DecodedNsec
export function decode(nip19: NPub): DecodedNpub
export function decode(nip19: Note): DecodedNote
export function decode(code: string): DecodedResult
export function decode(code: string): DecodedResult {
let { prefix, words } = bech32.decode(code, Bech32MaxSize)
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
export function decode(nip19: string): DecodeResult
export function decode(nip19: string): DecodeResult {
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words))
switch (prefix) {
@@ -164,9 +119,17 @@ export function decode(code: string): DecodedResult {
}
}
case 'nsec':
return { type: prefix, data }
case 'nrelay': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
return {
type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0]),
}
}
case 'nsec':
case 'npub':
case 'note':
return { type: prefix, data: bytesToHex(data) }
@@ -184,6 +147,7 @@ function parseTLV(data: Uint8Array): TLV {
while (rest.length > 0) {
let t = rest[0]
let l = rest[1]
if (!l) throw new Error(`malformed TLV ${t}`)
let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l)
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
@@ -193,16 +157,16 @@ function parseTLV(data: Uint8Array): TLV {
return result
}
export function nsecEncode(key: Uint8Array): NSec {
return encodeBytes('nsec', key)
export function nsecEncode(hex: string): `nsec1${string}` {
return encodeBytes('nsec', hex)
}
export function npubEncode(hex: string): NPub {
return encodeBytes('npub', hexToBytes(hex))
export function npubEncode(hex: string): `npub1${string}` {
return encodeBytes('npub', hex)
}
export function noteEncode(hex: string): Note {
return encodeBytes('note', hexToBytes(hex))
export function noteEncode(hex: string): `note1${string}` {
return encodeBytes('note', hex)
}
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
@@ -210,11 +174,12 @@ function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array):
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
}
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
return encodeBech32(prefix, bytes)
function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Prefix}1${string}` {
let data = hexToBytes(hex)
return encodeBech32(prefix, data)
}
export function nprofileEncode(profile: ProfilePointer): NProfile {
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
let data = encodeTLV({
0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
@@ -222,9 +187,9 @@ export function nprofileEncode(profile: ProfilePointer): NProfile {
return encodeBech32('nprofile', data)
}
export function neventEncode(event: EventPointer): NEvent {
export function neventEncode(event: EventPointer): `nevent1${string}` {
let kindArray
if (event.kind !== undefined) {
if (event.kind != undefined) {
kindArray = integerToUint8Array(event.kind)
}
@@ -238,7 +203,7 @@ export function neventEncode(event: EventPointer): NEvent {
return encodeBech32('nevent', data)
}
export function naddrEncode(addr: AddressPointer): NAddr {
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
let kind = new ArrayBuffer(4)
new DataView(kind).setUint32(0, addr.kind, false)
@@ -251,20 +216,25 @@ export function naddrEncode(addr: AddressPointer): NAddr {
return encodeBech32('naddr', data)
}
export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({
0: [utf8Encoder.encode(url)],
})
return encodeBech32('nrelay', data)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv)
.reverse()
.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)
})
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 concatBytes(...entries)
}

View File

@@ -1,4 +1,3 @@
import { test, expect } from 'bun:test'
import { test as testRegex, parse } from './nip21.ts'
test('test()', () => {

View File

@@ -1,7 +1,7 @@
import { AddressPointer, BECH32_REGEX, decode, EventPointer, ProfilePointer } from './nip19.ts'
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
/** Nostr URI regex, eg `nostr:npub1...` */
export const NOSTR_URI_REGEX: RegExp = new RegExp(`nostr:(${BECH32_REGEX.source})`)
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
/** Test whether the value is a Nostr URI. */
export function test(value: unknown): value is `nostr:${string}` {
@@ -15,31 +15,7 @@ export interface NostrURI {
/** The bech32-encoded data (eg `npub1...`). */
value: string
/** Decoded bech32 string, according to NIP-19. */
decoded:
| {
type: 'nevent'
data: EventPointer
}
| {
type: 'nprofile'
data: ProfilePointer
}
| {
type: 'naddr'
data: AddressPointer
}
| {
type: 'npub'
data: string
}
| {
type: 'nsec'
data: Uint8Array
}
| {
type: 'note'
data: string
}
decoded: DecodeResult
}
/** Parse and decode a Nostr URI. */

View File

@@ -1,16 +1,15 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts'
import { Reaction, ShortTextNote } from './kinds.ts'
import { finishEvent, Kind } from './event.ts'
import { getPublicKey } from './keys.ts'
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
describe('finishReactionEvent + getReactedEventPointer', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const reactedEvent = finalizeEvent(
const reactedEvent = finishEvent(
{
kind: ShortTextNote,
kind: Kind.Text,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
@@ -21,14 +20,14 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
privateKey,
)
test('should create a signed event from a minimal template', () => {
it('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115,
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Reaction)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.tags).toEqual([
['e', 'replied event id'],
['p', 'replied event pubkey'],
@@ -47,7 +46,7 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
test('should create a signed event from a filled template', () => {
it('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '👍',
@@ -56,7 +55,7 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Reaction)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', 'replied event id'],

View File

@@ -1,5 +1,4 @@
import { Event, finalizeEvent } from './pure.ts'
import { Reaction } from './kinds.ts'
import { Event, finishEvent, Kind } from './event.ts'
import type { EventPointer } from './nip19.ts'
@@ -17,13 +16,13 @@ export type ReactionEventTemplate = {
created_at: number
}
export function finishReactionEvent(t: ReactionEventTemplate, reacted: Event, privateKey: Uint8Array): Event {
export function finishReactionEvent(t: ReactionEventTemplate, reacted: Event<number>, privateKey: string): Event {
const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
return finalizeEvent(
return finishEvent(
{
...t,
kind: Reaction,
kind: Kind.Reaction,
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
content: t.content ?? '+',
},
@@ -31,8 +30,8 @@ export function finishReactionEvent(t: ReactionEventTemplate, reacted: Event, pr
)
}
export function getReactedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Reaction) {
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Reaction) {
return undefined
}

101
nip26.test.ts Normal file
View File

@@ -0,0 +1,101 @@
import { getPublicKey, generatePrivateKey } from './keys.ts'
import { getDelegator, createDelegation } from './nip26.ts'
import { buildEvent } from './test-helpers.ts'
test('parse good delegation from NIP', async () => {
expect(
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(
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(
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(
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 = createDelegation(sk1, { pubkey: pk2, kind: 1 })
expect(delegation).toHaveProperty('from', pk1)
expect(delegation).toHaveProperty('to', pk2)
expect(delegation).toHaveProperty('cond', 'kind=1')
let event = buildEvent({
kind: 1,
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
pubkey: pk2,
})
expect(getDelegator(event)).toEqual(pk1)
})

71
nip26.ts Normal file
View File

@@ -0,0 +1,71 @@
import { schnorr } from '@noble/curves/secp256k1'
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
import { getPublicKey } from './keys.ts'
import type { Event } from './event.ts'
export type Parameters = {
pubkey: string // the key to whom the delegation will be given
kind?: number
until?: number // delegation will only be valid until this date
since?: number // 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 = bytesToHex(schnorr.sign(sighash, privateKey))
return {
from: getPublicKey(privateKey),
to: parameters.pubkey,
cond,
sig,
}
}
export function getDelegator(event: Event<number>): 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 (!schnorr.verify(sig, sighash, pubkey)) return null
return pubkey
}

View File

@@ -1,77 +1,67 @@
import { test, expect } from 'bun:test'
import { parse } from './nip27.ts'
import { matchAll, replaceAll } from './nip27.ts'
test('first: parse simple content with 1 url and 1 nostr uri', () => {
const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
const blocks = Array.from(parse(content))
test('matchAll', () => {
const result = matchAll(
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
)
expect(blocks).toEqual([
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
{ type: 'text', text: ' check out my profile:' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: '; and this cool image ' },
{ type: 'image', url: 'https://images.com/image.jpg' },
])
})
test('second: parse content with 3 urls of different types', () => {
const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
http://music.com/song.mp3
and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
const blocks = Array.from(parse(content))
expect(blocks).toEqual([
{ type: 'text', text: ':' },
{ type: 'relay', url: 'wss://oa.ao/' },
{ type: 'text', text: "; this was a relay and now here's a video -> " },
{ type: 'video', url: 'https://videos.com/video.mp4' },
{ type: 'text', text: '! and some music:\n' },
{ type: 'audio', url: 'http://music.com/song.mp3' },
{ type: 'text', text: '\nand a regular link: ' },
{ type: 'url', url: 'https://regular.com/page?ok=true' },
expect([...result]).toEqual([
{
type: 'text',
text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
},
{ type: 'url', url: 'https://ok.com/' },
{ type: 'text', text: '!' },
])
})
test('third: parse complex content with 4 nostr uris and 3 urls', () => {
const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
with a video https://example.com/vid.webm and finally https://example.com/docs`
const blocks = Array.from(parse(content))
expect(blocks).toEqual([
{ type: 'text', text: 'Look at these profiles ' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: ' ' },
{
type: 'reference',
pointer: {
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
relays: ['wss://qwieu.com'],
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
decoded: {
type: 'npub',
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
start: 6,
end: 75,
},
{ type: 'text', text: ' check this event ' },
{
type: 'reference',
pointer: {
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
relays: ['wss://zjbdksa.aswjdkn'],
author: undefined,
kind: undefined,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
},
start: 78,
end: 147,
},
{ type: 'text', text: "\n here's an image " },
{ type: 'image', url: 'https://example.com/pic.png' },
{ type: 'text', text: ' and another profile ' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: '\n with a video ' },
{ type: 'video', url: 'https://example.com/vid.webm' },
{ type: 'text', text: ' and finally ' },
{ type: 'url', url: 'https://example.com/docs' },
])
})
test('matchAll with an invalid nip19', () => {
const result = matchAll(
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
)
expect([...result]).toEqual([
{
decoded: {
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
type: 'note',
},
end: 193,
start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
},
])
})
test('replaceAll', () => {
const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = replaceAll(content, ({ decoded, value }) => {
switch (decoded.type) {
case 'npub':
return '@alex'
case 'note':
return '!1234'
default:
return value
}
})
expect(result).toEqual('Hello @alex!\n\n!1234')
})

214
nip27.ts
View File

@@ -1,169 +1,63 @@
import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
import { decode } from './nip19.ts'
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
export type Block =
| {
type: 'text'
text: string
}
| {
type: 'reference'
pointer: ProfilePointer | AddressPointer | EventPointer
}
| {
type: 'url'
url: string
}
| {
type: 'relay'
url: string
}
| {
type: 'image'
url: string
}
| {
type: 'video'
url: string
}
| {
type: 'audio'
url: string
}
/** Regex to find NIP-21 URIs inside event content. */
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
const noCharacter = /\W/m
const noURLCharacter = /\W |\W$|$|,| /m
/** Match result for a Nostr URI in event content. */
export interface NostrURIMatch extends NostrURI {
/** Index where the URI begins in the event content. */
start: number
/** Index where the URI ends in the event content. */
end: number
}
export function* parse(content: string): Iterable<Block> {
const max = content.length
let prevIndex = 0
let index = 0
while (index < max) {
let u = content.indexOf(':', index)
if (u === -1) {
// reached end
break
}
/** Find and decode all NIP-21 URIs. */
export function* matchAll(content: string): Iterable<NostrURIMatch> {
const matches = content.matchAll(regex())
if (content.substring(u - 5, u) === 'nostr') {
const m = content.substring(u + 60).match(noCharacter)
const end = m ? u + 60 + m.index! : max
try {
let pointer: ProfilePointer | AddressPointer | EventPointer
let { data, type } = decode(content.substring(u + 1, end))
for (const match of matches) {
try {
const [uri, value] = match
switch (type) {
case 'npub':
pointer = { pubkey: data } as ProfilePointer
break
case 'nsec':
case 'note':
// ignore this, treat it as not a valid uri
index = end + 1
continue
default:
pointer = data as any
}
if (prevIndex !== u - 5) {
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
}
yield { type: 'reference', pointer }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid nostr uri
index = u + 1
continue
yield {
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
start: match.index!,
end: match.index! + uri.length,
}
} else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 5 : 4
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
if (
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.jpeg') ||
url.pathname.endsWith('.gif') ||
url.pathname.endsWith('.webp')
) {
yield { type: 'image', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp4') ||
url.pathname.endsWith('.avi') ||
url.pathname.endsWith('.webm') ||
url.pathname.endsWith('.mkv')
) {
yield { type: 'video', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp3') ||
url.pathname.endsWith('.aac') ||
url.pathname.endsWith('.ogg') ||
url.pathname.endsWith('.opus')
) {
yield { type: 'audio', url: url.toString() }
index = end
prevIndex = index
continue
}
yield { type: 'url', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 3 : 2
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid ws url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
yield { type: 'relay', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else {
// ignore this, it is nothing
index = u + 1
continue
} catch (_e) {
// do nothing
}
}
if (prevIndex !== max) {
yield { type: 'text', text: content.substring(prevIndex) }
}
}
/**
* Replace all occurrences of Nostr URIs in the text.
*
* WARNING: using this on an HTML string is potentially unsafe!
*
* @example
* ```ts
* nip27.replaceAll(event.content, ({ decoded, value }) => {
* switch(decoded.type) {
* case 'npub':
* return renderMention(decoded)
* case 'note':
* return renderNote(decoded)
* default:
* return value
* }
* })
* ```
*/
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
return content.replaceAll(regex(), (uri, value: string) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
})
})
}

View File

@@ -1,7 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { getPublicKey } from './pure.ts'
import * as Kind from './kinds.ts'
import { Kind } from './event.ts'
import { getPublicKey } from './keys.ts'
import {
channelCreateEvent,
channelMetadataEvent,
@@ -12,7 +10,7 @@ import {
ChannelMessageEventTemplate,
} from './nip28.ts'
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
describe('NIP-28 Functions', () => {
@@ -22,7 +20,7 @@ describe('NIP-28 Functions', () => {
picture: 'https://example.com/picture.jpg',
}
test('channelCreateEvent should create an event with given template', () => {
it('channelCreateEvent should create an event with given template', () => {
const template = {
content: channelMetadata,
created_at: 1617932115,
@@ -34,7 +32,7 @@ describe('NIP-28 Functions', () => {
expect(event!.pubkey).toEqual(publicKey)
})
test('channelMetadataEvent should create a signed event with given template', () => {
it('channelMetadataEvent should create a signed event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
content: channelMetadata,
@@ -50,8 +48,8 @@ describe('NIP-28 Functions', () => {
expect(typeof event!.sig).toEqual('string')
})
test('channelMessageEvent should create a signed message event with given template', () => {
const template: ChannelMessageEventTemplate = {
it('channelMessageEvent should create a signed message event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
@@ -67,7 +65,7 @@ describe('NIP-28 Functions', () => {
expect(typeof event.sig).toEqual('string')
})
test('channelMessageEvent should create a signed message reply event with given template', () => {
it('channelMessageEvent should create a signed message reply event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
reply_to_channel_message_event_id: 'channel message event id',
@@ -78,25 +76,15 @@ describe('NIP-28 Functions', () => {
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.channel_create_event_id)).toEqual([
'e',
template.channel_create_event_id,
template.relay_url,
'root',
])
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.reply_to_channel_message_event_id)).toEqual([
'e',
template.reply_to_channel_message_event_id as string,
template.relay_url,
'reply',
])
expect(event.tags).toContainEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
expect(event.tags).toContainEqual(['e', template.reply_to_channel_message_event_id, template.relay_url, 'reply'])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
test('channelHideMessageEvent should create a signed event with given template', () => {
it('channelHideMessageEvent should create a signed event with given template', () => {
const template = {
channel_message_event_id: 'channel message event id',
content: { reason: 'Inappropriate content' },
@@ -112,7 +100,7 @@ describe('NIP-28 Functions', () => {
expect(typeof event!.sig).toEqual('string')
})
test('channelMuteUserEvent should create a signed event with given template', () => {
it('channelMuteUserEvent should create a signed event with given template', () => {
const template = {
content: { reason: 'Spamming' },
created_at: 1617932115,

View File

@@ -1,11 +1,4 @@
import { Event, finalizeEvent } from './pure.ts'
import {
ChannelCreation,
ChannelHideMessage,
ChannelMessage,
ChannelMetadata as KindChannelMetadata,
ChannelMuteUser,
} from './kinds.ts'
import { Event, finishEvent, Kind } from './event.ts'
export interface ChannelMetadata {
name: string
@@ -51,7 +44,7 @@ export interface ChannelMuteUserEventTemplate {
tags?: string[][]
}
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: string): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
@@ -61,9 +54,9 @@ export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Ui
return undefined
}
return finalizeEvent(
return finishEvent(
{
kind: ChannelCreation,
kind: Kind.ChannelCreation,
tags: [...(t.tags ?? [])],
content: content,
created_at: t.created_at,
@@ -72,7 +65,10 @@ export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Ui
)
}
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
export const channelMetadataEvent = (
t: ChannelMetadataEventTemplate,
privateKey: string,
): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
@@ -82,9 +78,9 @@ export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey
return undefined
}
return finalizeEvent(
return finishEvent(
{
kind: KindChannelMetadata,
kind: Kind.ChannelMetadata,
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
@@ -93,16 +89,16 @@ export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey
)
}
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: Uint8Array): Event => {
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: string): Event => {
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
if (t.reply_to_channel_message_event_id) {
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
}
return finalizeEvent(
return finishEvent(
{
kind: ChannelMessage,
kind: Kind.ChannelMessage,
tags: [...tags, ...(t.tags ?? [])],
content: t.content,
created_at: t.created_at,
@@ -114,7 +110,7 @@ export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey:
/* "e" tag should be the kind 42 event to hide */
export const channelHideMessageEvent = (
t: ChannelHideMessageEventTemplate,
privateKey: Uint8Array,
privateKey: string,
): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
@@ -125,9 +121,9 @@ export const channelHideMessageEvent = (
return undefined
}
return finalizeEvent(
return finishEvent(
{
kind: ChannelHideMessage,
kind: Kind.ChannelHideMessage,
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
@@ -136,7 +132,10 @@ export const channelHideMessageEvent = (
)
}
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
export const channelMuteUserEvent = (
t: ChannelMuteUserEventTemplate,
privateKey: string,
): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
@@ -146,9 +145,9 @@ export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey
return undefined
}
return finalizeEvent(
return finishEvent(
{
kind: ChannelMuteUser,
kind: Kind.ChannelMuteUser,
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,

614
nip29.ts
View File

@@ -1,614 +0,0 @@
import { AbstractSimplePool } from './abstract-pool.ts'
import { Subscription } from './abstract-relay.ts'
import type { Event, EventTemplate } from './core.ts'
import { fetchRelayInformation, RelayInformation } from './nip11.ts'
import { AddressPointer, decode, NostrTypeGuard } from './nip19.ts'
import { normalizeURL } from './utils.ts'
/**
* Represents a NIP29 group.
*/
export type Group = {
relay: string
metadata: GroupMetadata
admins?: GroupAdmin[]
members?: GroupMember[]
reference: GroupReference
}
/**
* Represents the metadata for a NIP29 group.
*/
export type GroupMetadata = {
id: string
pubkey: string
name?: string
picture?: string
about?: string
isPublic?: boolean
isOpen?: boolean
}
/**
* Represents a NIP29 group reference.
*/
export type GroupReference = {
id: string
host: string
}
/**
* Represents a NIP29 group member.
*/
export type GroupMember = {
pubkey: string
label?: string
}
/**
* Represents a NIP29 group admin.
*/
export type GroupAdmin = {
pubkey: string
label?: string
permissions: GroupAdminPermission[]
}
/**
* Represents the permissions that a NIP29 group admin can have.
*/
export enum GroupAdminPermission {
/** @deprecated use PutUser instead */
AddUser = 'add-user',
EditMetadata = 'edit-metadata',
DeleteEvent = 'delete-event',
RemoveUser = 'remove-user',
/** @deprecated removed from NIP */
AddPermission = 'add-permission',
/** @deprecated removed from NIP */
RemovePermission = 'remove-permission',
/** @deprecated removed from NIP */
EditGroupStatus = 'edit-group-status',
PutUser = 'put-user',
CreateGroup = 'create-group',
DeleteGroup = 'delete-group',
CreateInvite = 'create-invite',
}
/**
* Generates a group metadata event template.
*
* @param group - The group object.
* @returns An event template with the generated group metadata that can be signed later.
*/
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
group.metadata.name && tags.push(['name', group.metadata.name])
group.metadata.picture && tags.push(['picture', group.metadata.picture])
group.metadata.about && tags.push(['about', group.metadata.about])
group.metadata.isPublic && tags.push(['public'])
group.metadata.isOpen && tags.push(['open'])
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39000,
tags,
}
}
/**
* Validates a group metadata event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is valid.
*/
export function validateGroupMetadataEvent(event: Event): boolean {
if (event.kind !== 39000) return false
if (!event.pubkey) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Generates an event template for group admins.
*
* @param group - The group object.
* @param admins - An array of group admins.
* @returns The generated event template with the group admins that can be signed later.
*/
export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
for (const admin of admins) {
tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions])
}
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39001,
tags,
}
}
/**
* Validates a group admins event.
*
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateGroupAdminsEvent(event: Event): boolean {
if (event.kind !== 39001) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
// validate permissions
for (const [tag, _value, _label, ...permissions] of event.tags) {
if (tag !== 'p') continue
for (let i = 0; i < permissions.length; i += 1) {
if (typeof permissions[i] !== 'string') return false
// validate permission name from the GroupAdminPermission enum
if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false
}
}
return true
}
/**
* Generates an event template for a group with its members.
*
* @param group - The group object.
* @param members - An array of group members.
* @returns The generated event template with the group members that can be signed later.
*/
export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
for (const member of members) {
tags.push(['p', member.pubkey, member.label || ''])
}
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39002,
tags,
}
}
/**
* Validates a group members event.
*
* @param event - The event to validate.
* @returns Returns `true` if the event is a valid group members event, `false` otherwise.
*/
export function validateGroupMembersEvent(event: Event): boolean {
if (event.kind !== 39002) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Returns the normalized relay URL based on the provided group reference.
*
* @param groupReference - The group reference object containing the host.
* @returns The normalized relay URL.
*/
export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string {
return normalizeURL(groupReference.host)
}
/**
* Fetches relay information by group reference.
*
* @param groupReference The group reference.
* @returns A promise that resolves to the relay information.
*/
export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise<RelayInformation> {
const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
return fetchRelayInformation(normalizedRelayURL)
}
/**
* Fetches the group metadata event from the specified pool.
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from.
* @param {GroupReference} options.groupReference - The reference to the group.
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay.
* @param {RelayInformation} [options.relayInformation] - The relay information object.
* @returns {Promise<Event>} The group metadata event that can be parsed later to get the group metadata object.
* @throws {Error} If the group is not found on the specified relay.
*/
export async function fetchGroupMetadataEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupMetadataEvent = await pool.get([normalizedRelayURL], {
kinds: [39000],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupMetadataEvent
}
/**
* Parses a group metadata event and returns the corresponding GroupMetadata object.
*
* @param event - The event to parse.
* @returns The parsed GroupMetadata object.
* @throws An error if the group metadata event is invalid.
*/
export function parseGroupMetadataEvent(event: Event): GroupMetadata {
if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event')
const metadata: GroupMetadata = {
id: '',
pubkey: event.pubkey,
}
for (const [tag, value] of event.tags) {
switch (tag) {
case 'd':
metadata.id = value
break
case 'name':
metadata.name = value
break
case 'picture':
metadata.picture = value
break
case 'about':
metadata.about = value
break
case 'public':
metadata.isPublic = true
break
case 'open':
metadata.isOpen = true
break
}
}
return metadata
}
/**
* Fetches the group admins event from the specified pool.
* If the normalizedRelayURL is not provided, it will be obtained from the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from.
* @param {GroupReference} options.groupReference - The reference to the group.
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
* @param {RelayInformation} [options.relayInformation] - The relay information.
* @returns {Promise<Event>} The group admins event that can be parsed later to get the group admins object.
* @throws {Error} If the group admins event is not found on the specified relay.
*/
export async function fetchGroupAdminsEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupAdminsEvent = await pool.get([normalizedRelayURL], {
kinds: [39001],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupAdminsEvent
}
/**
* Parses a group admins event and returns an array of GroupAdmin objects.
*
* @param event - The event to parse.
* @returns An array of GroupAdmin objects.
* @throws Throws an error if the group admins event is invalid.
*/
export function parseGroupAdminsEvent(event: Event): GroupAdmin[] {
if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event')
const admins: GroupAdmin[] = []
for (const [tag, value, label, ...permissions] of event.tags) {
if (tag !== 'p') continue
admins.push({
pubkey: value,
label,
permissions: permissions as GroupAdminPermission[],
})
}
return admins
}
/**
* Fetches the group members event from the specified relay.
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool object.
* @param {GroupReference} options.groupReference - The group reference object.
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
* @param {RelayInformation} [options.relayInformation] - The relay information object.
* @returns {Promise<Event>} The group members event that can be parsed later to get the group members object.
* @throws {Error} If the group members event is not found.
*/
export async function fetchGroupMembersEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupMembersEvent = await pool.get([normalizedRelayURL], {
kinds: [39002],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupMembersEvent
}
/**
* Parses a group members event and returns an array of GroupMember objects.
* @param event - The event to parse.
* @returns An array of GroupMember objects.
* @throws Throws an error if the group members event is invalid.
*/
export function parseGroupMembersEvent(event: Event): GroupMember[] {
if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event')
const members: GroupMember[] = []
for (const [tag, value, label] of event.tags) {
if (tag !== 'p') continue
members.push({
pubkey: value,
label,
})
}
return members
}
/**
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
* If the normalized relay URL is not provided, it will be obtained using the group reference.
* If the relay information is not provided, it will be fetched using the normalized relay URL.
*
* @param {Object} options - The options for loading the group.
* @param {AbstractSimplePool} options.pool - The pool to load the group from.
* @param {GroupReference} options.groupReference - The reference of the group to load.
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use.
* @param {RelayInformation} [options.relayInformation] - The relay information to use.
* @returns {Promise<Group>} A promise that resolves to the loaded group.
*/
export async function loadGroup({
pool,
groupReference,
normalizedRelayURL,
relayInformation,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Group> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const metadata = parseGroupMetadataEvent(metadataEvent)
const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const admins = parseGroupAdminsEvent(adminsEvent)
const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const members = parseGroupMembersEvent(membersEvent)
const group: Group = {
relay: normalizedRelayURL,
metadata,
admins,
members,
reference: groupReference,
}
return group
}
/**
* Loads a group from the specified pool using the provided group code.
*
* @param {AbstractSimplePool} pool - The pool to load the group from.
* @param {string} code - The code representing the group.
* @returns {Promise<Group>} - A promise that resolves to the loaded group.
* @throws {Error} - If the group code is invalid.
*/
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
const groupReference = parseGroupCode(code)
if (!groupReference) throw new Error('invalid group code')
return loadGroup({ pool, groupReference })
}
/**
* Parses a group code and returns a GroupReference object.
*
* @param code The group code to parse.
* @returns A GroupReference object if the code is valid, otherwise null.
*/
export function parseGroupCode(code: string): null | GroupReference {
if (NostrTypeGuard.isNAddr(code)) {
try {
let { data } = decode(code)
let { relays, identifier } = data
if (!relays || relays.length === 0) return null
let host = relays![0]
if (host.startsWith('wss://')) {
host = host.slice(6)
}
return { host, id: identifier }
} catch (err) {
return null
}
} else if (code.split("'").length === 2) {
let spl = code.split("'")
return { host: spl[0], id: spl[1] }
}
return null
}
/**
* Encodes a group reference into a string.
*
* @param gr - The group reference to encode.
* @returns The encoded group reference as a string.
*/
export function encodeGroupReference(gr: GroupReference): string {
const { host, id } = gr
const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
return `${normalizedHost}'${id}`
}
/**
* Subscribes to relay groups metadata events and calls the provided event handler function
* when an event is received.
*
* @param {Object} options - The options for subscribing to relay groups metadata events.
* @param {AbstractSimplePool} options.pool - The pool to subscribe to.
* @param {string} options.relayURL - The URL of the relay.
* @param {Function} options.onError - The error handler function.
* @param {Function} options.onEvent - The event handler function.
* @param {Function} [options.onConnect] - The connect handler function.
* @returns {Function} - A function to close the subscription
*/
export function subscribeRelayGroupsMetadataEvents({
pool,
relayURL,
onError,
onEvent,
onConnect,
}: {
pool: AbstractSimplePool
relayURL: string
onError: (err: Error) => void
onEvent: (event: Event) => void
onConnect?: () => void
}): () => void {
let sub: Subscription
const normalizedRelayURL = normalizeURL(relayURL)
fetchRelayInformation(normalizedRelayURL)
.then(async info => {
const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
onConnect?.()
sub = abstractedRelay.prepareSubscription(
[
{
kinds: [39000],
limit: 50,
authors: [info.pubkey],
},
],
{
onevent(event: Event) {
onEvent(event)
},
},
)
})
.catch(err => {
sub.close()
onError(err)
})
return () => sub.close()
}

View File

@@ -1,33 +0,0 @@
import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip30.ts'
test('matchAll', () => {
const result = matchAll('Hello :blobcat: :disputed: ::joy:joy:')
expect([...result]).toEqual([
{
name: 'blobcat',
shortcode: ':blobcat:',
start: 6,
end: 15,
},
{
name: 'disputed',
shortcode: ':disputed:',
start: 16,
end: 26,
},
])
})
test('replaceAll', () => {
const content = 'Hello :blobcat: :disputed: ::joy:joy:'
const result = replaceAll(content, ({ name }) => {
return `<img src="https://ditto.pub/emoji/${name}.png" />`
})
expect(result).toEqual(
'Hello <img src="https://ditto.pub/emoji/blobcat.png" /> <img src="https://ditto.pub/emoji/disputed.png" /> ::joy:joy:',
)
})

View File

@@ -1,51 +0,0 @@
/** Regex for a single emoji shortcode. */
export const EMOJI_SHORTCODE_REGEX = /:(\w+):/
/** Regex to find emoji shortcodes in content. */
export const regex = (): RegExp => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
/** Represents a Nostr custom emoji. */
export interface CustomEmoji {
/** The matched emoji name with colons. */
shortcode: `:${string}:`
/** The matched emoji name without colons. */
name: string
}
/** Match result for a custom emoji in text content. */
export interface CustomEmojiMatch extends CustomEmoji {
/** Index where the emoji begins in the text content. */
start: number
/** Index where the emoji ends in the text content. */
end: number
}
/** Find all custom emoji shortcodes. */
export function* matchAll(content: string): Iterable<CustomEmojiMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
try {
const [shortcode, name] = match
yield {
shortcode: shortcode as `:${string}:`,
name,
start: match.index!,
end: match.index! + shortcode.length,
}
} catch (_e) {
// do nothing
}
}
}
/** Replace all emoji shortcodes in the content. */
export function replaceAll(content: string, replacer: (match: CustomEmoji) => string): string {
return content.replaceAll(regex(), (shortcode, name) => {
return replacer({
shortcode: shortcode as `:${string}:`,
name,
})
})
}

View File

@@ -1,4 +1,3 @@
import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, validateGithub } from './nip39.ts'

View File

@@ -1,44 +0,0 @@
import { describe, test, expect, jest } from 'bun:test'
import { buildEvent } from './test-helpers.ts'
import { getExpiration, isEventExpired, waitForExpire, onExpire } from './nip40.ts'
describe('getExpiration', () => {
test('returns the expiration as a Date object', () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = getExpiration(event)
expect(result).toEqual(new Date(123000))
})
})
describe('isEventExpired', () => {
test('returns true when the event has expired', () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = isEventExpired(event)
expect(result).toEqual(true)
})
test('returns false when the event has not expired', () => {
const future = Math.floor(Date.now() / 1000) + 10
const event = buildEvent({ tags: [['expiration', future.toString()]] })
const result = isEventExpired(event)
expect(result).toEqual(false)
})
})
describe('waitForExpire', () => {
test('returns a promise that resolves when the event expires', async () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = await waitForExpire(event)
expect(result).toEqual(event)
})
})
describe('onExpire', () => {
test('calls the callback when the event expires', async () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const callback = jest.fn()
onExpire(event, callback)
await new Promise(resolve => setTimeout(resolve, 200))
expect(callback).toHaveBeenCalled()
})
})

View File

@@ -1,49 +0,0 @@
import { Event } from './core.ts'
/** Get the expiration of the event as a `Date` object, if any. */
function getExpiration(event: Event): Date | undefined {
const tag = event.tags.find(([name]) => name === 'expiration')
if (tag) {
return new Date(parseInt(tag[1]) * 1000)
}
}
/** Check if the event has expired. */
function isEventExpired(event: Event): boolean {
const expiration = getExpiration(event)
if (expiration) {
return Date.now() > expiration.getTime()
} else {
return false
}
}
/** Returns a promise that resolves when the event expires. */
async function waitForExpire(event: Event): Promise<Event> {
const expiration = getExpiration(event)
if (expiration) {
const diff = expiration.getTime() - Date.now()
if (diff > 0) {
await sleep(diff)
return event
} else {
return event
}
} else {
throw new Error('Event has no expiration')
}
}
/** Calls the callback when the event expires. */
function onExpire(event: Event, callback: (event: Event) => void): void {
waitForExpire(event)
.then(callback)
.catch(() => {})
}
/** Resolves when the given number of milliseconds have elapsed. */
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export { getExpiration, isEventExpired, waitForExpire, onExpire }

View File

@@ -1,16 +1,26 @@
import { expect, test } from 'bun:test'
import 'websocket-polyfill'
import { makeAuthEvent } from './nip42.ts'
import { Relay } from './relay.ts'
import { MockRelay } from './test-helpers.ts'
import { finishEvent } from './event.ts'
import { generatePrivateKey } from './keys.ts'
import { authenticate } from './nip42.ts'
import { relayInit } from './relay.ts'
test('auth flow', async () => {
const mockRelay = new MockRelay()
const relay = await Relay.connect(mockRelay.url)
const auth = makeAuthEvent(relay.url, 'chachacha')
test('auth flow', () => {
const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect()
const sk = generatePrivateKey()
expect(auth.tags).toHaveLength(2)
expect(auth.tags[0]).toEqual(['relay', mockRelay.url])
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
expect(auth.kind).toEqual(22242)
return new Promise<void>(resolve => {
relay.on('auth', async challenge => {
await expect(
authenticate({
challenge,
relay,
sign: e => finishEvent(e, sk),
}),
).rejects.toBeTruthy()
relay.close()
resolve()
})
})
})

View File

@@ -1,17 +1,32 @@
import { EventTemplate } from './core.ts'
import { ClientAuth } from './kinds.ts'
import { Kind, type EventTemplate, type Event } from './event.ts'
import { Relay } from './relay.ts'
/**
* creates an EventTemplate for an AUTH event to be signed.
* Authenticate via NIP-42 flow.
*
* @example
* const sign = window.nostr.signEvent
* relay.on('auth', challenge =>
* authenticate({ relay, sign, challenge })
* )
*/
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
return {
kind: ClientAuth,
export const authenticate = async ({
challenge,
relay,
sign,
}: {
challenge: string
relay: Relay
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
}): Promise<void> => {
const e: EventTemplate = {
kind: Kind.ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL],
['relay', relay.url],
['challenge', challenge],
],
content: '',
}
return relay.auth(await sign(e))
}

View File

@@ -1,46 +1,75 @@
import { test, expect } from 'bun:test'
import { v2 } from './nip44.js'
import { encrypt, decrypt, utils } from './nip44.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { default as vec } from './nip44.vectors.json' with { type: 'json' }
import { schnorr } from '@noble/curves/secp256k1'
const v2vec = vec.v2
import { v2 as vectors } from './nip44.vectors.json'
import { getPublicKey } from './keys.ts'
test('get_conversation_key', () => {
for (const v of v2vec.valid.get_conversation_key) {
const key = v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key)
}
})
test('encrypt_decrypt', () => {
for (const v of v2vec.valid.encrypt_decrypt) {
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
const key = v2.utils.getConversationKey(hexToBytes(v.sec1), pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key)
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
expect(ciphertext).toEqual(v.payload)
const decrypted = v2.decrypt(ciphertext, key)
test('NIP44: valid_sec', async () => {
for (const v of vectors.valid_sec) {
const pub2 = getPublicKey(v.sec2)
const key = utils.v2.getConversationKey(v.sec1, pub2)
expect(bytesToHex(key)).toEqual(v.shared)
const ciphertext = encrypt(key, v.plaintext, { salt: hexToBytes(v.salt) })
expect(ciphertext).toEqual(v.ciphertext)
const decrypted = decrypt(key, ciphertext)
expect(decrypted).toEqual(v.plaintext)
}
})
test('calc_padded_len', () => {
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
const actual = v2.utils.calcPaddedLen(len)
test('NIP44: valid_pub', async () => {
for (const v of vectors.valid_pub) {
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
expect(bytesToHex(key)).toEqual(v.shared)
const ciphertext = encrypt(key, v.plaintext, { salt: hexToBytes(v.salt) })
expect(ciphertext).toEqual(v.ciphertext)
const decrypted = decrypt(key, ciphertext)
expect(decrypted).toEqual(v.plaintext)
}
})
test('NIP44: invalid', async () => {
for (const v of vectors.invalid) {
expect(() => {
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
const ciphertext = decrypt(key, v.ciphertext)
}).toThrowError(v.note)
}
})
test('NIP44: invalid_conversation_key', async () => {
for (const v of vectors.invalid_conversation_key) {
expect(() => {
const key = utils.v2.getConversationKey(v.sec1, v.pub2)
const ciphertext = encrypt(key, 'a')
}).toThrowError()
}
})
test('NIP44: v1 calcPadding', () => {
for (const [len, shouldBePaddedTo] of vectors.padding) {
const actual = utils.v2.calcPadding(len)
expect(actual).toEqual(shouldBePaddedTo)
}
})
test('decrypt', async () => {
for (const v of v2vec.invalid.decrypt) {
expect(() => v2.decrypt(v.payload, hexToBytes(v.conversation_key))).toThrow(new RegExp(v.note))
}
})
test('get_conversation_key', async () => {
for (const v of v2vec.invalid.get_conversation_key) {
expect(() => v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)).toThrow(
/(Point is not on curve|Cannot find square root)/,
)
}
})
// To re-generate vectors and produce new ones:
// Create regen.mjs with this content:
// import {getPublicKey, nip44} from './lib/esm/nostr.mjs'
// import {bytesToHex, hexToBytes} from '@noble/hashes/utils'
// import vectors from './nip44.vectors.json' assert { type: "json" };
// function genVectors(v) {
// const pub2 = v.pub2 ?? getPublicKey(v.sec2);
// let sharedKey = nip44.utils.v2.getConversationKey(v.sec1, pub2)
// let ciphertext = nip44.encrypt(sharedKey, v.plaintext, { salt: hexToBytes(v.salt) })
// console.log({
// sec1: v.sec1,
// pub2: pub2,
// sharedKey: bytesToHex(sharedKey),
// salt: v.salt,
// plaintext: v.plaintext,
// ciphertext
// })
// }
// for (let v of vectors.valid_sec) genVectors(v);
// for (let v of vectors.valid_pub) genVectors(v);
// const padded = concatBytes(utils.v2.pad(plaintext), new Uint8Array(250))
// const mac = randomBytes(32)

182
nip44.ts
View File

@@ -1,127 +1,107 @@
import { chacha20 } from '@noble/ciphers/chacha'
import { equalBytes } from '@noble/ciphers/utils'
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
import { hkdf } from '@noble/hashes/hkdf'
import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha256'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts'
const minPlaintextSize = 0x0001 // 1b msg => padded to 32b
const maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
export const utils = {
v2: {
maxPlaintextSize: 65536 - 128, // 64kb - 128
minCiphertextSize: 100, // should be 128 if min padded to 32b: base64(1+32+32+32)
maxCiphertextSize: 102400, // 100kb
export function getConversationKey(privkeyA: Uint8Array, pubkeyB: string): Uint8Array {
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
return hkdf_extract(sha256, sharedX, 'nip44-v2')
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
const key = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB)
return key.subarray(1, 33)
},
getMessageKeys(conversationKey: Uint8Array, salt: Uint8Array) {
const keys = hkdf(sha256, conversationKey, salt, 'nip44-v2', 76)
return {
encryption: keys.subarray(0, 32),
nonce: keys.subarray(32, 44),
auth: keys.subarray(44, 76),
}
},
calcPadding(len: number): number {
if (!Number.isSafeInteger(len) || len < 0) throw new Error('expected positive integer')
if (len <= 32) return 32
const nextpower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
const chunk = nextpower <= 256 ? 32 : nextpower / 8
return chunk * (Math.floor((len - 1) / chunk) + 1)
},
pad(unpadded: string): Uint8Array {
const unpaddedB = utf8Encoder.encode(unpadded)
const len = unpaddedB.length
if (len < 1 || len >= utils.v2.maxPlaintextSize) throw new Error('invalid plaintext length: must be between 1b and 64KB')
const paddedLen = utils.v2.calcPadding(len)
const zeros = new Uint8Array(paddedLen - len)
const lenBuf = new Uint8Array(2)
new DataView(lenBuf.buffer).setUint16(0, len)
return concatBytes(lenBuf, unpaddedB, zeros)
},
unpad(padded: Uint8Array): string {
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
const unpadded = padded.subarray(2, 2 + unpaddedLen)
if (
unpaddedLen === 0 ||
unpadded.length !== unpaddedLen ||
padded.length !== 2 + utils.v2.calcPadding(unpaddedLen)
)
throw new Error('invalid padding')
return utf8Decoder.decode(unpadded)
},
},
}
function getMessageKeys(
conversationKey: Uint8Array,
nonce: Uint8Array,
): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } {
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
return {
chacha_key: keys.subarray(0, 32),
chacha_nonce: keys.subarray(32, 44),
hmac_key: keys.subarray(44, 76),
}
export function encrypt(
key: Uint8Array,
plaintext: string,
options: { salt?: Uint8Array; version?: number } = {},
): string {
const version = options.version ?? 2
if (version !== 2) throw new Error('unknown encryption version ' + version)
const salt = options.salt ?? randomBytes(32)
ensureBytes(salt, 32)
const keys = utils.v2.getMessageKeys(key, salt)
const padded = utils.v2.pad(plaintext)
const ciphertext = chacha20(keys.encryption, keys.nonce, padded)
const mac = hmac(sha256, keys.auth, ciphertext)
return base64.encode(concatBytes(new Uint8Array([version]), salt, ciphertext, mac))
}
function calcPaddedLen(len: number): number {
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
if (len <= 32) return 32
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
const chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * (Math.floor((len - 1) / chunk) + 1)
}
export function decrypt(key: Uint8Array, ciphertext: string): string {
const u = utils.v2
ensureBytes(key, 32)
function writeU16BE(num: number): Uint8Array {
if (!Number.isSafeInteger(num) || num < minPlaintextSize || num > maxPlaintextSize)
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
const arr = new Uint8Array(2)
new DataView(arr.buffer).setUint16(0, num, false)
return arr
}
const clen = ciphertext.length
if (clen < u.minCiphertextSize || clen >= u.maxCiphertextSize) throw new Error('invalid ciphertext length: ' + clen)
function pad(plaintext: string): Uint8Array {
const unpadded = utf8Encoder.encode(plaintext)
const unpaddedLen = unpadded.length
const prefix = writeU16BE(unpaddedLen)
const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen)
return concatBytes(prefix, unpadded, suffix)
}
function unpad(padded: Uint8Array): string {
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
const unpadded = padded.subarray(2, 2 + unpaddedLen)
if (
unpaddedLen < minPlaintextSize ||
unpaddedLen > maxPlaintextSize ||
unpadded.length !== unpaddedLen ||
padded.length !== 2 + calcPaddedLen(unpaddedLen)
)
throw new Error('invalid padding')
return utf8Decoder.decode(unpadded)
}
function hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array): Uint8Array {
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
const combined = concatBytes(aad, message)
return hmac(sha256, key, combined)
}
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
// plaintext: 1b to 0xffff
// padded plaintext: 32b to 0xffff
// ciphertext: 32b+2 to 0xffff+2
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
// compressed payload (base64): 132b to 87472b
function decodePayload(payload: string): { nonce: Uint8Array; ciphertext: Uint8Array; mac: Uint8Array } {
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
const plen = payload.length
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
if (payload[0] === '#') throw new Error('unknown encryption version')
if (ciphertext[0] === '#') throw new Error('unknown encryption version')
let data: Uint8Array
try {
data = base64.decode(payload)
data = base64.decode(ciphertext)
} catch (error) {
throw new Error('invalid base64: ' + (error as any).message)
}
const dlen = data.length
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
const vers = data[0]
const vers = data.subarray(0, 1)[0]
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
return {
nonce: data.subarray(1, 33),
ciphertext: data.subarray(33, -32),
mac: data.subarray(-32),
}
}
export function encrypt(plaintext: string, conversationKey: Uint8Array, nonce: Uint8Array = randomBytes(32)): string {
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
const padded = pad(plaintext)
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
const mac = hmacAad(hmac_key, ciphertext, nonce)
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
}
const salt = data.subarray(1, 33)
const ciphertext_ = data.subarray(33, -32)
const mac = data.subarray(-32)
export function decrypt(payload: string, conversationKey: Uint8Array): string {
const { nonce, ciphertext, mac } = decodePayload(payload)
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
const calculatedMac = hmacAad(hmac_key, ciphertext, nonce)
const keys = u.getMessageKeys(key, salt)
const calculatedMac = hmac(sha256, keys.auth, ciphertext_)
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
return unpad(padded)
}
export const v2 = {
utils: {
getConversationKey,
calcPaddedLen,
},
encrypt,
decrypt,
const padded = chacha20(keys.encryption, keys.nonce, ciphertext_)
return u.unpad(padded)
}

View File

@@ -1,605 +1,245 @@
{
"v2": {
"valid": {
"get_conversation_key": [
{
"sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
"pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
"conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"
},
{
"sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
"pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
"conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b"
},
{
"sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
"pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
"conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"
},
{
"sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f",
"pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585",
"conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"
},
{
"sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34",
"pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60",
"conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442"
},
{
"sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2",
"pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65",
"conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d"
},
{
"sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082",
"pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b",
"conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db"
},
{
"sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1",
"pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de",
"conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf"
},
{
"sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6",
"pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0",
"conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e"
},
{
"sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64",
"pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065",
"conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1"
},
{
"sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af",
"pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e",
"conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6"
},
{
"sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9",
"pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92",
"conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795"
},
{
"sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a",
"pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4",
"conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946"
},
{
"sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a",
"pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b",
"conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d"
},
{
"sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358",
"pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c",
"conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92"
},
{
"sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc",
"pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70",
"conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c"
},
{
"sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa",
"pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc",
"conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b"
},
{
"sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6",
"pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6",
"conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848"
},
{
"sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98",
"pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198",
"conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc"
},
{
"sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a",
"pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8",
"conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6"
},
{
"sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc",
"pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41",
"conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088"
},
{
"sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6",
"pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd",
"conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753"
},
{
"sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d",
"pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c",
"conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b"
},
{
"sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4",
"pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda",
"conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79"
},
{
"sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe",
"pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3",
"conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b"
},
{
"sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9",
"pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18",
"conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6"
},
{
"sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907",
"pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828",
"conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6"
},
{
"sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df",
"pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112",
"conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74"
},
{
"sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3",
"pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae",
"conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036"
},
{
"sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e",
"pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70",
"conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409"
},
{
"sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344",
"pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d",
"conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e"
},
{
"sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c",
"pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d",
"conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba",
"note": "sec1 = n-2, pub2: random, 0x02"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
"conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43",
"note": "sec1 = 2, pub2: rand"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e",
"note": "sec1 == pub2"
}
],
"get_message_keys": {
"conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54",
"keys": [
{
"nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72",
"chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
"chacha_nonce": "c4ad129bb01180c0933a160c",
"hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
},
{
"nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101",
"chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92",
"chacha_nonce": "22925e920cee4a50a478be90",
"hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30"
},
{
"nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650",
"chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf",
"chacha_nonce": "d3594987af769a52904656ac",
"hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745"
},
{
"nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967",
"chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be",
"chacha_nonce": "50bb859aa2dde938cc49ec7a",
"hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"
},
{
"nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d",
"chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b",
"chacha_nonce": "400224ba67fc2f1b76736916",
"hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b"
},
{
"nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367",
"chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110",
"chacha_nonce": "021905b1ea3afc17cb9bf96f",
"hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81"
},
{
"nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17",
"chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1",
"chacha_nonce": "72f69a5a5f795465cee59da8",
"hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9"
},
{
"nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf",
"chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6",
"chacha_nonce": "6e69be92d61c04a276021565",
"hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b"
},
{
"nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976",
"chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef",
"chacha_nonce": "3dda53569cfcb7fac1805c35",
"hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e"
},
{
"nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb",
"chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85",
"chacha_nonce": "65064239186e50304cc0f156",
"hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a"
},
{
"nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814",
"chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45",
"chacha_nonce": "2e605e1d825a3eaeb613db9c",
"hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec"
},
{
"nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03",
"chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4",
"chacha_nonce": "cbb2530ea653766e5a37a83a",
"hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178"
},
{
"nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff",
"chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07",
"chacha_nonce": "ef649fcf335583e8d45e3c2e",
"hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1"
},
{
"nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c",
"chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a",
"chacha_nonce": "67803605a7e5010d0f63f8c8",
"hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8"
},
{
"nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58",
"chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77",
"chacha_nonce": "4e62a0073087ed808be62469",
"hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7"
},
{
"nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9",
"chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a",
"chacha_nonce": "a963ed7dc29b7b1046820a1d",
"hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2"
},
{
"nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49",
"chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd",
"chacha_nonce": "329bb3024461e84b2e1c489b",
"hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec"
},
{
"nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53",
"chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325",
"chacha_nonce": "653d759042b85194d4d8c0a7",
"hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba"
},
{
"nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c",
"chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3",
"chacha_nonce": "b822e2c959df32b3cb772a7c",
"hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420"
},
{
"nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5",
"chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba",
"chacha_nonce": "5f72c5b87c590bcd0f93b305",
"hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918"
},
{
"nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa",
"chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94",
"chacha_nonce": "409a7654c0e4bf8c2c6489be",
"hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4"
},
{
"nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140",
"chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8",
"chacha_nonce": "1b7fd2534f015a8f795d8f32",
"hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0"
},
{
"nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d",
"chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056",
"chacha_nonce": "6ffe4f1971b904a1b1a81b99",
"hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4"
},
{
"nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301",
"chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0",
"chacha_nonce": "a9b5a67d081d3b42e737d16f",
"hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e"
},
{
"nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3",
"chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90",
"chacha_nonce": "263830a065af33d9c6c5aa1f",
"hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17"
},
{
"nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9",
"chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9",
"chacha_nonce": "d0f9d2a1ace6c758f594ffdd",
"hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a"
},
{
"nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83",
"chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe",
"chacha_nonce": "ccdaad5b3b7645be430992eb",
"hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4"
},
{
"nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a",
"chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2",
"chacha_nonce": "ed02dece5fc3a186f123420b",
"hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e"
},
{
"nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183",
"chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975",
"chacha_nonce": "7d9acb0fdc174e3c220f40de",
"hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7"
},
{
"nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225",
"chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd",
"chacha_nonce": "26b450612ca5e905b937e147",
"hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3"
},
{
"nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4",
"chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9",
"chacha_nonce": "4a296a1fb0048e5020d3b129",
"hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806"
},
{
"nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036",
"chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27",
"chacha_nonce": "a8188daff807a1182200b39d",
"hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef"
}
]
"valid_sec": [
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
"shared": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
"salt": "0000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "a",
"ciphertext": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABYNpT9ESckRbRUY7bUF5P+1rObpA4BNoksAUQ8myMDd9/37W/J2YHvBpRjvy9uC0+ovbpLc0WLaMFieqAMdIYqR14",
"note": "sk1 = 1, sk2 = random, 0x02"
},
"calc_padded_len": [
[16, 32],
[32, 32],
[33, 64],
[37, 64],
[45, 64],
[49, 64],
[64, 64],
[65, 96],
[100, 128],
[111, 128],
[200, 224],
[250, 256],
[320, 320],
[383, 384],
[384, 384],
[400, 448],
[500, 512],
[512, 512],
[515, 640],
[700, 768],
[800, 896],
[900, 1024],
[1020, 1024],
[65536, 65536]
],
"encrypt_decrypt": [
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "a",
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "f00000000000000000000000000000f00000000000000000000000000000000f",
"plaintext": "🍕🫃",
"payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"
},
{
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
"conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45",
"nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
"plaintext": "表ポあA鷗Œé逍Üߪąñ丂㐀𠀀",
"payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="
},
{
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
"conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b",
"nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
"plaintext": "ability🤝的 ȺȾ",
"payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD"
},
{
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
"conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8",
"nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
"plaintext": "pepper👀їжак",
"payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+"
},
{
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
"conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5",
"nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
"plaintext": "( ͡° ͜ʖ ͡°)",
"payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
"payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
"plaintext": "الكل في المجمو عة (5)",
"payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
"payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
"plaintext": "🙈 🙉 🙊 0⃣ 1⃣ 2⃣ 3⃣ 4⃣ 5⃣ 6⃣ 7⃣ 8⃣ 9⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
"payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="
}
],
"encrypt_decrypt_long_msg": [
{
"conversation_key": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
"nonce": "a000000000000000000000000000000000000000000000000000000000000001",
"letter": "ф",
"repeat": 65535,
"payload_checksum_sha256": "",
"note": "фффф... (65535 times)"
}
]
},
"invalid": {
"encrypt_msg_lengths": [0, 65536, 100000, 10000000],
"decrypt_msg_lengths": [0, 1, 2, 5, 10, 20, 32, 48, 64],
"get_conversation_key": [
{
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 higher than curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 is 0"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"note": "pub2 is invalid, no sqrt, all-ff"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 == curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "pub2 is invalid, no sqrt"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "0000000000000000000000000000000000000000000000000000000000000000",
"note": "pub2 is point of order 3 on twist"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d",
"note": "pub2 is point of order 13 on twist"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f",
"note": "pub2 is point of order 3319 on twist"
}
],
"decrypt": [
{
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o b l e",
"payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp",
"note": "unknown encryption version"
},
{
"conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481",
"nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
"plaintext": "⚠️",
"payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz",
"note": "unknown encryption version 0"
},
{
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o s t r",
"payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq",
"note": "invalid base64"
},
{
"conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c",
"nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
"plaintext": "¯\\_(ツ)_/¯",
"payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"note": "invalid MAC"
},
{
"conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957",
"nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
"plaintext": "🥎",
"payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR",
"note": "invalid MAC"
},
{
"conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214",
"nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
"plaintext": "elliptic-curve cryptography",
"payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA",
"note": "invalid padding"
},
{
"conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496",
"nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
"plaintext": "noble",
"payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16",
"note": "invalid padding"
},
{
"conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f",
"nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
"plaintext": "censorship-resistant and global social network",
"payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z",
"note": "invalid padding"
}
]
}
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
"shared": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
"salt": "f00000000000000000000000000000f00000000000000000000000000000000f",
"plaintext": "🍕🫃",
"ciphertext": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPKY68BwdF7PIT205jBoaZHSs7OMpKsULW5F5ClOJWiy6XjZy7s2v85KugYmbBKgEC2LytbXbxkr7Jpgfk529K3/pP",
"note": "sk1 = 1, sk2 = random, 0x02"
},
{
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
"shared": "94da47d851b9c1ed33b3b72f35434f56aa608d60e573e9c295f568011f4f50a4",
"salt": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
"plaintext": "表ポあA鷗Œé逍Üߪąñ丂㐀𠀀",
"ciphertext": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7yuU7XwJ8wCYUrq4aXX86HLnkMx7fPFvNeMk0uek9ma01magfEBIf+vJvZdWKiv48eUu9Cv31plAJsH6kSIsGc5TVYBYipkrQUNRxxJA15QT+uCURF96v3XuSS0k2Pf108AI=",
"note": "unicode-heavy string"
},
{
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
"shared": "ab99c122d4586cdd5c813058aa543d0e7233545dbf6874fc34a3d8d9a18fbbc3",
"salt": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
"plaintext": "ability🤝的 ȺȾ",
"ciphertext": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvPSc+7YCIFTmGk5OLuh1nhl6TvID7sGKLFUCWRW1eRfV/0a7sT46N3nTQzD7IE67zLWrYqGnE+0DDNz6sJ4hAaFrT"
},
{
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
"shared": "a449f2a85c6d3db0f44c64554a05d11a3c0988d645e4b4b2592072f63662f422",
"salt": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
"plaintext": "pepper👀їжак",
"ciphertext": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGx1TkkMfiHJxEeSdQ/4Rlaghn0okDCNYLihBsHrDzBsNRC27APmH9mmZcpcg66Mb0exH9V5/lLBWdQW+fcY9GpvXv0"
},
{
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
"shared": "decde9938ffcb14fa7ff300105eb1bf239469af9baf376e69755b9070ae48c47",
"salt": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
"plaintext": "( ͡° ͜ʖ ͡°)",
"ciphertext": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHiR8Hljs6Nl/XsNDAmCz6U1Z3NUGhbCtczc3wXXxDzFkjjMimxsf/74OEzu7LphUadM9iSWvVKPrNXY7lTD0B2muz"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"shared": "c6f2fde7aa00208c388f506455c31c3fa07caf8b516d43bf7514ee19edcda994",
"salt": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
"ciphertext": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItohfde4vHVRHUupr+Glh9JW4f9EY+w795hvRZbixs0EQgDZ7zwLlymVQI3NNvMqvemQzHUA1I5+9gSu8XSMwX9gDCUAjUJtntCkRt9+tjdy2Wa2ZrDYqCvgirvzbJTIC69Ve3YbKuiTQCKtVi0PA5ZLqVmnkHPIqfPqDOGj/a3dvJVzGSgeijcIpjuEgFF54uirrWvIWmTBDeTA+tlQzJHpB2wQnUndd2gLDb8+eKFUZPBifshD3WmgWxv8wRv6k3DeWuWEZQ70Z+YDpgpeOzuzHj0MDBwMAlY8Qq86Rx6pxY76PLDDfHh3rE2CHJEKl2MhDj7pGXao2o633vSRd9ueG8W"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"shared": "c6f2fde7aa00208c388f506455c31c3fa07caf8b516d43bf7514ee19edcda994",
"salt": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
"plaintext": "الكل في المجمو عة (5)",
"ciphertext": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjTHmhdBC3meTY4A7Lv8s8B86MnmlUBJ8ebzwxFQzDyVCcdSbWFaKe0gigEBdXew7TjrjH8BCpAbtYjoa4YHa8GNjj7zH314ApVnwoByHdLHLB9Vr6VdzkxcJgA6oL4MAsRLg="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"shared": "c6f2fde7aa00208c388f506455c31c3fa07caf8b516d43bf7514ee19edcda994",
"salt": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
"ciphertext": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLg/25Q5uBC88jl5ghtEREXX6o4QijPzM0uwmkeQ54/6aIqUyzGNVdryWKZ0mee2lmVVWhU+26X6XGFQ5DGRn+1v0POsFUCZ/REh35+beBNHnyvjxD/rbrMfhP2Blc8X5m8Xvk="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"shared": "c6f2fde7aa00208c388f506455c31c3fa07caf8b516d43bf7514ee19edcda994",
"salt": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
"plaintext": "🙈 🙉 🙊 0⃣ 1⃣ 2⃣ 3⃣ 4⃣ 5⃣ 6⃣ 7⃣ 8⃣ 9⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
"ciphertext": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtU9bUC2ji2A2udRI2VCEQZ7IAmYRRgxodBtd5Yi/5htCUczf1jLHxIt9AhVAZLKuRgbWOuEMq5RBybkxPsSeAkxzXVOlWHZ1Febq5ogkjqY/6Xj8CwwmaZxfbx+d1BKKO3Wa+IFuXwuVAZa1Xo+fan+skyf+2R5QSj10QGAnGO7odAu/iZ9A28eMoSNeXsdxqy1+PRt5Zk4i019xmf7C4PDGSzgFZSvQ2EzusJN5WcsnRFmF1L5rXpX1AYo8HusOpWcGf9PjmFbO+8spUkX1W/T21GRm4o7dro1Y6ycgGOA9BsiQ=="
}
],
"valid_pub": [
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
"shared": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
"salt": "a000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "⁰⁴⁵₀₁₂",
"ciphertext": "AqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2+xmGnjIMPMqqJGmjdYAYZUDUyEEUO3/evHUaO40LePeR91VlMVZ7I+nKJPkaUiKZ3cQiQnA86Uwti2IxepmzOFN",
"note": "sec1 = n-2, pub2: random, 0x02"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
"shared": "aa971537d741089885a0b48f2730a125e15b36033d089d4537a4e1204e76b39e",
"salt": "b000000000000000000000000000000000000000000000000000000000000002",
"plaintext": "A Peer-to-Peer Electronic Cash System",
"ciphertext": "ArAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACyuqG6RycuPyDPtwxzTcuMQu+is3N5XuWTlvCjligVaVBRydexaylXbsX592MEd3/Jt13BNL/GlpYpGDvLS4Tt/+2s9FX/16e/RDc+czdwXglc4DdSHiq+O06BvvXYfEQOPw=",
"note": "sec1 = 2, pub2: "
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"shared": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"salt": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"plaintext": "A purely peer-to-peer version of electronic cash would allow online payments to be sent directly from one party to another without going through a financial institution. Digital signatures provide part of the solution, but the main benefits are lost if a trusted third party is still required to prevent double-spending.",
"ciphertext": "Anm+Zn753LusVaBilc6HCwcCm/zbLc4o2VnygVsW+BeYb9wHyKevpe7ohJ6OkpceFcb0pySY8TLGwT7Q3zWNDKxc9blXanxKborEXkQH8xNaB2ViJfgxpkutbwbYd0Grix34xzaZBASufdsNm7R768t51tI6sdS0nms6kWLVJpEGu6Ke4Bldv4StJtWBLaTcgsgN+4WxDbBhC/nhwjEQiBBbbmUrPWjaVZXjl8dzzPrYtkSoeBNJs/UNvDwym4+qrmhv4ASTvVflpZgLlSe4seqeu6dWoRqn8uRHZQnPs+XhqwbdCHpeKGB3AfGBykZY0RIr0tjarWdXNasGbIhGM3GiLasioJeabAZw0plCevDkKpZYDaNfMJdzqFVJ8UXRIpvDpQad0SOm8lLum/aBzUpLqTjr3RvSlhYdbuODpd9pR5K60k4L2N8nrPtBv08wlilQg2ymwQgKVE6ipxIzzKMetn8+f0nQ9bHjWFJqxetSuMzzArTUQl9c4q/DwZmCBhI2",
"note": "sec1 == pub2 == salt"
}
],
"invalid": [
{
"sec1": "2573d1e9b9ac5de5d570f652cbb9e8d4f235e3d3d334181448e87c417f374e83",
"pub2": "8348c2d35549098706e5bab7966d9a9c72fbf6554e918f41c2b6cb275f79ec13",
"sharedKey": "8673ec68393a997bfad7eab8661461daf8b3931b7e885d78312a3fb7fe17f41a",
"salt": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o b l e",
"ciphertext": "##Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbOsrsqIEyf8ccwhlrnI/Cx03mDSmeweOLKD7dw5BDZQDxXe2FwUJ8Ag25VoJ4MGhjlPCNmCU/Uqk4k0jwbhgR3fRh",
"note": "unknown encryption version"
},
{
"sec1": "11063318c5cb3cd9cafcced42b4db5ea02ec976ed995962d2bc1fa1e9b52e29f",
"pub2": "5c49873b6eac3dd363325250cc55d5dd4c7ce9a885134580405736d83506bb74",
"sharedKey": "e2aad10de00913088e5cb0f73fa526a6a17e95763cc5b2a127022f5ea5a73445",
"salt": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
"plaintext": "⚠️",
"ciphertext": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBA4fZmoHrtrz5I5pCHuwWZ22qqL/Xt1VidEZGMLds0yaJ5VwUbeEifEJlPICOFt1ssZJxCUf43HvRwCVTFskbhSMh",
"note": "unknown encryption version 0"
},
{
"sec1": "2573d1e9b9ac5de5d570f652cbb9e8d4f235e3d3d334181448e87c417f374e83",
"pub2": "8348c2d35549098706e5bab7966d9a9c72fbf6554e918f41c2b6cb275f79ec13",
"sharedKey": "8673ec68393a997bfad7eab8661461daf8b3931b7e885d78312a3fb7fe17f41a",
"salt": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o s t r",
"ciphertext": "Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbOsrsqIEybscEwg5rnI/Cx03mDSmeweOLKD,7dw5BDZQDxXSlCwX1LIcTJEZaJPTz98Ftu0zSE0d93ED7OtdlvNeZx",
"note": "invalid base64"
},
{
"sec1": "5a2f39347fed3883c9fe05868a8f6156a292c45f606bc610495fcc020ed158f7",
"pub2": "775bbfeba58d07f9d1fbb862e306ac780f39e5418043dadb547c7b5900245e71",
"sharedKey": "2e70c0a1cde884b88392458ca86148d859b273a5695ede5bbe41f731d7d88ffd",
"salt": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
"plaintext": "¯\\_(ツ)_/¯",
"ciphertext": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholdUejFZPARM22IvOqp1U/UmFSkeSyTBYbbwy5ykmi+mKiEcWL+nVmTOf28MMiC+rTpZys/8p1hqQFpn+XWZRPrVay",
"note": "invalid MAC"
},
{
"sec1": "067eda13c4a36090ad28a7a183e9df611186ca01f63cb30fcdfa615ebfd6fb6d",
"pub2": "32c1ece2c5dd2160ad03b243f50eff12db605b86ac92da47eacc78144bf0cdd3",
"sharedKey": "a808915e31afc5b853d654d2519632dac7298ee2ecddc11695b8eba925935c2a",
"salt": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
"plaintext": "🥎",
"ciphertext": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0U0P/M1/oKm95z8qz6Kg0zQawLzwk3DskvWA2drXP4zK+tzHpKvWq0KOdx5MdypboSQsP4NXfhh2KoUffjkyIOiMA",
"note": "invalid MAC"
},
{
"sec1": "3e7be560fb9f8c965c48953dbd00411d48577e200cf00d7cc427e49d0e8d9c01",
"pub2": "e539e5fee58a337307e2a937ee9a7561b45876fb5df405c5e7be3ee564b239cc",
"sharedKey": "6ee3efc4255e3b8270e5dd3f7dc7f6b60878cda6218c8df34a3261cd48744931",
"salt": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
"plaintext": "elliptic-curve cryptography",
"ciphertext": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHBu7F9m36yBd58mVUBB5ktBTOJREDaQT1KAyPmZidP+IRea1lNw5YAEK7+pbnpfCw8CD0i2n8Pf2IDWlKDhLiVvatw",
"note": "invalid padding"
},
{
"sec1": "c22e1d4de967aa39dc143354d8f596cec1d7c912c3140831fff2976ce3e387c1",
"pub2": "4e405be192677a2da95ffc733950777213bf880cf7c3b084eeb6f3fe5bd43705",
"sharedKey": "1675a773dbf6fbcbef6a293004a4504b6c856978be738b10584b0269d437c8d1",
"salt": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
"plaintext": "Peer-to-Peer",
"ciphertext": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwhT0hvSnF9Xjp9Lml792qtNbmAVvR6laukTe9eYEjeWPpZFxtkVpYTbbL9wDKFeplDMKsUKVa+roSeSvv0ela9seDVl2Sfso=",
"note": "invalid padding"
},
{
"sec1": "be1edab14c5912e5c59084f197f0945242e969c363096cccb59af8898815096f",
"pub2": "9eaf0775d971e4941c97189232542e1daefcdb7dddafc39bcea2520217710ba2",
"sharedKey": "1741a44c052d5ae363c7845441f73d2b6c28d9bfb3006190012bba12eb4c774b",
"salt": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
"plaintext": "censorship-resistant and global social network",
"ciphertext": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6bL9HUMo3Mjkz4bjQo/FJF2LWHmaCr9Byc3hU9D7we+EkNBWenBHasT1G52fZk9r3NKeOC1hLezNwBLr7XXiULh+NbMBDtJh9/aQh1uZ9EpAfeISOzbZXwYwf0P5M85g9XER8hZ2fgJDLb4qMOuQRG6CrPezhr357nS3UHwPC2qHo3uKACxhE+2td+965yDcvMTx4KYTQg1zNhd7PA5v/WPnWeq2B623yLxlevUuo/OvXplFho3QVy7s5QZVop6qV2g2/l/SIsvD0HIcv3V35sywOCBR0K4VHgduFqkx/LEF3NGgAbjONXQHX8ZKushsEeR4TxlFoRSovAyYjhWolz+Ok3KJL2Ertds3H+M/Bdl2WnZGT0IbjZjn3DS+b1Ke0R0X4Onww2ZG3+7o6ncIwTc+lh1O7YQn00V0HJ+EIp03heKV2zWdVSC615By/+Yt9KAiV56n5+02GAuNqA",
"note": "invalid padding"
}
],
"invalid_conversation_key": [
{
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 higher than curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 is 0"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"note": "pub2 is invalid, no sqrt, all-ff"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 == curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "pub2 is invalid, no sqrt"
}
],
"padding": [
[16, 32],
[32, 32],
[33, 64],
[37, 64],
[45, 64],
[49, 64],
[64, 64],
[65, 96],
[100, 128],
[111, 128],
[200, 224],
[250, 256],
[320, 320],
[383, 384],
[384, 384],
[400, 448],
[500, 512],
[512, 512],
[515, 640],
[700, 768],
[800, 896],
[900, 1024],
[1020, 1024],
[74123, 81920]
]
}
}
}

370
nip46.ts
View File

@@ -1,370 +0,0 @@
import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts'
import type { RelayRecord } from './relay.ts'
import { Signer } from './signer.ts'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%-]*)$/
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export type BunkerPointer = {
relays: string[]
pubkey: string
secret: null | string
}
export function toBunkerURL(bunkerPointer: BunkerPointer): string {
let bunkerURL = new URL(`bunker://${bunkerPointer.pubkey}`)
bunkerPointer.relays.forEach(relay => {
bunkerURL.searchParams.append('relay', relay)
})
if (bunkerPointer.secret) {
bunkerURL.searchParams.set('secret', bunkerPointer.secret)
}
return bunkerURL.toString()
}
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
and returns a BunkerPointer -- or null in case of error */
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
let match = input.match(BUNKER_REGEX)
if (match) {
try {
const pubkey = match[1]
const qs = new URLSearchParams(match[2])
return {
pubkey,
relays: qs.getAll('relay'),
secret: qs.get('secret'),
}
} catch (_err) {
/* just move to the next case */
}
}
return queryBunkerProfile(input)
}
export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
const match = nip05.match(NIP05_REGEX)
if (!match) return null
const [_, name = '_', domain] = match
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await (await _fetch(url, { redirect: 'error' })).json()
let pubkey = res.names[name]
let relays = res.nip46[pubkey] || []
return { pubkey, relays, secret: null }
} catch (_err) {
return null
}
}
export type BunkerSignerParams = {
pool?: AbstractSimplePool
onauth?: (url: string) => void
}
export class BunkerSigner implements Signer {
private params: BunkerSignerParams
private pool: AbstractSimplePool
private subCloser: SubCloser | undefined
private isOpen: boolean
private serial: number
private idPrefix: string
private listeners: {
[id: string]: {
resolve: (_: string) => void
reject: (_: string) => void
}
}
private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array
private conversationKey: Uint8Array
public bp: BunkerPointer
private cachedPubKey: string | undefined
/**
* Creates a new instance of the Nip46 class.
* @param relays - An array of relay addresses.
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param secretKey - An optional key pair.
*/
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker')
}
this.params = params
this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp
this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7)
this.serial = 0
this.listeners = {}
this.waitingForAuth = {}
this.setupSubscription(params)
}
private setupSubscription(params: BunkerSignerParams) {
const listeners = this.listeners
const waitingForAuth = this.waitingForAuth
const convKey = this.conversationKey
this.subCloser = this.pool.subscribe(
this.bp.relays,
{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] },
{
onevent: async (event: NostrEvent) => {
const o = JSON.parse(decrypt(event.content, convKey))
const { id, result, error } = o
if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id]
if (params.onauth) {
params.onauth(error)
} else {
console.warn(
`nostr-tools/nip46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
)
}
return
}
let handler = listeners[id]
if (handler) {
if (error) handler.reject(error)
else if (result) handler.resolve(result)
delete listeners[id]
}
},
onclose: () => {
this.subCloser = undefined
},
},
)
this.isOpen = true
}
// closes the subscription -- this object can't be used anymore after this
async close() {
this.isOpen = false
this.subCloser!.close()
}
async sendRequest(method: string, params: string[]): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
if (!this.subCloser) this.setupSubscription(this.params)
this.serial++
const id = `${this.idPrefix}-${this.serial}`
const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey)
// the request event
const verifiedEvent: VerifiedEvent = finalizeEvent(
{
kind: NostrConnect,
tags: [['p', this.bp.pubkey]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000),
},
this.secretKey,
)
// setup callback listener
this.listeners[id] = { resolve, reject }
this.waitingForAuth[id] = true
// publish the event
await Promise.any(this.pool.publish(this.bp.relays, verifiedEvent))
} catch (err) {
reject(err)
}
})
}
/**
* Calls the "connect" method on the bunker.
* The promise will be rejected if the response is not "pong".
*/
async ping(): Promise<void> {
let resp = await this.sendRequest('ping', [])
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
}
/**
* Calls the "connect" method on the bunker.
*/
async connect(): Promise<void> {
await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || ''])
}
/**
* Calls the "get_public_key" method on the bunker.
* (before we would return the public key hardcoded in the bunker parameters, but
* that is not correct as that may be the bunker pubkey and the actual signer
* pubkey may be different.)
*/
async getPublicKey(): Promise<string> {
if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest('get_public_key', [])
}
return this.cachedPubKey
}
/**
* @deprecated removed from NIP
*/
async getRelays(): Promise<RelayRecord> {
return JSON.parse(await this.sendRequest('get_relays', []))
}
/**
* Signs an event using the remote private key.
* @param event - The event to sign.
* @returns A Promise that resolves to the signed event.
*/
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
let signed: NostrEvent = JSON.parse(resp)
if (verifyEvent(signed)) {
return signed
} else {
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
}
}
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
}
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
}
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
}
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext])
}
}
/**
* Creates an account with the specified username, domain, and optional email.
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
* @param username - The username for the account.
* @param domain - The domain for the account.
* @param email - The optional email for the account.
* @param localSecretKey - Optionally pass a local secret key that will be used to communicate with the bunker,
this will default to generating a random key.
* @throws Error if the email is present but invalid.
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
*/
export async function createAccount(
bunker: BunkerProfile,
params: BunkerSignerParams,
username: string,
domain: string,
email?: string,
localSecretKey: Uint8Array = generateSecretKey(),
): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params)
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
// once we get the newly created pubkey back, we hijack this signer instance
// and turn it into the main instance for this newly created pubkey
rpc.bp.pubkey = pubkey
await rpc.connect()
return rpc
}
/**
* Fetches info on available providers that announce themselves using NIP-89 events.
* @returns A promise that resolves to an array of available bunker objects.
*/
export async function fetchBunkerProviders(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
const events = await pool.querySync(relays, {
kinds: [Handlerinformation],
'#k': [NostrConnect.toString()],
})
events.sort((a, b) => b.created_at - a.created_at)
// validate bunkers by checking their NIP-05 and pubkey
// map to a more useful object
const validatedBunkers = await Promise.all(
events.map(async (event, i) => {
try {
const content = JSON.parse(event.content)
// skip duplicates
try {
if (events.findIndex(ev => JSON.parse(ev.content).nip05 === content.nip05) !== i) return undefined
} catch (err) {
/***/
}
const bp = await queryBunkerProfile(content.nip05)
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
return {
bunkerPointer: bp,
nip05: content.nip05,
domain: content.nip05.split('@')[1],
name: content.name || content.display_name,
picture: content.picture,
about: content.about,
website: content.website,
local: false,
}
}
} catch (err) {
return undefined
}
}),
)
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
}
export type BunkerProfile = {
bunkerPointer: BunkerPointer
domain: string
nip05: string
name: string
picture: string
about: string
website: string
local: boolean
}

View File

@@ -1,8 +1,11 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { makeNwcRequestEvent, parseConnectionString } from './nip47.ts'
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
import { Kind } from './event'
import { decrypt } from './nip04.ts'
import { NWCWalletRequest } from './kinds.ts'
import crypto from 'node:crypto'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
describe('parseConnectionString', () => {
test('returns pubkey, relay, and secret if connection string is valid', () => {
@@ -40,11 +43,19 @@ describe('parseConnectionString', () => {
describe('makeNwcRequestEvent', () => {
test('returns a valid NWC request event', async () => {
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
const secret = '71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
const invoice =
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
const result = await makeNwcRequestEvent(pubkey, secret, invoice)
expect(result.kind).toBe(NWCWalletRequest)
const timeBefore = Date.now() / 1000
const result = await makeNwcRequestEvent({
pubkey,
secret,
invoice,
})
const timeAfter = Date.now() / 1000
expect(result.kind).toBe(Kind.NwcRequest)
expect(result.created_at).toBeGreaterThan(timeBefore)
expect(result.created_at).toBeLessThan(timeAfter)
expect(await decrypt(secret, pubkey, result.content)).toEqual(
JSON.stringify({
method: 'pay_invoice',

View File

@@ -1,14 +1,8 @@
import { type VerifiedEvent, finalizeEvent } from './pure.ts'
import { NWCWalletRequest } from './kinds.ts'
import { finishEvent } from './event.ts'
import { encrypt } from './nip04.ts'
import { Kind } from './event'
interface NWCConnection {
pubkey: string
relay: string
secret: string
}
export function parseConnectionString(connectionString: string): NWCConnection {
export function parseConnectionString(connectionString: string) {
const { pathname, searchParams } = new URL(connectionString)
const pubkey = pathname
const relay = searchParams.get('relay')
@@ -21,24 +15,28 @@ export function parseConnectionString(connectionString: string): NWCConnection {
return { pubkey, relay, secret }
}
export async function makeNwcRequestEvent(
pubkey: string,
secretKey: Uint8Array,
invoice: string,
): Promise<VerifiedEvent> {
export async function makeNwcRequestEvent({
pubkey,
secret,
invoice,
}: {
pubkey: string
secret: string
invoice: string
}) {
const content = {
method: 'pay_invoice',
params: {
invoice,
},
}
const encryptedContent = encrypt(secretKey, pubkey, JSON.stringify(content))
const encryptedContent = await encrypt(secret, pubkey, JSON.stringify(content))
const eventTemplate = {
kind: NWCWalletRequest,
kind: Kind.NwcRequest,
created_at: Math.round(Date.now() / 1000),
content: encryptedContent,
tags: [['p', pubkey]],
}
return finalizeEvent(eventTemplate, secretKey)
return finishEvent(eventTemplate, secret)
}

View File

@@ -1,95 +0,0 @@
import { test, expect } from 'bun:test'
import { decrypt, encrypt } from './nip49.ts'
import { hexToBytes } from '@noble/hashes/utils'
test('encrypt and decrypt', () => {
for (let i = 0; i < vectors.length; i++) {
let [password, secret, logn, ksb, ncryptsec] = vectors[i]
let sec = hexToBytes(secret)
let there = encrypt(sec, password, logn, ksb)
let back = decrypt(there, password)
let again = decrypt(ncryptsec, password)
expect(back).toEqual(again)
expect(again).toEqual(sec)
}
})
const vectors: [string, string, number, 0x00 | 0x01 | 0x02, string][] = [
[
'.ksjabdk.aselqwe',
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
1,
0x00,
'ncryptsec1qgqeya6cggg2chdaf48s9evsr0czq3dw059t2khf5nvmq03yeckywqmspcc037l9ajjsq2p08480afuc5hq2zq3rtt454c2epjqxcxll0eff3u7ln2t349t7rc04029q63u28mkeuj4tdazsqqk6p5ky',
],
[
'skjdaklrnçurbç l',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
2,
0x01,
'ncryptsec1qgp86t7az0u5w0wp8nrjnxu9xhullqt39wvfsljz8289gyxg0thrlzv3k40dsqu32vcqza3m7srzm27mkg929gmv6hv5ctay59jf0h8vsj5pjmylvupkdtvy7fy88et3fhe6m3d84t9m8j2umq0j75lw',
],
[
'777z7z7z7z7z7z7z',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
3,
0x02,
'ncryptsec1qgpc7jmmzmds376r8slazywlagrm5eerlrx7njnjenweggq2atjl0h9vmpk8f9gad0tqy3pwch8e49kyj5qtehp4mjwpzlshx5f5cce8feukst08w52zf4a7gssdqvt3eselup7x4zzezlme3ydxpjaf',
],
[
'.ksjabdk.aselqwe',
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
7,
0x00,
'ncryptsec1qgrss6ycqptee05e5anq33x2vz6ljr0rqunsy9xj5gypkp0lucatdf8yhexrztqcy76sqweuzk8yqzep9mugp988vznz5df8urnyrmaa7l7fvvskp4t0ydjtz0zeajtumul8cnsjcksp68xhxggmy4dz',
],
[
'skjdaklrnçurbç l',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
8,
0x01,
'ncryptsec1qgy0gg98z4wvl35eqlraxf7cyxhfs4968teq59vm97e94gpycmcy6znsc8z82dy5rk8sz0r499ue7xfmd0yuyvzxagtfyxtnwcrcsjavkch8lfseejukwdq7mdcpm43znffngw7texdc5pdujywszhrr',
],
[
'777z7z7z7z7z7z7z',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x02,
'ncryptsec1qgyskhh7mpr0zspg95kv4eefm8233hyz46xyr6s52s6qvan906c2u24gl3dc5f7wytzq9njx7sqksd7snagce3kqth7tv4ug4avlxd5su4vthsh54vk62m88whkazavyc6yefnegf4tx473afssxw4p9',
],
[
'',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
4,
0x00,
'ncryptsec1qgzv73a9ktnwmgyvv24x2xtr6grup2v6an96xgs64z3pmh5etg2k4yryachtlu3tpqwqphhm0pjnq9zmftr0qf4p5lmah4rlz02ucjkawr2s9quau67p3jq3d7yp3kreghs0wdcqpf6pkc8jcgsqrn5l',
],
[
'',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
5,
0x01,
'ncryptsec1qgzs50vjjhewdrxnm0z4y77w7juycf6crny9q0kzeg7vxv3erw77qpauthaf7sfwsgnszjzcqh7zql74m8yxnhcj07dry3v5fgr5x42mpzxvfl76gpuayccvk2nczc7ner3q842rj9v033nykvja6cql',
],
[
'',
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
1,
0x00,
'ncryptsec1qgqnx59n7duv6ec3hhrvn33q25u2qfd7m69vv6plsg7spnw6d4r9hq0ayjsnlw99eghqqzj8ps7vfwx40nqp9gpw7yzyy09jmwkq3a3z8q0ph5jahs2hap5k6h2wfrme7w2nuek4jnwpzfht4q3u79ra',
],
[
'ÅΩẛ̣',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x01,
'ncryptsec1qgy5kwr5v8p206vwaflp4g6r083kwts6q5sh8m4d0q56edpxwhrly78ema2z7jpdeldsz7u5wpxpyhs6m0405skdsep9n37uncw7xlc8q8meyw6d6ky47vcl0guhqpt5dx8ejxc8hvzf6y2gwsl5s0nw',
],
[
'ÅΩṩ',
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
9,
0x01,
'ncryptsec1qgy5f4lcx873yarkfpngaudarxfj4wj939xn4azmd66j6jrwcml6av87d6vnelzn70kszgkg4lj9rsdjlqz0wn7m7456sr2q5yjpy72ykgkdwckevl857hpcfnwzswj9lajxtln0tsr9h7xdwqm6pqzf',
],
]

View File

@@ -1,50 +0,0 @@
import { scrypt } from '@noble/hashes/scrypt'
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
import { bech32 } from '@scure/base'
export function encrypt(
sec: Uint8Array,
password: string,
logn: number = 16,
ksb: 0x00 | 0x01 | 0x02 = 0x02,
): Ncryptsec {
let salt = randomBytes(16)
let n = 2 ** logn
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
let nonce = randomBytes(24)
let aad = Uint8Array.from([ksb])
let xc2p1 = xchacha20poly1305(key, nonce, aad)
let ciphertext = xc2p1.encrypt(sec)
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
return encodeBytes('ncryptsec', b)
}
export function decrypt(ncryptsec: string, password: string): Uint8Array {
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
if (prefix !== 'ncryptsec') {
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
}
let b = new Uint8Array(bech32.fromWords(words))
let version = b[0]
if (version !== 0x02) {
throw new Error(`invalid version ${version}, expected 0x02`)
}
let logn = b[1]
let n = 2 ** logn
let salt = b.slice(2, 2 + 16)
let nonce = b.slice(2 + 16, 2 + 16 + 24)
let ksb = b[2 + 16 + 24]
let aad = Uint8Array.from([ksb])
let ciphertext = b.slice(2 + 16 + 24 + 1)
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
let xc2p1 = xchacha20poly1305(key, nonce, aad)
let sec = xc2p1.decrypt(ciphertext)
return sec
}

View File

@@ -1,42 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { normalizeIdentifier } from './nip54.ts'
describe('normalizeIdentifier', () => {
test('converts to lowercase', () => {
expect(normalizeIdentifier('HELLO')).toBe('hello')
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
})
test('trims whitespace', () => {
expect(normalizeIdentifier(' hello ')).toBe('hello')
expect(normalizeIdentifier('\thello\n')).toBe('hello')
})
test('normalizes Unicode to NFKC form', () => {
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
expect(normalizeIdentifier('café')).toBe('café')
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
})
test('replaces non-alphanumeric characters with hyphens', () => {
expect(normalizeIdentifier('hello world')).toBe('hello-world')
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
})
test('preserves numbers', () => {
expect(normalizeIdentifier('user123')).toBe('user123')
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
})
test('handles multiple consecutive special characters', () => {
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
})
test('handles Unicode letters from different scripts', () => {
expect(normalizeIdentifier('привет')).toBe('привет')
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
})
})

View File

@@ -1,19 +0,0 @@
export function normalizeIdentifier(name: string): string {
// Trim and lowercase
name = name.trim().toLowerCase()
// Normalize Unicode to NFKC form
name = name.normalize('NFKC')
// Convert to array of characters and map each one
return Array.from(name)
.map(char => {
// Check if character is letter or number using Unicode ranges
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
return char
}
return '-'
})
.join('')
}

View File

@@ -1,166 +0,0 @@
import { test, expect } from 'bun:test'
import * as nip55 from './nip55.js'
// Function to parse the NostrSigner URI
function parseNostrSignerUri(uri: string) {
const [base, query] = uri.split('?')
const basePart = base.replace('nostrsigner:', '')
let jsonObject = null
if (basePart) {
try {
jsonObject = JSON.parse(decodeURIComponent(basePart))
} catch (e) {
console.warn('Failed to parse base JSON:', e)
}
}
const urlSearchParams = new URLSearchParams(query)
const queryParams = Object.fromEntries(urlSearchParams.entries())
if (queryParams.permissions) {
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
}
return {
base: jsonObject,
...queryParams,
}
}
// Test cases
test('Get Public Key URI', () => {
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
const callbackUrl = 'https://example.com/?event='
const uri = nip55.getPublicKeyUri({
permissions,
callbackUrl,
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'get_public_key')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
})
test('Sign Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.signEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('base.content', 'test')
expect(jsonObject).toHaveProperty('type', 'sign_event')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})
test('Encrypt NIP-04 URI', () => {
const callbackUrl = 'https://example.com/?event='
const uri = nip55.encryptNip04Uri({
callbackUrl,
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-04 URI', () => {
const uri = nip55.decryptNip04Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Encrypt NIP-44 URI', () => {
const uri = nip55.encryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-44 URI', () => {
const uri = nip55.decryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Decrypt Zap Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.decryptZapEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
returnType: 'event',
compressionType: 'gzip',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
expect(jsonObject).toHaveProperty('returnType', 'event')
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})

123
nip55.ts
View File

@@ -1,123 +0,0 @@
type BaseParams = {
callbackUrl?: string
returnType?: 'signature' | 'event'
compressionType?: 'none' | 'gzip'
}
type PermissionsParams = BaseParams & {
permissions?: { type: string; kind?: number }[]
}
type EventUriParams = BaseParams & {
eventJson: Record<string, unknown>
id?: string
currentUser?: string
}
type EncryptDecryptParams = BaseParams & {
pubKey: string
content: string
id?: string
currentUser?: string
}
type UriParams = BaseParams & {
base: string
type: string
id?: string
currentUser?: string
permissions?: { type: string; kind?: number }[]
pubKey?: string
plainText?: string
encryptedText?: string
appName?: string
}
function encodeParams(params: Record<string, unknown>): string {
return new URLSearchParams(params as Record<string, string>).toString()
}
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
}
function buildUri({
base,
type,
callbackUrl,
returnType = 'signature',
compressionType = 'none',
...params
}: UriParams): string {
const baseParams = {
type,
compressionType,
returnType,
callbackUrl,
id: params.id,
current_user: params.currentUser,
permissions:
params.permissions && params.permissions.length > 0
? encodeURIComponent(JSON.stringify(params.permissions))
: undefined,
pubKey: params.pubKey,
plainText: params.plainText,
encryptedText: params.encryptedText,
appName: params.appName,
}
const filteredParams = filterUndefined(baseParams)
return `${base}?${encodeParams(filteredParams)}`
}
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
return buildUri({
base: 'nostrsigner:',
type,
...params,
})
}
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
return buildDefaultUri('get_public_key', { permissions, ...params })
}
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'sign_event',
...params,
})
}
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, plainText: params.content })
}
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, encryptedText: params.content })
}
export function encryptNip04Uri(params: EncryptDecryptParams): string {
return encryptUri('nip04_encrypt', params)
}
export function decryptNip04Uri(params: EncryptDecryptParams): string {
return decryptUri('nip04_decrypt', params)
}
export function encryptNip44Uri(params: EncryptDecryptParams): string {
return encryptUri('nip44_encrypt', params)
}
export function decryptNip44Uri(params: EncryptDecryptParams): string {
return decryptUri('nip44_decrypt', params)
}
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'decrypt_zap_event',
...params,
})
}

View File

@@ -1,14 +1,6 @@
import { describe, test, expect, mock } from 'bun:test'
import { finalizeEvent } from './pure.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import {
getSatoshisAmountFromBolt11,
getZapEndpoint,
makeZapReceipt,
makeZapRequest,
useFetchImplementation,
validateZapRequest,
} from './nip57.ts'
import { finishEvent } from './event.ts'
import { getPublicKey, generatePrivateKey } from './keys.ts'
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
import { buildEvent } from './test-helpers.ts'
describe('getZapEndpoint', () => {
@@ -20,7 +12,7 @@ describe('getZapEndpoint', () => {
})
test('returns null if fetch fails', async () => {
const fetchImplementation = mock(() => Promise.reject(new Error()))
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
@@ -31,7 +23,7 @@ describe('getZapEndpoint', () => {
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
const fetchImplementation = jest.fn(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
@@ -42,7 +34,7 @@ describe('getZapEndpoint', () => {
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = mock(() =>
const fetchImplementation = jest.fn(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
@@ -102,9 +94,9 @@ describe('makeZapRequest', () => {
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
['e', 'event'],
]),
)
expect(result.tags).toContainEqual(['e', 'event'])
})
})
@@ -129,7 +121,7 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the signature on the Zap request is invalid', () => {
const privateKey = generateSecretKey()
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = {
@@ -148,8 +140,9 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the Zap request does not have a "p" tag', () => {
const privateKey = generateSecretKey()
const zapRequest = finalizeEvent(
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -166,8 +159,9 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
const privateKey = generateSecretKey()
const zapRequest = finalizeEvent(
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -185,10 +179,10 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
const privateKey = generateSecretKey()
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -207,10 +201,10 @@ describe('validateZapRequest', () => {
})
test('returns an error message if the Zap request does not have a relays tag', () => {
const privateKey = generateSecretKey()
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -227,10 +221,10 @@ describe('validateZapRequest', () => {
})
test('returns null for a valid Zap request', () => {
const privateKey = generateSecretKey()
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
@@ -249,19 +243,18 @@ describe('validateZapRequest', () => {
})
describe('makeZapReceipt', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
test('returns a valid Zap receipt with a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finalizeEvent(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', target],
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
@@ -278,26 +271,24 @@ describe('makeZapReceipt', () => {
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', target],
['P', publicKey],
['preimage', preimage],
]),
)
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).toContainEqual(['preimage', preimage])
})
test('returns a valid Zap receipt without a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finalizeEvent(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', target],
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
@@ -313,37 +304,9 @@ describe('makeZapReceipt', () => {
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', target],
['P', publicKey],
]),
)
expect(JSON.stringify(result.tags)).not.toContain('preimage')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).not.toContain('preimage')
})
})
test('parses the amount from bolt11 invoices', () => {
expect(
getSatoshisAmountFromBolt11(
'lnbc4u1p5zcarnpp5djng98r73nxu66nxp6gndjkw24q7rdzgp7p80lt0gk4z3h3krkssdq9tfpygcqzzsxqzjcsp58hz3v5qefdm70g5fnm2cn6q9thzpu6m4f5wjqurhur5xzmf9vl3s9qxpqysgq9v6qv86xaruzeak9jjyz54fygrkn526z7xhm0llh8wl44gcgh0rznhjqdswd4cjurzdgh0pgzrfj4sd7f3mf89jd6kadse008ex7kxgqqa5xrk',
),
).toEqual(400)
expect(
getSatoshisAmountFromBolt11(
'lnbc8400u1p5zcaz5pp5ltvyhtg4ed7sd8jurj28ugmavezkmqsadpe3t9npufpcrd0uet0scqzyssp5l3hz4ayt5ee0p83ma4a96l2rruhx33eyycewldu2ffa5pk2qx7jq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdq8w3jhxaqmqz9gxqyjw5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll8qkt3np4rqyqqqqlgqqqqqeqqjqhuhjk5u9r850ncxngne7cfp9s08s2nm6c2rkz7jhl8gjmlx0fga5tlncgeuh4avlsrkq6ljyyhgq8rrxprga03esqhd0gf5455x6tdcqahhw9q',
),
).toEqual(840000)
expect(
getSatoshisAmountFromBolt11(
'lnbc210n1p5zcuaxpp52nn778cfk46md4ld0hdj2juuzvfrsrdaf4ek2k0yeensae07x2cqdq9tfpygcqzzsxqzjcsp5768c4k79jtnq92pgppan8rjnujcpcqhnqwqwk3lm5dfr7e0k2a7s9qxpqysgqt8lnh9l7ple27t73x7gty570ltas2s33uahc7egke5tdmhxr3ezn590wf2utxyt7d3afnk2lxc2u0enc6n53ck4mxwpmzpxa7ws05aqp0c5x3r',
),
).toEqual(21)
expect(
getSatoshisAmountFromBolt11(
'lnbc899640n1p5zcuavpp5w72fqrf09286lq33vw364qryrq5nw60z4dhdx56f8w05xkx4massdq9tfpygcqzzsxqzjcsp5qrqn4kpvem5jwpl63kj5pfdlqxg2plaffz0prz7vaqjy29uc66us9qxpqysgqlhzzqmn2jxd2476404krm8nvrarymwq7nj2zecl92xug54ek0mfntdxvxwslf756m8kq0r7jtpantm52fmewc72r5lfmd85505jnemgqw5j0pc',
),
).toEqual(89964)
})

View File

@@ -1,8 +1,7 @@
import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { Kind, validateEvent, verifySignature, type Event, type EventTemplate } from './event.ts'
import { utf8Decoder } from './utils.ts'
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
var _fetch: any
@@ -24,7 +23,7 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
lnurl = utf8Decoder.decode(data)
} else if (lud16) {
let [name, domain] = lud16.split('@')
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
} else {
return null
}
@@ -50,7 +49,7 @@ export function makeZapRequest({
comment = '',
}: {
profile: string
event: string | Event | null
event: string | null
amount: number
comment: string
relays: string[]
@@ -59,7 +58,7 @@ export function makeZapRequest({
if (!profile) throw new Error('profile not given')
let zr: EventTemplate = {
kind: 9734,
kind: Kind.ZapRequest,
created_at: Math.round(Date.now() / 1000),
content: comment,
tags: [
@@ -69,22 +68,9 @@ export function makeZapRequest({
],
}
if (event && typeof event === 'string') {
if (event) {
zr.tags.push(['e', event])
}
if (event && typeof event === 'object') {
// replacable event
if (isReplaceableKind(event.kind)) {
const a = ['a', `${event.kind}:${event.pubkey}:`]
zr.tags.push(a)
// addressable event
} else if (isAddressableKind(event.kind)) {
let d = event.tags.find(([t, v]) => t === 'd' && v)
if (!d) throw new Error('d tag not found or is empty')
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
zr.tags.push(a)
}
}
return zr
}
@@ -100,7 +86,7 @@ export function validateZapRequest(zapRequestString: string): string | null {
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
if (!p) return "Zap request doesn't have a 'p' tag."
@@ -130,10 +116,10 @@ export function makeZapReceipt({
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
let zap: EventTemplate = {
kind: 9735,
kind: Kind.Zap,
created_at: Math.round(paidAt.getTime() / 1000),
content: '',
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
}
if (preimage) {
@@ -142,52 +128,3 @@ export function makeZapReceipt({
return zap
}
export function getSatoshisAmountFromBolt11(bolt11: string): number {
if (bolt11.length < 50) {
return 0
}
bolt11 = bolt11.substring(0, 50)
const idx = bolt11.lastIndexOf('1')
if (idx === -1) {
return 0
}
const hrp = bolt11.substring(0, idx)
if (!hrp.startsWith('lnbc')) {
return 0
}
const amount = hrp.substring(4) // equivalent to strings.CutPrefix
if (amount.length < 1) {
return 0
}
// if last character is a digit, then the amount can just be interpreted as BTC
const char = amount[amount.length - 1]
const digit = char.charCodeAt(0) - '0'.charCodeAt(0)
const isDigit = digit >= 0 && digit <= 9
let cutPoint = amount.length - 1
if (isDigit) {
cutPoint++
}
if (cutPoint < 1) {
return 0
}
const num = parseInt(amount.substring(0, cutPoint))
switch (char) {
case 'm':
return num * 100000
case 'u':
return num * 100
case 'n':
return num / 10
case 'p':
return num / 10000
default:
return num * 100000000
}
}

View File

@@ -1,357 +0,0 @@
import { expect, test } from 'bun:test'
import { EventTemplate } from './core.ts'
import {
BadgeAward as BadgeAwardKind,
BadgeDefinition as BadgeDefinitionKind,
ProfileBadges as ProfileBadgesKind,
} from './kinds.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
import {
BadgeAward,
BadgeDefinition,
ProfileBadges,
generateBadgeAwardEventTemplate,
generateBadgeDefinitionEventTemplate,
generateProfileBadgesEventTemplate,
validateBadgeAwardEvent,
validateBadgeDefinitionEvent,
validateProfileBadgesEvent,
} from './nip58.ts'
test('BadgeDefinition has required property "d"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
}
expect(badge.d).toEqual('badge-id')
})
test('BadgeDefinition has optional property "name"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
name: 'Badge Name',
}
expect(badge.name).toEqual('Badge Name')
})
test('BadgeDefinition has optional property "description"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
description: 'Badge Description',
}
expect(badge.description).toEqual('Badge Description')
})
test('BadgeDefinition has optional property "image"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
image: ['https://example.com/badge.png', '1024x1024'],
}
expect(badge.image).toEqual(['https://example.com/badge.png', '1024x1024'])
})
test('BadgeDefinition has optional property "thumbs"', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
thumbs: [
['https://example.com/thumb.png', '100x100'],
['https://example.com/thumb2.png', '200x200'],
],
}
expect(badge.thumbs).toEqual([
['https://example.com/thumb.png', '100x100'],
['https://example.com/thumb2.png', '200x200'],
])
})
test('BadgeAward has required property "a"', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
expect(badgeAward.a).toEqual('badge-definition-address')
})
test('BadgeAward has required property "p"', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
expect(badgeAward.p).toEqual([
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
])
})
test('ProfileBadges has required property "d"', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [],
}
expect(profileBadges.d).toEqual('profile_badges')
})
test('ProfileBadges has required property "badges"', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [],
}
expect(profileBadges.badges).toEqual([])
})
test('ProfileBadges badges array contains objects with required properties "a" and "e"', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [
{
a: 'badge-definition-address',
e: ['badge-award-event-id'],
},
],
}
expect(profileBadges.badges[0].a).toEqual('badge-definition-address')
expect(profileBadges.badges[0].e).toEqual(['badge-award-event-id'])
})
test('generateBadgeDefinitionEventTemplate generates EventTemplate with mandatory tags', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
}
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
})
test('generateBadgeDefinitionEventTemplate generates EventTemplate with optional tags', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
name: 'Badge Name',
description: 'Badge Description',
image: ['https://example.com/badge.png', '1024x1024'],
thumbs: [
['https://example.com/thumb.png', '100x100'],
['https://example.com/thumb2.png', '200x200'],
],
}
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
expect(eventTemplate.tags).toEqual([
['d', 'badge-id'],
['name', 'Badge Name'],
['description', 'Badge Description'],
['image', 'https://example.com/badge.png', '1024x1024'],
['thumb', 'https://example.com/thumb.png', '100x100'],
['thumb', 'https://example.com/thumb2.png', '200x200'],
])
})
test('generateBadgeDefinitionEventTemplate generates EventTemplate without optional tags', () => {
const badge: BadgeDefinition = {
d: 'badge-id',
}
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
})
test('validateBadgeDefinitionEvent returns true for valid BadgeDefinition event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeDefinitionKind,
tags: [
['d', 'badge-id'],
['name', 'Badge Name'],
['description', 'Badge Description'],
['image', 'https://example.com/badge.png', '1024x1024'],
['thumb', 'https://example.com/thumb.png', '100x100'],
['thumb', 'https://example.com/thumb2.png', '200x200'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateBadgeDefinitionEvent(event)
expect(isValid).toBe(true)
})
test('validateBadgeDefinitionEvent returns false for invalid BadgeDefinition event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeDefinitionKind,
tags: [],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateBadgeDefinitionEvent(event)
expect(isValid).toBe(false)
})
test('generateBadgeAwardEventTemplate generates EventTemplate with mandatory tags', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
expect(eventTemplate.tags).toEqual([
['a', 'badge-definition-address'],
['p', 'pubkey1', 'relay1'],
['p', 'pubkey2', 'relay2'],
])
})
test('generateBadgeAwardEventTemplate generates EventTemplate without optional tags', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
expect(eventTemplate.tags).toEqual([
['a', 'badge-definition-address'],
['p', 'pubkey1', 'relay1'],
['p', 'pubkey2', 'relay2'],
])
})
test('generateBadgeAwardEventTemplate generates EventTemplate with optional tags', () => {
const badgeAward: BadgeAward = {
a: 'badge-definition-address',
p: [
['pubkey1', 'relay1'],
['pubkey2', 'relay2'],
],
}
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
expect(eventTemplate.tags).toEqual([
['a', 'badge-definition-address'],
['p', 'pubkey1', 'relay1'],
['p', 'pubkey2', 'relay2'],
])
})
test('validateBadgeAwardEvent returns true for valid BadgeAward event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeAwardKind,
tags: [
['a', 'badge-definition-address'],
['p', 'pubkey1', 'relay1'],
['p', 'pubkey2', 'relay2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateBadgeAwardEvent(event)
expect(isValid).toBe(true)
})
test('validateBadgeAwardEvent returns false for invalid BadgeAward event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeAwardKind,
tags: [],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateBadgeAwardEvent(event)
expect(isValid).toBe(false)
})
test('generateProfileBadgesEventTemplate generates EventTemplate with mandatory tags', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [],
}
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
expect(eventTemplate.tags).toEqual([['d', 'profile_badges']])
})
test('generateProfileBadgesEventTemplate generates EventTemplate with optional tags', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [
{
a: 'badge-definition-address',
e: ['badge-award-event-id'],
},
],
}
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
expect(eventTemplate.tags).toEqual([
['d', 'profile_badges'],
['a', 'badge-definition-address'],
['e', 'badge-award-event-id'],
])
})
test('generateProfileBadgesEventTemplate generates EventTemplate with multiple optional tags', () => {
const profileBadges: ProfileBadges = {
d: 'profile_badges',
badges: [
{
a: 'badge-definition-address1',
e: ['badge-award-event-id1', 'badge-award-event-id2'],
},
{
a: 'badge-definition-address2',
e: ['badge-award-event-id3'],
},
],
}
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
expect(eventTemplate.tags).toEqual([
['d', 'profile_badges'],
['a', 'badge-definition-address1'],
['e', 'badge-award-event-id1', 'badge-award-event-id2'],
['a', 'badge-definition-address2'],
['e', 'badge-award-event-id3'],
])
})
test('validateProfileBadgesEvent returns true for valid ProfileBadges event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: ProfileBadgesKind,
tags: [
['d', 'profile_badges'],
['a', 'badge-definition-address'],
['e', 'badge-award-event-id'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateProfileBadgesEvent(event)
expect(isValid).toBe(true)
})
test('validateProfileBadgesEvent returns false for invalid ProfileBadges event', () => {
const sk = generateSecretKey()
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: ProfileBadgesKind,
tags: [],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateProfileBadgesEvent(event)
expect(isValid).toBe(false)
})

245
nip58.ts
View File

@@ -1,245 +0,0 @@
import { Event, EventTemplate } from './core.ts'
import {
BadgeAward as BadgeAwardKind,
BadgeDefinition as BadgeDefinitionKind,
ProfileBadges as ProfileBadgesKind,
} from './kinds.ts'
/**
* Represents the structure for defining a badge within the Nostr network.
* This structure is used to create templates for badge definition events,
* facilitating the recognition and awarding of badges to users for various achievements.
*/
export type BadgeDefinition = {
/**
* A unique identifier for the badge. This is used to distinguish badges
* from one another and should be unique across all badge definitions.
* Typically, this could be a short, descriptive string.
*/
d: string
/**
* An optional short name for the badge. This provides a human-readable
* title for the badge, making it easier to recognize and refer to.
*/
name?: string
/**
* An optional description for the badge. This field can be used to
* provide more detailed information about the badge, such as the criteria
* for its awarding or its significance.
*/
description?: string
/**
* An optional image URL and dimensions for the badge. The first element
* of the tuple is the URL pointing to a high-resolution image representing
* the badge, and the second element specifies the image's dimensions in
* the format "widthxheight". The recommended dimensions are 1024x1024 pixels.
*/
image?: [string, string]
/**
* An optional list of thumbnail images for the badge. Each element in the
* array is a tuple, where the first element is the URL pointing to a thumbnail
* version of the badge image, and the second element specifies the thumbnail's
* dimensions in the format "widthxheight". Multiple thumbnails can be provided
* to support different display sizes.
*/
thumbs?: Array<[string, string]>
}
/**
* Represents the structure for awarding a badge to one or more recipients
* within the Nostr network. This structure is used to create templates for
* badge award events, which are immutable and signify the recognition of
* individuals' achievements or contributions.
*/
export type BadgeAward = {
/**
* A reference to the Badge Definition event. This is typically composed
* of the event ID of the badge definition. It establishes a clear linkage
* between the badge being awarded and its original definition, ensuring
* that recipients are awarded the correct badge.
*/
a: string
/**
* An array of p tags, each containing a pubkey and its associated relays.
*/
p: string[][]
}
/**
* Represents the collection of badges a user chooses to display on their profile.
* This structure is crucial for applications that allow users to showcase achievements
* or recognitions in the form of badges, following the specifications of NIP-58.
*/
export type ProfileBadges = {
/**
* A unique identifier for the profile badges collection. According to NIP-58,
* this should be set to "profile_badges" to differentiate it from other event types.
*/
d: 'profile_badges'
/**
* A list of badges that the user has elected to display on their profile. Each item
* in the array represents a specific badge, including references to both its definition
* and the award event.
*/
badges: Array<{
/**
* The event address of the badge definition. This is a reference to the specific badge
* being displayed, linking back to the badge's original definition event. It allows
* clients to fetch and display the badge's details, such as its name, description,
* and image.
*/
a: string
/**
* The event id of the badge award with corresponding relays. This references the event
* in which the badge was awarded to the user. It is crucial for verifying the
* authenticity of the badge display, ensuring that the user was indeed awarded the
* badge they are choosing to display.
*/
e: string[]
}>
}
/**
* Generates an EventTemplate based on the provided BadgeDefinition.
*
* @param {BadgeDefinition} badgeDefinition - The BadgeDefinition object.
* @returns {EventTemplate} - The generated EventTemplate object.
*/
export function generateBadgeDefinitionEventTemplate({
d,
description,
image,
name,
thumbs,
}: BadgeDefinition): EventTemplate {
// Mandatory tags
const tags: string[][] = [['d', d]]
// Append optional tags
name && tags.push(['name', name])
description && tags.push(['description', description])
image && tags.push(['image', ...image])
if (thumbs) {
for (const thumb of thumbs) {
tags.push(['thumb', ...thumb])
}
}
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeDefinitionKind,
tags,
}
return eventTemplate
}
/**
* Validates a badge definition event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is a valid badge definition event.
*/
export function validateBadgeDefinitionEvent(event: Event): boolean {
if (event.kind !== BadgeDefinitionKind) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Generates an EventTemplate based on the provided BadgeAward.
*
* @param {BadgeAward} badgeAward - The BadgeAward object.
* @returns {EventTemplate} - The generated EventTemplate object.
*/
export function generateBadgeAwardEventTemplate({ a, p }: BadgeAward): EventTemplate {
// Mandatory tags
const tags: string[][] = [['a', a]]
for (const _p of p) {
tags.push(['p', ..._p])
}
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: BadgeAwardKind,
tags,
}
return eventTemplate
}
/**
* Validates a badge award event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is a valid badge award event.
*/
export function validateBadgeAwardEvent(event: Event): boolean {
if (event.kind !== BadgeAwardKind) return false
const requiredTags = ['a', 'p'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Generates an EventTemplate based on the provided ProfileBadges.
*
* @param {ProfileBadges} profileBadges - The ProfileBadges object.
* @returns {EventTemplate} - The generated EventTemplate object.
*/
export function generateProfileBadgesEventTemplate({ badges }: ProfileBadges): EventTemplate {
// Mandatory tags
const tags: string[][] = [['d', 'profile_badges']]
// Append optional tags
for (const badge of badges) {
tags.push(['a', badge.a], ['e', ...badge.e])
}
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: ProfileBadgesKind,
tags,
}
return eventTemplate
}
/**
* Validates a profile badges event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is a valid profile badges event.
*/
export function validateProfileBadgesEvent(event: Event): boolean {
if (event.kind !== ProfileBadgesKind) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}

View File

@@ -1,113 +0,0 @@
import { test, expect } from 'bun:test'
import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts'
import { decode } from './nip19.ts'
import { NostrEvent, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
import { GiftWrap } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data as Uint8Array
const recipientPublicKey = getPublicKey(recipientPrivateKey)
const event = {
kind: 1,
content: 'Are you going to the party tonight?',
}
const wrappedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
[Symbol('verified')]: true,
}
const result = wrapEvent(event, senderPrivateKey, recipientPublicKey)
expect(result.kind).toEqual(expected.kind)
expect(result.tags).toEqual(expected.tags)
})
test('wrapManyEvent', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(event, senderPrivateKey, [recipientPublicKey])
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 1,
content: 'Are you going to the party tonight?',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [],
}
const result = unwrapEvent(wrappedEvent, recipientPrivateKey)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})
test('getWrappedEvents and unwrapManyEvents', async () => {
const expected = [
{
created_at: 1729721879,
content: 'Hello!',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3',
},
{
created_at: 1729722025,
content: 'How are you?',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77',
},
]
const relays = ['wss://relay.damus.io', 'wss://nos.lol']
const privateKey = hexToBytes('582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6')
const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa'
const pool = new SimplePool()
const wrappedEvents: NostrEvent[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [publicKey] })
const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey)
unwrappedEvents.forEach((event, index) => {
expect(event).toEqual(expected[index])
})
})

107
nip59.ts
View File

@@ -1,107 +0,0 @@
import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts'
import { Seal, GiftWrap } from './kinds.ts'
type Rumor = UnsignedEvent & { id: string }
const TWO_DAYS = 2 * 24 * 60 * 60
const now = () => Math.round(Date.now() / 1000)
const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) => getConversationKey(privateKey, publicKey)
const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))
const nip44Decrypt = (data: NostrEvent, privateKey: Uint8Array) =>
JSON.parse(decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array): Rumor {
const rumor = {
created_at: now(),
content: '',
tags: [],
...event,
pubkey: getPublicKey(privateKey),
} as any
rumor.id = getEventHash(rumor)
return rumor as Rumor
}
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string): NostrEvent {
return finalizeEvent(
{
kind: Seal,
content: nip44Encrypt(rumor, privateKey, recipientPublicKey),
created_at: randomNow(),
tags: [],
},
privateKey,
)
}
export function createWrap(seal: NostrEvent, recipientPublicKey: string): NostrEvent {
const randomKey = generateSecretKey()
return finalizeEvent(
{
kind: GiftWrap,
content: nip44Encrypt(seal, randomKey, recipientPublicKey),
created_at: randomNow(),
tags: [['p', recipientPublicKey]],
},
randomKey,
) as NostrEvent
}
export function wrapEvent(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientPublicKey: string,
): NostrEvent {
const rumor = createRumor(event, senderPrivateKey)
const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
return createWrap(seal, recipientPublicKey)
}
export function wrapManyEvents(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientsPublicKeys: string[],
): NostrEvent[] {
if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]
recipientsPublicKeys.forEach(recipientPublicKey => {
wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey))
})
return wrappeds
}
export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor {
const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
return nip44Decrypt(unwrappedSeal, recipientPrivateKey)
}
export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] {
let unwrappedEvents: Rumor[] = []
wrappedEvents.forEach(e => {
unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey))
})
unwrappedEvents.sort((a, b) => a.created_at - b.created_at)
return unwrappedEvents
}

View File

@@ -1,203 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { ZapGoal } from './kinds.ts'
import { Goal, generateGoalEventTemplate, validateZapGoalEvent } from './nip75.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
describe('Goal Type', () => {
it('should create a proper Goal object', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
a: 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
expect(goal.content).toBe('Fundraising for a new project')
expect(goal.amount).toBe('100000000')
expect(goal.relays).toEqual(['wss://relay1.example.com', 'wss://relay2.example.com'])
expect(goal.closedAt).toBe(1671150419)
expect(goal.image).toBe('https://example.com/goal-image.jpg')
expect(goal.summary).toBe('Help us reach our fundraising goal!')
expect(goal.r).toBe('https://example.com/additional-info')
expect(goal.a).toBe('fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146')
expect(goal.zapTags).toEqual([
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
])
})
})
describe('generateGoalEventTemplate', () => {
it('should generate an EventTemplate for a fundraising goal', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const eventTemplate = generateGoalEventTemplate(goal)
expect(eventTemplate.kind).toBe(ZapGoal)
expect(eventTemplate.content).toBe('Fundraising for a new project')
expect(eventTemplate.tags).toEqual([
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
])
})
it('should generate an EventTemplate for a fundraising goal without optional properties', () => {
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
}
const eventTemplate = generateGoalEventTemplate(goal)
expect(eventTemplate.kind).toBe(ZapGoal)
expect(eventTemplate.content).toBe('Fundraising for a new project')
expect(eventTemplate.tags).toEqual([
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
])
})
it('should generate an EventTemplate that is valid', () => {
const sk = generateSecretKey()
const goal: Goal = {
content: 'Fundraising for a new project',
amount: '100000000',
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
closedAt: 1671150419,
image: 'https://example.com/goal-image.jpg',
summary: 'Help us reach our fundraising goal!',
r: 'https://example.com/additional-info',
zapTags: [
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const eventTemplate = generateGoalEventTemplate(goal)
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(true)
})
})
describe('validateZapGoalEvent', () => {
it('should validate a proper Goal event', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(true)
})
it('should not validate an event with an incorrect kind', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: 0, // Incorrect kind
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
it('should not validate an event with missing required "amount" tag', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
// Missing "amount" tag
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
it('should not validate an event with missing required "relays" tag', () => {
const sk = generateSecretKey()
const eventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content: 'Fundraising for a new project',
tags: [
['amount', '100000000'],
// Missing "relays" tag
['closed_at', '1671150419'],
['image', 'https://example.com/goal-image.jpg'],
['summary', 'Help us reach our fundraising goal!'],
['r', 'https://example.com/additional-info'],
['zap', 'beneficiary1'],
['zap', 'beneficiary2'],
],
}
const event = finalizeEvent(eventTemplate, sk)
const isValid = validateZapGoalEvent(event)
expect(isValid).toBe(false)
})
})

115
nip75.ts
View File

@@ -1,115 +0,0 @@
import { Event, EventTemplate } from './core.ts'
import { ZapGoal } from './kinds.ts'
/**
* Represents a fundraising goal in the Nostr network as defined by NIP-75.
* This type is used to structure the information needed to create a goal event (`kind:9041`).
*/
export type Goal = {
/**
* A human-readable description of the fundraising goal.
* This content should provide clear information about the purpose of the fundraising.
*/
content: string
/**
* The target amount for the fundraising goal in milisats.
* This defines the financial target that the fundraiser aims to reach.
*/
amount: string
/**
* A list of relays where the zaps towards this goal will be sent to and tallied from.
* Each relay is represented by its WebSocket URL.
*/
relays: string[]
/**
* An optional timestamp (in seconds, UNIX epoch) indicating when the fundraising goal is considered closed.
* Zaps published after this timestamp should not count towards the goal progress.
* If not provided, the goal remains open indefinitely or until manually closed.
*/
closedAt?: number
/**
* An optional URL to an image related to the goal.
* This can be used to visually represent the goal on client interfaces.
*/
image?: string
/**
* An optional brief description or summary of the goal.
* This can provide a quick overview of the goal, separate from the detailed `content`.
*/
summary?: string
/**
* An optional URL related to the goal, providing additional information or actions through an 'r' tag.
* This is a single URL, as per NIP-75 specifications for linking additional resources.
*/
r?: string
/**
* An optional parameterized replaceable event linked to the goal, specified through an 'a' tag.
* This is a single event id, aligning with NIP-75's allowance for linking to specific events.
*/
a?: string
/**
* Optional tags specifying multiple beneficiary pubkeys or additional criteria for zapping,
* allowing contributions to be directed towards multiple recipients or according to specific conditions.
*/
zapTags?: string[][]
}
/**
* Generates an EventTemplate for a fundraising goal based on the provided ZapGoal object.
* This function is tailored to fit the structure of EventTemplate as defined in the library.
* @param zapGoal The ZapGoal object containing the details of the fundraising goal.
* @returns An EventTemplate object structured for creating a Nostr event.
*/
export function generateGoalEventTemplate({
amount,
content,
relays,
a,
closedAt,
image,
r,
summary,
zapTags,
}: Goal): EventTemplate {
const tags: string[][] = [
['amount', amount],
['relays', ...relays],
]
// Append optional tags based on the presence of optional properties in zapGoal
closedAt && tags.push(['closed_at', closedAt.toString()])
image && tags.push(['image', image])
summary && tags.push(['summary', summary])
r && tags.push(['r', r])
a && tags.push(['a', a])
zapTags && tags.push(...zapTags)
// Construct the EventTemplate object
const eventTemplate: EventTemplate = {
created_at: Math.floor(Date.now() / 1000),
kind: ZapGoal,
content,
tags,
}
return eventTemplate
}
export function validateZapGoalEvent(event: Event): boolean {
if (event.kind !== ZapGoal) return false
const requiredTags = ['amount', 'relays'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}

View File

@@ -1,390 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { Event, EventTemplate } from './core.ts'
import { FileMetadata as FileMetadataKind } from './kinds.ts'
import { FileMetadataObject, generateEventTemplate, parseEvent, validateEvent } from './nip94.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
describe('generateEventTemplate', () => {
it('should generate the correct event template', () => {
const fileMetadataObject: FileMetadataObject = {
content: 'Lorem ipsum dolor sit amet',
url: 'https://example.com/image.jpg',
m: 'image/jpeg',
x: 'image',
ox: 'original',
size: '1024',
dim: '800x600',
i: 'abc123',
blurhash: 'abcdefg',
thumb: 'https://example.com/thumb.jpg',
image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum',
alt: 'Image alt text',
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
}
const expectedEventTemplate: EventTemplate = {
content: 'Lorem ipsum dolor sit amet',
created_at: expect.any(Number),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback1.example.com/image.jpg'],
['fallback', 'https://fallback2.example.com/image.jpg'],
],
}
const eventTemplate = generateEventTemplate(fileMetadataObject)
expect(eventTemplate).toEqual(expectedEventTemplate)
})
})
describe('validateEvent', () => {
it('should return true for a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(event)).toBe(true)
})
it('should return false if kind is not FileMetadataKind', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: 0, // not FileMetadataKind
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if content is empty', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: '', // empty
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if required tags are missing', () => {
const sk = generateSecretKey()
const eventWithoutUrl: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
// missing url
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
const eventWithoutM: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
// missing m
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
const eventWithoutX: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
// missing x
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
const eventWithoutOx: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
// missing ox
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(eventWithoutUrl)).toBe(false)
expect(validateEvent(eventWithoutM)).toBe(false)
expect(validateEvent(eventWithoutX)).toBe(false)
expect(validateEvent(eventWithoutOx)).toBe(false)
})
it('should return false if size is not a number', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', 'abc'], // not a number
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
it('should return false if dim is not a valid dimension string', () => {
const sk = generateSecretKey()
const eventWithInvalidDim: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', 'abc'], // invalid dim
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(validateEvent(eventWithInvalidDim)).toBe(false)
})
})
describe('parseEvent', () => {
it('should parse a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: 'Lorem ipsum dolor sit amet',
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback1.example.com/image.jpg'],
['fallback', 'https://fallback2.example.com/image.jpg'],
],
},
sk,
)
const parsedEvent = parseEvent(event)
expect(parsedEvent).toEqual({
content: 'Lorem ipsum dolor sit amet',
url: 'https://example.com/image.jpg',
m: 'image/jpeg',
x: 'image',
ox: 'original',
size: '1024',
dim: '800x600',
i: 'abc123',
blurhash: 'abcdefg',
thumb: 'https://example.com/thumb.jpg',
image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum',
alt: 'Image alt text',
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
})
})
it('should throw an error if the event is invalid', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
content: '', // invalid
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', 'https://example.com/image.jpg'],
['m', 'image/jpeg'],
['x', 'image'],
['ox', 'original'],
['size', '1024'],
['dim', '800x600'],
['i', 'abc123'],
['blurhash', 'abcdefg'],
['thumb', 'https://example.com/thumb.jpg'],
['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'],
['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
],
},
sk,
)
expect(() => parseEvent(event)).toThrow('Invalid event')
})
})

211
nip94.ts
View File

@@ -1,211 +0,0 @@
import { Event, EventTemplate } from './core.ts'
import { FileMetadata as FileMetadataKind } from './kinds.ts'
/**
* Type definition for File Metadata as specified in NIP-94.
* This type is used to represent the metadata associated with a file sharing event (kind: 1063).
*/
export type FileMetadataObject = {
/**
* A description or caption for the file content.
*/
content: string
/**
* The URL to download the file.
*/
url: string
/**
* The MIME type of the file, in lowercase.
*/
m: string
/**
* The SHA-256 hex-encoded string of the file.
*/
x: string
/**
* The SHA-256 hex-encoded string of the original file, before any transformations done by the upload server.
*/
ox: string
/**
* Optional: The size of the file in bytes.
*/
size?: string
/**
* Optional: The dimensions of the file in pixels, in the format "<width>x<height>".
*/
dim?: string
/**
* Optional: The URI to the magnet file.
*/
magnet?: string
/**
* Optional: The torrent infohash.
*/
i?: string
/**
* Optional: The blurhash string to show while the file is being loaded by the client.
*/
blurhash?: string
/**
* Optional: The URL of the thumbnail image with the same aspect ratio as the original file.
*/
thumb?: string
/**
* Optional: The URL of a preview image with the same dimensions as the original file.
*/
image?: string
/**
* Optional: A text excerpt or summary of the file's content.
*/
summary?: string
/**
* Optional: A description for accessibility, providing context or a brief description of the file.
*/
alt?: string
/**
* Optional: fallback URLs in case url fails.
*/
fallback?: string[]
}
/**
* Generates an event template based on a file metadata object.
*
* @param fileMetadata - The file metadata object.
* @returns The event template.
*/
export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTemplate {
const eventTemplate: EventTemplate = {
content: fileMetadata.content,
created_at: Math.floor(Date.now() / 1000),
kind: FileMetadataKind,
tags: [
['url', fileMetadata.url],
['m', fileMetadata.m],
['x', fileMetadata.x],
['ox', fileMetadata.ox],
],
}
if (fileMetadata.size) eventTemplate.tags.push(['size', fileMetadata.size])
if (fileMetadata.dim) eventTemplate.tags.push(['dim', fileMetadata.dim])
if (fileMetadata.i) eventTemplate.tags.push(['i', fileMetadata.i])
if (fileMetadata.blurhash) eventTemplate.tags.push(['blurhash', fileMetadata.blurhash])
if (fileMetadata.thumb) eventTemplate.tags.push(['thumb', fileMetadata.thumb])
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
if (fileMetadata.fallback) fileMetadata.fallback.forEach(url => eventTemplate.tags.push(['fallback', url]))
return eventTemplate
}
/**
* Validates an event to ensure it is a valid file metadata event.
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateEvent(event: Event): boolean {
if (event.kind !== FileMetadataKind) return false
if (!event.content) return false
const requiredTags = ['url', 'm', 'x', 'ox'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
// validate optional size tag
const sizeTag = event.tags.find(([t]) => t == 'size')
if (sizeTag && isNaN(Number(sizeTag[1]))) return false
// validate optional dim tag
const dimTag = event.tags.find(([t]) => t == 'dim')
if (dimTag && !dimTag[1].match(/^\d+x\d+$/)) return false
return true
}
/**
* Parses an event and returns a file metadata object.
* @param event - The event to parse.
* @returns The file metadata object.
* @throws Error if the event is invalid.
*/
export function parseEvent(event: Event): FileMetadataObject {
if (!validateEvent(event)) {
throw new Error('Invalid event')
}
const fileMetadata: FileMetadataObject = {
content: event.content,
url: '',
m: '',
x: '',
ox: '',
}
for (const [tag, value] of event.tags) {
switch (tag) {
case 'url':
fileMetadata.url = value
break
case 'm':
fileMetadata.m = value
break
case 'x':
fileMetadata.x = value
break
case 'ox':
fileMetadata.ox = value
break
case 'size':
fileMetadata.size = value
break
case 'dim':
fileMetadata.dim = value
break
case 'magnet':
fileMetadata.magnet = value
break
case 'i':
fileMetadata.i = value
break
case 'blurhash':
fileMetadata.blurhash = value
break
case 'thumb':
fileMetadata.thumb = value
break
case 'image':
fileMetadata.image = value
break
case 'summary':
fileMetadata.summary = value
break
case 'alt':
fileMetadata.alt = value
break
case 'fallback':
fileMetadata.fallback ??= []
fileMetadata.fallback.push(value)
break
}
}
return fileMetadata
}

View File

@@ -1,350 +1,130 @@
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { describe, expect, test } from 'bun:test'
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
import { Event, Kind, finishEvent } from './event.ts'
import { generatePrivateKey, getPublicKey } from './keys.ts'
import { HTTPAuth } from './kinds.ts'
import {
getToken,
hashPayload,
unpackEventFromToken,
validateEvent,
validateEventKind,
validateEventMethodTag,
validateEventPayloadTag,
validateEventTimestamp,
validateEventUrlTag,
validateToken,
} from './nip98.ts'
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { utf8Encoder } from './utils.ts'
const sk = generatePrivateKey()
describe('getToken', () => {
test('returns without authorization scheme for GET', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const unpackedEvent: Event = await unpackEventFromToken(token)
test('getToken GET returns without authorization scheme', async () => {
let result = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'get'],
])
})
test('returns token without authorization scheme for POST', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
const unpackedEvent: Event = await unpackEventFromToken(token)
test('getToken POST returns token without authorization scheme', async () => {
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk))
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('returns token WITH authorization scheme for POST', async () => {
test('getToken GET returns token WITH authorization scheme', async () => {
const authorizationScheme = 'Nostr '
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
expect(token.startsWith(authorizationScheme)).toBe(true)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk), true)
expect(result.startsWith(authorizationScheme)).toBe(true)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('returns token with a valid payload tag when payload is present', async () => {
const sk = generateSecretKey()
const payload = { test: 'payload' }
const payloadHash = hashPayload(payload)
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
const unpackedEvent: Event = await unpackEventFromToken(token)
test('getToken missing loginUrl throws an error', async () => {
const result = getToken('', 'get', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
['payload', payloadHash],
])
test('getToken missing httpMethod throws an error', async () => {
const result = getToken('http://test.com', '', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
})
describe('validateToken', () => {
test('returns true for valid token without authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
test('validateToken returns true for valid token without authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
expect(isTokenValid).toBe(true)
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('returns true for valid token with authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
test('validateToken returns true for valid token with authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
expect(isTokenValid).toBe(true)
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('throws an error for invalid token', async () => {
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
test('validateToken throws an error for invalid token', async () => {
const result = validateToken('fake', 'http://test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('throws an error for missing token', async () => {
const isTokenValid = validateToken('', 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
test('validateToken throws an error for missing token', async () => {
const result = validateToken('', 'http://test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('throws an error for invalid event kind', async () => {
const sk = generateSecretKey()
const invalidToken = await getToken('http://test.com', 'get', e => {
e.kind = 0
return finalizeEvent(e, sk)
})
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
test('validateToken throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
expect(isTokenValid).rejects.toThrow(Error)
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('throws an error for invalid event timestamp', async () => {
const sk = generateSecretKey()
const invalidToken = await getToken('http://test.com', 'get', e => {
e.created_at = 0
return finalizeEvent(e, sk)
})
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
test('validateToken throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
expect(isTokenValid).rejects.toThrow(Error)
const result = validateToken(validToken, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
})
test('throws an error for invalid url', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
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)
expect(isTokenValid).rejects.toThrow(Error)
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('throws an error for invalid method', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = validateToken(token, 'http://test.com', 'post')
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)
expect(isTokenValid).rejects.toThrow(Error)
})
})
describe('validateEvent', () => {
test('returns true for valid decoded token with authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).toBe(true)
})
test('throws an error for invalid event kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.kind = 0
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid event timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.created_at = 0
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
expect(isEventValid).rejects.toThrow(Error)
})
test('returns true for valid payload tag hash', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
expect(isEventValid).toBe(true)
})
test('returns false for invalid payload tag hash', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
expect(isEventValid).rejects.toThrow(Error)
})
})
describe('validateEventTimestamp', () => {
test('returns true for valid timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
expect(isEventTimestampValid).toBe(true)
})
test('returns false for invalid timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.created_at = 0
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
expect(isEventTimestampValid).toBe(false)
})
})
describe('validateEventKind', () => {
test('returns true for valid kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventKindValid = validateEventKind(unpackedEvent)
expect(isEventKindValid).toBe(true)
})
test('returns false for invalid kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.kind = 0
const isEventKindValid = validateEventKind(unpackedEvent)
expect(isEventKindValid).toBe(false)
})
})
describe('validateEventUrlTag', () => {
test('returns true for valid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
expect(isEventUrlTagValid).toBe(true)
})
test('returns false for invalid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
expect(isEventUrlTagValid).toBe(false)
})
})
describe('validateEventMethodTag', () => {
test('returns true for valid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
expect(isEventMethodTagValid).toBe(true)
})
test('returns false for invalid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
expect(isEventMethodTagValid).toBe(false)
})
})
describe('validateEventPayloadTag', () => {
test('returns true for valid payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
expect(isEventPayloadTagValid).toBe(true)
})
test('returns false for invalid payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
expect(isEventPayloadTagValid).toBe(false)
})
test('returns false for missing payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
expect(isEventPayloadTagValid).toBe(false)
})
})
describe('hashPayload', () => {
test('returns hash for valid payload', async () => {
const payload = { test: 'payload' }
const computedPayloadHash = hashPayload(payload)
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
expect(computedPayloadHash).toBe(expectedPayloadHash)
})
test('returns hash for empty payload', async () => {
const payload = {}
const computedPayloadHash = hashPayload(payload)
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
expect(computedPayloadHash).toBe(expectedPayloadHash)
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)
})
})

162
nip98.ts
View File

@@ -1,10 +1,6 @@
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
import { HTTPAuth } from './kinds.ts'
import { Event, EventTemplate, verifyEvent } from './pure.ts'
import { utf8Decoder, utf8Encoder } from './utils.ts'
import { Event, EventTemplate, Kind, getBlankEvent, verifySignature } from './event'
import { utf8Decoder, utf8Encoder } from './utils'
const _authorizationScheme = 'Nostr '
@@ -18,27 +14,22 @@ const _authorizationScheme = 'Nostr '
export async function getToken(
loginUrl: string,
httpMethod: string,
sign: (e: EventTemplate) => Promise<Event> | Event,
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>,
includeAuthorizationScheme: boolean = false,
payload?: Record<string, any>,
): Promise<string> {
const event: EventTemplate = {
kind: HTTPAuth,
tags: [
['u', loginUrl],
['method', httpMethod],
],
created_at: Math.round(new Date().getTime() / 1000),
content: '',
}
if (!loginUrl || !httpMethod) throw new Error('Missing loginUrl or httpMethod')
if (payload) {
event.tags.push(['payload', hashPayload(payload)])
}
const event = getBlankEvent(Kind.HttpAuth)
event.tags = [
['u', loginUrl],
['method', httpMethod],
]
event.created_at = Math.round(new Date().getTime() / 1000)
const signedEvent = await sign(event)
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
}
@@ -52,7 +43,6 @@ export async function validateToken(token: string, url: string, method: string):
const event = await unpackEventFromToken(token).catch(error => {
throw error
})
const valid = await validateEvent(event, url, method).catch(error => {
throw error
})
@@ -60,18 +50,10 @@ export async function validateToken(token: string, url: string, method: string):
return valid
}
/**
* Unpacks an event from a token.
*
* @param token - The token to unpack.
* @returns A promise that resolves to the unpacked event.
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
*/
export async function unpackEventFromToken(token: string): Promise<Event> {
if (!token) {
throw new Error('Missing token')
}
token = token.replace(_authorizationScheme, '')
const eventB64 = utf8Decoder.decode(base64.decode(token))
@@ -84,123 +66,35 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
return event
}
/**
* Validates the timestamp of an event.
* @param event - The event object to validate.
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
*/
export function validateEventTimestamp(event: Event): boolean {
if (!event.created_at) {
return false
export async function validateEvent(event: Event, url: string, method: string): Promise<boolean> {
if (!event) {
throw new Error('Invalid nostr event')
}
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
}
/**
* Validates the kind of an event.
* @param event The event to validate.
* @returns A boolean indicating whether the event kind is valid.
*/
export function validateEventKind(event: Event): boolean {
return event.kind === HTTPAuth
}
/**
* Validates if the given URL matches the URL tag of the event.
* @param event - The event object.
* @param url - The URL to validate.
* @returns A boolean indicating whether the URL is valid or not.
*/
export function validateEventUrlTag(event: Event, url: string): boolean {
const urlTag = event.tags.find(t => t[0] === 'u')
if (!urlTag) {
return false
}
return urlTag.length > 0 && urlTag[1] === url
}
/**
* Validates if the given event has a method tag that matches the specified method.
* @param event - The event to validate.
* @param method - The method to match against the method tag.
* @returns A boolean indicating whether the event has a matching method tag.
*/
export function validateEventMethodTag(event: Event, method: string): boolean {
const methodTag = event.tags.find(t => t[0] === 'method')
if (!methodTag) {
return false
}
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
}
/**
* Calculates the hash of a payload.
* @param payload - The payload to be hashed.
* @returns The hash value as a string.
*/
export function hashPayload(payload: any): string {
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
return bytesToHex(hash)
}
/**
* Validates the event payload tag against the provided payload.
* @param event The event object.
* @param payload The payload to validate.
* @returns A boolean indicating whether the payload tag is valid.
*/
export function validateEventPayloadTag(event: Event, payload: any): boolean {
const payloadTag = event.tags.find(t => t[0] === 'payload')
if (!payloadTag) {
return false
}
const payloadHash = hashPayload(payload)
return payloadTag.length > 0 && payloadTag[1] === payloadHash
}
/**
* Validates a Nostr event for the NIP-98 flow.
*
* @param event - The Nostr event to validate.
* @param url - The URL associated with the event.
* @param method - The HTTP method associated with the event.
* @param body - The request body associated with the event (optional).
* @returns A promise that resolves to a boolean indicating whether the event is valid.
* @throws An error if the event is invalid.
*/
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
if (!verifyEvent(event)) {
if (!verifySignature(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (!validateEventKind(event)) {
if (event.kind !== Kind.HttpAuth) {
throw new Error('Invalid nostr event, kind invalid')
}
if (!validateEventTimestamp(event)) {
throw new Error('Invalid nostr event, created_at timestamp invalid')
if (!event.created_at) {
throw new Error('Invalid nostr event, created_at invalid')
}
if (!validateEventUrlTag(event, url)) {
// Event must be less than 60 seconds old
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
throw new Error('Invalid nostr event, expired')
}
const urlTag = event.tags.find(t => t[0] === 'u')
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
throw new Error('Invalid nostr event, url tag invalid')
}
if (!validateEventMethodTag(event, method)) {
const methodTag = event.tags.find(t => t[0] === 'method')
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
throw new Error('Invalid nostr event, method tag invalid')
}
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
if (!validateEventPayloadTag(event, body)) {
throw new Error('Invalid nostr event, payload tag does not match request body hash')
}
}
return true
}

View File

@@ -1,506 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { Event } from './core.ts'
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
describe('validateEvent', () => {
test('should return true for a valid classified listing event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(true)
})
test('should return false when the "d" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
// Missing 'd' tag
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "title" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
// Missing 'title' tag
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "summary" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
// Missing 'summary' tag
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "published_at" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
// Missing 'published_at' tag
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "location" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
// Missing 'location' tag
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
// Missing 'price' tag
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "published_at" tag is not a valid timestamp', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', 'not-a-valid-timestamp'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid price', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', 'not-a-valid-price', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid currency', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'not-a-valid-currency'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid number of elements', () => {
const sk = generateSecretKey()
const event1: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event1)).toBe(false)
})
test('should return false when the "a" tag has not a valid number of elements', () => {
const sk = generateSecretKey()
const event1: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['a', 'extra1'],
['a', 'extra2', 'value2', 'extra3'],
],
},
sk,
)
const event2: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['e', 'extra1'],
['e', 'extra2', 'value2', 'extra3'],
],
},
sk,
)
expect(validateEvent(event1)).toBe(false)
expect(validateEvent(event2)).toBe(false)
})
})
describe('parseEvent', () => {
test('should parse a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: DraftClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
const expectedListing = {
title: 'Sample Title',
summary: 'Sample Summary',
publishedAt: '1296962229',
location: 'NYC',
price: {
amount: '100',
currency: 'USD',
},
images: [
{
url: 'https://example.com/image1.jpg',
dimensions: '800x600',
},
{
url: 'https://example.com/image2.jpg',
},
],
hashtags: ['tag1', 'tag2'],
additionalTags: {
e: ['value1', 'value2'],
a: ['value1', 'value2'],
},
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
isDraft: true,
}
expect(parseEvent(event)).toEqual(expectedListing)
})
test('should throw an error for an invalid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: DraftClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
// Missing 'd' tag
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(() => parseEvent(event)).toThrow(Error)
})
})
describe('generateEventTemplate', () => {
test('should generate the correct event template for a classified listing', () => {
const listing: ClassifiedListingObject = {
title: 'Sample Title',
summary: 'Sample Summary',
publishedAt: '1296962229',
location: 'NYC',
price: {
amount: '100',
currency: 'USD',
},
images: [
{
url: 'https://example.com/image1.jpg',
dimensions: '800x600',
},
{
url: 'https://example.com/image2.jpg',
},
],
hashtags: ['tag1', 'tag2'],
additionalTags: {
extra1: 'value1',
extra2: 'value2',
},
content:
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
isDraft: true,
}
const expectedEventTemplate = {
kind: DraftClassifiedListing,
content:
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['published_at', '1296962229'],
['summary', 'Sample Summary'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['extra1', 'value1'],
['extra2', 'value2'],
],
created_at: expect.any(Number),
}
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
})
})

228
nip99.ts
View File

@@ -1,228 +0,0 @@
import { Event, EventTemplate } from './core.ts'
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
/**
* Represents the details of a price.
* @example { amount: '100', currency: 'USD', frequency: 'month' }
* @example { amount: '100', currency: 'EUR' }
*/
export type PriceDetails = {
/**
* The amount of the price.
*/
amount: string
/**
* The currency of the price in 3-letter ISO 4217 format.
* @example 'USD'
*/
currency: string
/**
* The optional frequency of payment.
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
*/
frequency?: string
}
/**
* Represents a classified listing object.
*/
export type ClassifiedListingObject = {
/**
* Whether the listing is a draft or not.
*/
isDraft: boolean
/**
* A title of the listing.
*/
title: string
/**
* A short summary or tagline.
*/
summary: string
/**
* A description in Markdown format.
*/
content: string
/**
* Timestamp in unix seconds of when the listing was published.
*/
publishedAt: string
/**
* Location of the listing.
* @example 'NYC'
*/
location: string
/**
* Price details.
*/
price: PriceDetails
/**
* Images of the listing with optional dimensions.
*/
images: Array<{
url: string
dimensions?: string
}>
/**
* Tags/Hashtags (i.e. categories, keywords, etc.)
*/
hashtags: string[]
/**
* Other standard tags.
* @example "g", a geohash for more precise location
*/
additionalTags: Record<string, string | string[]>
}
/**
* Validates an event to ensure it is a valid classified listing event.
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateEvent(event: Event): boolean {
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
const requiredTagCount = requiredTags.length
const tagCounts: Record<string, number> = {}
if (event.tags.length < requiredTagCount) return false
for (const tag of event.tags) {
if (tag.length < 2) return false
const [tagName, ...tagValues] = tag
if (tagName == 'published_at') {
const timestamp = parseInt(tagValues[0])
if (isNaN(timestamp)) return false
} else if (tagName == 'price') {
if (tagValues.length < 2) return false
const price = parseInt(tagValues[0])
if (isNaN(price) || tagValues[1].length != 3) return false
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
return false
}
if (requiredTags.includes(tagName)) {
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
}
}
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
}
/**
* Parses an event and returns a classified listing object.
* @param event - The event to parse.
* @returns The classified listing object.
* @throws Error if the event is invalid.
*/
export function parseEvent(event: Event): ClassifiedListingObject {
if (!validateEvent(event)) {
throw new Error('Invalid event')
}
const listing: ClassifiedListingObject = {
isDraft: event.kind === DraftClassifiedListing,
title: '',
summary: '',
content: event.content,
publishedAt: '',
location: '',
price: {
amount: '',
currency: '',
},
images: [],
hashtags: [],
additionalTags: {},
}
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
const [tagName, ...tagValues] = tag
if (tagName == 'title') {
listing.title = tagValues[0]
} else if (tagName == 'summary') {
listing.summary = tagValues[0]
} else if (tagName == 'published_at') {
listing.publishedAt = tagValues[0]
} else if (tagName == 'location') {
listing.location = tagValues[0]
} else if (tagName == 'price') {
listing.price.amount = tagValues[0]
listing.price.currency = tagValues[1]
if (tagValues.length == 3) {
listing.price.frequency = tagValues[2]
}
} else if (tagName == 'image') {
listing.images.push({
url: tagValues[0],
dimensions: tagValues?.[1] ?? undefined,
})
} else if (tagName == 't') {
listing.hashtags.push(tagValues[0])
} else if (tagName == 'e' || tagName == 'a') {
listing.additionalTags[tagName] = [...tagValues]
}
}
return listing
}
/**
* Generates an event template based on a classified listing object.
*
* @param listing - The classified listing object.
* @returns The event template.
*/
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
const priceTag = ['price', listing.price.amount, listing.price.currency]
if (listing.price.frequency) priceTag.push(listing.price.frequency)
const tags: string[][] = [
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
['title', listing.title],
['published_at', listing.publishedAt],
['summary', listing.summary],
['location', listing.location],
priceTag,
]
for (let i = 0; i < listing.images.length; i++) {
const image = listing.images[i]
const imageTag = ['image', image.url]
if (image.dimensions) imageTag.push(image.dimensions)
tags.push(imageTag)
}
for (let i = 0; i < listing.hashtags.length; i++) {
const t = listing.hashtags[i]
tags.push(['t', t])
}
for (const [key, value] of Object.entries(listing.additionalTags)) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const val = value[i]
tags.push([key, val])
}
} else {
tags.push([key, value])
}
}
return {
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
content: listing.content,
tags,
created_at: Math.floor(Date.now() / 1000),
}
}

View File

@@ -1,55 +0,0 @@
import { test, expect } from 'bun:test'
import { BlossomClient } from './nipb7.ts'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from './utils.ts'
import { PlainKeySigner } from './signer.ts'
import { generateSecretKey } from './pure.ts'
test('blossom', async () => {
const BLOSSOM_SERVER = 'blossom.primal.net'
const TEST_CONTENT = 'hello world'
const TEST_BLOB = new Blob([TEST_CONTENT], { type: 'text/plain' })
const expectedHash = bytesToHex(sha256(new TextEncoder().encode(TEST_CONTENT)))
const signer = new PlainKeySigner(generateSecretKey())
const client = new BlossomClient(BLOSSOM_SERVER, signer)
expect(client).toBeDefined()
// check for non-existent file should throw
const invalidHash = expectedHash.slice(0, 62) + 'ba'
let hasThrown = false
try {
await client.check(invalidHash)
} catch (err) {
hasThrown = true
}
expect(hasThrown).toBeTrue()
// upload hello world blob
const descriptor = await client.uploadBlob(TEST_BLOB, 'text/plain')
expect(descriptor).toBeDefined()
expect(descriptor.sha256).toBe(expectedHash)
expect(descriptor.size).toBe(TEST_CONTENT.length)
expect(descriptor.type).toBe('text/plain')
expect(descriptor.url).toContain(expectedHash)
expect(descriptor.uploaded).toBeGreaterThan(0)
await client.check(expectedHash)
// download and verify
const downloadedBuffer = await client.download(expectedHash)
const downloadedContent = new TextDecoder().decode(downloadedBuffer)
expect(downloadedContent).toBe(TEST_CONTENT)
// list blobs should include our uploaded file
const blobs = await client.list()
expect(Array.isArray(blobs)).toBe(true)
const ourBlob = blobs.find(blob => blob.sha256 === expectedHash)
expect(ourBlob).toBeDefined()
expect(ourBlob?.type).toBe('text/plain')
expect(ourBlob?.size).toBe(TEST_CONTENT.length)
// delete
await client.delete(expectedHash)
})

203
nipb7.ts
View File

@@ -1,203 +0,0 @@
import { sha256 } from '@noble/hashes/sha256'
import { EventTemplate } from './core.ts'
import { Signer } from './signer.ts'
import { bytesToHex } from './utils.ts'
export type BlobDescriptor = {
url: string
sha256: string
size: number
type: string
uploaded: number
}
export class BlossomClient {
private mediaserver: string
private signer: Signer
constructor(mediaserver: string, signer: Signer) {
if (!mediaserver.startsWith('http')) {
mediaserver = 'https://' + mediaserver
}
this.mediaserver = mediaserver.replace(/\/$/, '') + '/'
this.signer = signer
}
private async httpCall(
method: string,
url: string,
contentType?: string,
addAuthorization?: () => Promise<string>,
body?: File | Blob,
result?: any,
): Promise<any> {
const headers: { [_: string]: string } = {}
if (contentType) {
headers['Content-Type'] = contentType
}
if (addAuthorization) {
const auth = await addAuthorization()
if (auth) {
headers['Authorization'] = auth
}
}
const response = await fetch(this.mediaserver + url, {
method,
headers,
body,
})
if (response.status >= 300) {
const reason = response.headers.get('X-Reason') || response.statusText
throw new Error(`${url} returned an error (${response.status}): ${reason}`)
}
if (result !== null && response.headers.get('content-type')?.includes('application/json')) {
return await response.json()
}
return response
}
private async authorizationHeader(modify?: (event: EventTemplate) => void): Promise<string> {
const now = Math.floor(Date.now() / 1000)
const event: EventTemplate = {
created_at: now,
kind: 24242,
content: 'blossom stuff',
tags: [['expiration', String(now + 60)]],
}
if (modify) {
modify(event)
}
try {
const signedEvent = await this.signer.signEvent(event)
const eventJson = JSON.stringify(signedEvent)
return 'Nostr ' + btoa(eventJson)
} catch (error) {
return ''
}
}
private isValid32ByteHex(hash: string): boolean {
return /^[a-f0-9]{64}$/i.test(hash)
}
async check(hash: string): Promise<void> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
try {
await this.httpCall('HEAD', hash)
} catch (error) {
throw new Error(`failed to check for ${hash}: ${error}`)
}
}
async uploadBlob(file: File | Blob, contentType?: string): Promise<BlobDescriptor> {
const hash = bytesToHex(sha256(new Uint8Array(await file.arrayBuffer())))
const actualContentType = contentType || file.type || 'application/octet-stream'
const bd = await this.httpCall(
'PUT',
'upload',
actualContentType,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'upload'])
evt.tags.push(['x', hash])
}),
file,
{},
)
return bd
}
async uploadFile(file: File): Promise<BlobDescriptor> {
return this.uploadBlob(file, file.type)
}
async download(hash: string): Promise<ArrayBuffer> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
const authHeader = await this.authorizationHeader(evt => {
evt.tags.push(['t', 'get'])
evt.tags.push(['x', hash])
})
const response = await fetch(this.mediaserver + hash, {
method: 'GET',
headers: {
Authorization: authHeader,
},
})
if (response.status >= 300) {
throw new Error(`${hash} is not present in ${this.mediaserver}: ${response.status}`)
}
return await response.arrayBuffer()
}
async downloadAsBlob(hash: string): Promise<Blob> {
const arrayBuffer = await this.download(hash)
return new Blob([arrayBuffer])
}
async list(): Promise<BlobDescriptor[]> {
const pubkey = await this.signer.getPublicKey()
if (!this.isValid32ByteHex(pubkey)) {
throw new Error(`pubkey ${pubkey} is not valid`)
}
try {
const bds = await this.httpCall(
'GET',
`list/${pubkey}`,
undefined,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'list'])
}),
undefined,
[],
)
return bds
} catch (error) {
throw new Error(`failed to list blobs: ${error}`)
}
}
async delete(hash: string): Promise<void> {
if (!this.isValid32ByteHex(hash)) {
throw new Error(`${hash} is not a valid 32-byte hex string`)
}
try {
await this.httpCall(
'DELETE',
hash,
undefined,
() =>
this.authorizationHeader(evt => {
evt.tags.push(['t', 'delete'])
evt.tags.push(['x', hash])
}),
undefined,
null,
)
} catch (error) {
throw new Error(`failed to delete ${hash}: ${error}`)
}
}
}

View File

@@ -1,7 +1,6 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.15.0",
"version": "1.17.0",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -20,45 +19,25 @@
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./core": {
"import": "./lib/esm/core.js",
"require": "./lib/cjs/core.js",
"types": "./lib/types/core.d.ts"
},
"./pure": {
"import": "./lib/esm/pure.js",
"require": "./lib/cjs/pure.js",
"types": "./lib/types/pure.d.ts"
},
"./wasm": {
"import": "./lib/esm/wasm.js",
"require": "./lib/cjs/wasm.js",
"types": "./lib/types/wasm.d.ts"
},
"./kinds": {
"import": "./lib/esm/kinds.js",
"require": "./lib/cjs/kinds.js",
"types": "./lib/types/kinds.d.ts"
},
"./filter": {
"import": "./lib/esm/filter.js",
"require": "./lib/cjs/filter.js",
"types": "./lib/types/filter.d.ts"
},
"./abstract-relay": {
"import": "./lib/esm/abstract-relay.js",
"require": "./lib/cjs/abstract-relay.js",
"types": "./lib/types/abstract-relay.d.ts"
"./keys": {
"import": "./lib/esm/keys.js",
"require": "./lib/cjs/keys.js",
"types": "./lib/types/keys.d.ts"
},
"./relay": {
"import": "./lib/esm/relay.js",
"require": "./lib/cjs/relay.js",
"types": "./lib/types/relay.d.ts"
},
"./abstract-pool": {
"import": "./lib/esm/abstract-pool.js",
"require": "./lib/cjs/abstract-pool.js",
"types": "./lib/types/abstract-pool.d.ts"
"./event": {
"import": "./lib/esm/event.js",
"require": "./lib/cjs/event.js",
"types": "./lib/types/event.d.ts"
},
"./filter": {
"import": "./lib/esm/filter.js",
"require": "./lib/cjs/filter.js",
"types": "./lib/types/filter.d.ts"
},
"./pool": {
"import": "./lib/esm/pool.js",
@@ -85,29 +64,16 @@
"require": "./lib/cjs/nip06.js",
"types": "./lib/types/nip06.d.ts"
},
"./nip07": {
"types": "./lib/types/nip07.d.ts"
},
"./nip10": {
"import": "./lib/esm/nip10.js",
"require": "./lib/cjs/nip10.js",
"types": "./lib/types/nip10.d.ts"
},
"./nip11": {
"import": "./lib/esm/nip11.js",
"require": "./lib/cjs/nip11.js",
"types": "./lib/types/nip11.d.ts"
},
"./nip13": {
"import": "./lib/esm/nip13.js",
"require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts"
},
"./nip17": {
"import": "./lib/esm/nip17.js",
"require": "./lib/cjs/nip17.js",
"types": "./lib/types/nip17.d.ts"
},
"./nip18": {
"import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js",
@@ -128,6 +94,11 @@
"require": "./lib/cjs/nip25.js",
"types": "./lib/types/nip25.d.ts"
},
"./nip26": {
"import": "./lib/esm/nip26.js",
"require": "./lib/cjs/nip26.js",
"types": "./lib/types/nip26.d.ts"
},
"./nip27": {
"import": "./lib/esm/nip27.js",
"require": "./lib/cjs/nip27.js",
@@ -138,16 +109,6 @@
"require": "./lib/cjs/nip28.js",
"types": "./lib/types/nip28.d.ts"
},
"./nip29": {
"import": "./lib/esm/nip29.js",
"require": "./lib/cjs/nip29.js",
"types": "./lib/types/nip29.d.ts"
},
"./nip30": {
"import": "./lib/esm/nip30.js",
"require": "./lib/cjs/nip30.js",
"types": "./lib/types/nip30.d.ts"
},
"./nip39": {
"import": "./lib/esm/nip39.js",
"require": "./lib/cjs/nip39.js",
@@ -163,71 +124,21 @@
"require": "./lib/cjs/nip44.js",
"types": "./lib/types/nip44.d.ts"
},
"./nip46": {
"import": "./lib/esm/nip46.js",
"require": "./lib/cjs/nip46.js",
"types": "./lib/types/nip46.d.ts"
},
"./nip49": {
"import": "./lib/esm/nip49.js",
"require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts"
},
"./nip54": {
"import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.d.ts"
},
"./nip57": {
"import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts"
},
"./nip59": {
"import": "./lib/esm/nip59.js",
"require": "./lib/cjs/nip59.js",
"types": "./lib/types/nip59.d.ts"
},
"./nip58": {
"import": "./lib/esm/nip58.js",
"require": "./lib/cjs/nip58.js",
"types": "./lib/types/nip58.d.ts"
},
"./nip75": {
"import": "./lib/esm/nip75.js",
"require": "./lib/cjs/nip75.js",
"types": "./lib/types/nip75.d.ts"
},
"./nip94": {
"import": "./lib/esm/nip94.js",
"require": "./lib/cjs/nip94.js",
"types": "./lib/types/nip94.d.ts"
},
"./nip98": {
"import": "./lib/esm/nip98.js",
"require": "./lib/cjs/nip98.js",
"types": "./lib/types/nip98.d.ts"
},
"./nip99": {
"import": "./lib/esm/nip99.js",
"require": "./lib/cjs/nip99.js",
"types": "./lib/types/nip99.d.ts"
},
"./nipb7": {
"import": "./lib/esm/nipb7.js",
"require": "./lib/cjs/nipb7.js",
"types": "./lib/types/nipb7.d.ts"
},
"./fakejson": {
"import": "./lib/esm/fakejson.js",
"require": "./lib/cjs/fakejson.js",
"types": "./lib/types/fakejson.d.ts"
},
"./signer": {
"import": "./lib/esm/signer.js",
"require": "./lib/cjs/signer.js",
"types": "./lib/types/signer.d.ts"
},
"./utils": {
"import": "./lib/esm/utils.js",
"require": "./lib/cjs/utils.js",
@@ -236,13 +147,12 @@
},
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/ciphers": "0.2.0",
"@noble/curves": "1.1.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
"@scure/bip39": "1.2.1"
},
"peerDependencies": {
"typescript": ">=5.0.0"
@@ -259,23 +169,31 @@
"client",
"nostr"
],
"scripts": {
"build": "node build && tsc",
"format": "prettier --plugin-search-dir . --write .",
"test": "jest"
},
"devDependencies": {
"@types/jest": "^29.5.1",
"@types/node": "^18.13.0",
"@types/node-fetch": "^2.6.3",
"@typescript-eslint/eslint-plugin": "^6.5.0",
"@typescript-eslint/parser": "^6.5.0",
"bun-types": "^1.0.18",
"esbuild": "0.16.9",
"eslint": "^8.56.0",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-jest": "^27.2.3",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"mitata": "^0.1.6",
"mock-socket": "^9.3.1",
"jest": "^29.5.0",
"node-fetch": "^2.6.9",
"prettier": "^3.0.3",
"typescript": "^5.8.2"
},
"scripts": {
"prepublish": "just build"
"ts-jest": "^29.1.0",
"tsd": "^0.22.0",
"typescript": "^5.0.4",
"websocket-polyfill": "^0.0.3"
}
}

View File

@@ -1,31 +1,38 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import 'websocket-polyfill'
import { SimplePool, useWebSocketImplementation } from './pool.ts'
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
import { hexToBytes } from '@noble/hashes/utils'
import { finishEvent, type Event } from './event.ts'
import { generatePrivateKey, getPublicKey } from './keys.ts'
import { SimplePool } from './pool.ts'
useWebSocketImplementation(MockWebSocketClient)
let pool = new SimplePool()
let pool: SimplePool
let mockRelays: MockRelay[]
let relayURLs: string[]
let relays = [
'wss://relay.damus.io/',
'wss://relay.nostr.bg/',
'wss://nostr.fmt.wiz.biz/',
'wss://relay.nostr.band/',
'wss://nos.lol/',
]
beforeEach(() => {
pool = new SimplePool()
mockRelays = Array.from({ length: 10 }, () => new MockRelay())
relayURLs = mockRelays.map(mr => mr.url)
afterAll(() => {
pool.close([...relays, 'wss://nostr.wine', 'wss://offchain.pub', 'wss://eden.nostr.land'])
})
afterEach(() => {
pool.close(relayURLs)
})
test('removing duplicates when subscribing', async () => {
let priv = generateSecretKey()
test('removing duplicates when querying', async () => {
let priv = generatePrivateKey()
let pub = getPublicKey(priv)
let sub = pool.sub(relays, [{ authors: [pub] }])
let received: Event[] = []
let event = finalizeEvent(
sub.on('event', event => {
// this should be called only once even though we're listening
// to multiple relays because the events will be catched and
// deduplicated efficiently (without even being parsed)
received.push(event)
})
let event = finishEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test',
@@ -35,40 +42,31 @@ test('removing duplicates when subscribing', async () => {
priv,
)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event: Event) {
// this should be called only once even though we're listening
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
})
pool.publish(relays, event)
await Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event)
})
test('same with double subs', async () => {
let priv = generateSecretKey()
test('same with double querying', async () => {
let priv = generatePrivateKey()
let pub = getPublicKey(priv)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
let sub1 = pool.sub(relays, [{ authors: [pub] }])
let sub2 = pool.sub(relays, [{ authors: [pub] }])
let received: Event[] = []
let event = finalizeEvent(
sub1.on('event', event => {
received.push(event)
})
sub2.on('event', event => {
received.push(event)
})
let event = finishEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test2',
@@ -78,160 +76,55 @@ test('same with double subs', async () => {
priv,
)
await Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
pool.publish(relays, event)
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(2)
})
test('subscribe many map', async () => {
let priv = hexToBytes('8ea002840d413ccdd5be98df5dd89d799eaa566355ede83ca0bbdbb4b145e0d3')
let pub = getPublicKey(priv)
let received: Event[] = []
let event1 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test1',
kind: 20001,
tags: [],
},
priv,
)
let event2 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test2',
kind: 20002,
tags: [['t', 'biloba']],
},
priv,
)
let event3 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test3',
kind: 20003,
tags: [['t', 'biloba']],
},
priv,
)
const [relayA, relayB, relayC] = relayURLs
pool.subscribeMap(
[
{ url: relayA, filter: { authors: [pub], kinds: [20001] } },
{ url: relayB, filter: { authors: [pub], kinds: [20002] } },
{ url: relayC, filter: { kinds: [20003], '#t': ['biloba'] } },
],
{
onevent(event: Event) {
received.push(event)
},
},
)
// publish the first
await Promise.all(pool.publish([relayA, relayB], event1))
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event1)
// publish the second
await pool.publish([relayB], event2)[0]
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(2)
expect(received[1]).toEqual(event2)
// publish a events that shouldn't match our filters
await Promise.all([
...pool.publish([relayA, relayB], event3),
...pool.publish([relayA, relayB, relayC], event1),
pool.publish([relayA, relayB, relayC], event2),
])
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(2)
// publsih the third
await pool.publish([relayC], event3)[0]
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(3)
expect(received[2]).toEqual(event3)
})
test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>()
await new Promise<void>(resolve => {
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
onevent(event) {
events.add(event.id)
},
onclose: resolve as any,
})
})
expect(events.size).toBeGreaterThan(50)
})
test('querySync()', async () => {
let authors = mockRelays.flatMap(mr => mr.authors)
let events = await pool.querySync(relayURLs, {
authors: authors,
kinds: [1],
limit: 2,
})
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
// the actual received number will be greater than 2, but there will be no duplicates
expect(events.length).toBeGreaterThan(2)
expect(events).toHaveLength(uniqueEventCount)
})
test('get()', async () => {
let ids = mockRelays.flatMap(mr => mr.ids)
let event = await pool.get(relayURLs, {
ids: [ids[0]],
let event = await pool.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
})
expect(event).not.toBeNull()
expect(event).toHaveProperty('id', ids[0])
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
})
test('track relays when publishing', async () => {
let event1 = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
)
let event2 = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
test('list()', async () => {
let events = await pool.list(
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
[
{
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
kinds: [1],
limit: 2,
},
],
)
pool.trackRelays = true
await Promise.all(pool.publish(relayURLs, event1))
expect(pool.seenOn.get(event1.id)).toBeDefined()
expect(Array.from(pool.seenOn.get(event1.id)!).map(r => r.url)).toEqual(expect.arrayContaining(relayURLs))
// the actual received number will be greater than 2, but there will be no duplicates
expect(events.length).toEqual(
events
.map(evt => evt.id)
// @ts-ignore ???
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), []).length,
)
pool.trackRelays = false
await Promise.all(pool.publish(relayURLs, event2))
expect(pool.seenOn.get(event2.id)).toBeUndefined()
let relaysForAllEvents = events.map(event => pool.seenOn(event.id)).reduce((acc, n) => acc.concat(n), [])
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
})
test('seenOnEnabled: false', async () => {
const poolWithoutSeenOn = new SimplePool({ seenOnEnabled: false })
const event = await poolWithoutSeenOn.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
})
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)
expect(relaysForEvent).toHaveLength(0)
})

259
pool.ts
View File

@@ -1,22 +1,249 @@
/* global WebSocket */
import { eventsGenerator, relayInit, type Relay, type Sub, type SubscriptionOptions } from './relay.ts'
import { normalizeURL } from './utils.ts'
import { verifyEvent } from './pure.ts'
import { AbstractSimplePool } from './abstract-pool.ts'
import type { Event } from './event.ts'
import { matchFilters, mergeFilters, type Filter } from './filter.ts'
var _WebSocket: typeof WebSocket
try {
_WebSocket = WebSocket
} catch {}
export function useWebSocketImplementation(websocketImplementation: any) {
_WebSocket = websocketImplementation
type BatchedRequest = {
filters: Filter<any>[]
relays: string[]
resolve: (events: Event<any>[]) => void
events: Event<any>[]
}
export class SimplePool extends AbstractSimplePool {
constructor() {
super({ verifyEvent, websocketImplementation: _WebSocket })
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 {
relays.forEach(url => {
let relay = this._conn[normalizeURL(url)]
if (relay) relay.close()
})
}
async ensureRelay(url: string): Promise<Relay> {
const nm = normalizeURL(url)
if (!this._conn[nm]) {
this._conn[nm] = relayInit(nm, {
getTimeout: this.getTimeout * 0.9,
listTimeout: this.getTimeout * 0.9,
})
}
const relay = this._conn[nm]
await relay.connect()
return relay
}
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
let _knownIds: Set<string> = new Set()
let modifiedOpts = { ...(opts || {}) }
modifiedOpts.alreadyHaveEvent = (id, url) => {
if (opts?.alreadyHaveEvent?.(id, url)) {
return true
}
if (this.seenOnEnabled) {
let set = this._seenOn[id] || new Set()
set.add(url)
this._seenOn[id] = set
}
return _knownIds.has(id)
}
let subs: Sub[] = []
let eventListeners: Set<any> = new Set()
let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length
let eoseSent = false
let eoseTimeout = setTimeout(
() => {
eoseSent = true
for (let cb of eoseListeners.values()) cb()
},
opts?.eoseSubTimeout || this.eoseSubTimeout,
)
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<K> = {
sub(filters, opts) {
subs.forEach(sub => sub.sub(filters, opts))
return greaterSub as any
},
unsub() {
subs.forEach(sub => sub.unsub())
},
on(type, cb) {
if (type === 'event') {
eventListeners.add(cb)
} else if (type === 'eose') {
eoseListeners.add(cb as () => void | Promise<void>)
}
},
off(type, cb) {
if (type === 'event') {
eventListeners.delete(cb)
} else if (type === 'eose') eoseListeners.delete(cb as () => void | Promise<void>)
},
get events() {
return eventsGenerator(greaterSub)
},
}
return greaterSub
}
get<K extends number = number>(
relays: string[],
filter: Filter<K>,
opts?: SubscriptionOptions,
): Promise<Event<K> | null> {
return new Promise(resolve => {
let sub = this.sub(relays, [filter], opts)
let timeout = setTimeout(() => {
sub.unsub()
resolve(null)
}, this.getTimeout)
sub.on('event', event => {
resolve(event)
clearTimeout(timeout)
sub.unsub()
})
})
}
list<K extends number = number>(
relays: string[],
filters: Filter<K>[],
opts?: SubscriptionOptions,
): Promise<Event<K>[]> {
return new Promise(resolve => {
let events: Event<K>[] = []
let sub = this.sub(relays, filters, opts)
sub.on('event', event => {
events.push(event)
})
// we can rely on an eose being emitted here because pool.sub() will fake one
sub.on('eose', () => {
sub.unsub()
resolve(events)
})
})
}
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, [mergeFilters(...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)
return r.publish(event)
})
}
seenOn(id: string): string[] {
return Array.from(this._seenOn[id]?.values?.() || [])
}
}
export * from './abstract-pool.ts'

View File

@@ -1,293 +0,0 @@
import { describe, test, expect } from 'bun:test'
import {
finalizeEvent,
serializeEvent,
getEventHash,
validateEvent,
verifyEvent,
verifiedSymbol,
getPublicKey,
generateSecretKey,
} from './pure.ts'
import { ShortTextNote } from './kinds.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
test('private key generation', () => {
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key generation', () => {
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key from private key deterministic', () => {
let sk = generateSecretKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})
describe('finalizeEvent', () => {
test('should create a signed event from a template', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const template = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
}
const event = finalizeEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
test('should serialize a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content,
]),
)
})
test('should throw an error for an invalid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115,
pubkey: publicKey, // missing content
}
expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
test('should return the correct event hash', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
test('should return true for a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
test('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
test('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115, // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid properties', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey',
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid tags', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifyEvent', () => {
test('should return true for a valid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const event = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
const isValid = verifyEvent(event)
expect(isValid).toEqual(true)
})
test('should return false for an invalid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the signature
event.sig = event.sig.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
test('should return false when verifying an event with a different private key', () => {
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
const publicKey2 = getPublicKey(privateKey2)
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey1,
)
// verify with different private key
const isValid = verifyEvent({
...event,
pubkey: publicKey2,
})
expect(isValid).toEqual(false)
})
test('should return false for an invalid event id', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the id
event.id = event.id.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
})

59
pure.ts
View File

@@ -1,59 +0,0 @@
import { schnorr } from '@noble/curves/secp256k1'
import { bytesToHex } from '@noble/hashes/utils'
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
class JS implements Nostr {
generateSecretKey(): Uint8Array {
return schnorr.utils.randomPrivateKey()
}
getPublicKey(secretKey: Uint8Array): string {
return bytesToHex(schnorr.getPublicKey(secretKey))
}
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
const event = t as VerifiedEvent
event.pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
event.id = getEventHash(event)
event.sig = bytesToHex(schnorr.sign(getEventHash(event), secretKey))
event[verifiedSymbol] = true
return event
}
verifyEvent(event: Event): event is VerifiedEvent {
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
const hash = getEventHash(event)
if (hash !== event.id) {
event[verifiedSymbol] = false
return false
}
try {
const valid = schnorr.verify(event.sig, hash, event.pubkey)
event[verifiedSymbol] = valid
return valid
} catch (err) {
event[verifiedSymbol] = false
return false
}
}
}
export function serializeEvent(evt: UnsignedEvent): string {
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
}
export function getEventHash(event: UnsignedEvent): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash)
}
const i: JS = new JS()
export const generateSecretKey = i.generateSecretKey
export const getPublicKey = i.getPublicKey
export const finalizeEvent = i.finalizeEvent
export const verifyEvent = i.verifyEvent
export * from './core.ts'

View File

@@ -1,4 +1,3 @@
import { test, expect } from 'bun:test'
import { parseReferences } from './references.ts'
import { buildEvent } from './test-helpers.ts'

Some files were not shown because too many files have changed in this diff Show More