Compare commits

..

2 Commits

Author SHA1 Message Date
fiatjaf
52cd6490fe max keys 256. 2023-04-04 12:33:23 -03:00
fiatjaf
3248b8b166 add nip41: stateless revocations. 2023-04-04 12:33:20 -03:00
26 changed files with 373 additions and 4516 deletions

View File

@@ -22,6 +22,7 @@
"globals": {
"document": false,
"BigInt": false,
"navigator": false,
"window": false,
"crypto": false,

24
LICENSE
View File

@@ -1,24 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View File

@@ -325,4 +325,4 @@ Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-t
## 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.
Public domain.

View File

@@ -13,7 +13,7 @@ export enum Kind {
EncryptedDirectMessage = 4,
EventDeletion = 5,
Reaction = 7,
BadgeAward = 8,
StatelessRevocation = 13,
ChannelCreation = 40,
ChannelMetadata = 41,
ChannelMessage = 42,
@@ -24,8 +24,6 @@ export enum Kind {
Zap = 9735,
RelayList = 10002,
ClientAuth = 22242,
BadgeDefinition = 30008,
ProfileBadge = 30009,
Article = 30023
}
@@ -81,10 +79,8 @@ export function getEventHash(event: UnsignedEvent): string {
return secp256k1.utils.bytesToHex(eventHash)
}
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
export function validateEvent(event: UnsignedEvent): boolean {
if (typeof event !== 'object') return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
@@ -103,7 +99,7 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent {
return true
}
export function verifySignature(event: Event): boolean {
export function verifySignature(event: Event & {sig: string}): boolean {
return secp256k1.schnorr.verifySync(
event.sig,
getEventHash(event),

View File

@@ -37,16 +37,6 @@ describe('Filter', () => {
expect(result).toEqual(false)
})
it('should return true when the event id starts with a prefix', () => {
const filter = {ids: ['22', '00']}
const event = {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]}
@@ -142,20 +132,6 @@ describe('Filter', () => {
expect(result).toEqual(true)
})
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 = {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},

View File

@@ -13,19 +13,12 @@ export type Filter = {
export function matchFilter(
filter: Filter,
event: Event
event: Event & {id: string}
): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
return false
}
}
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
return false
}
}
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
return false
for (let f in filter) {
if (f[0] === '#') {
@@ -49,7 +42,7 @@ export function matchFilter(
export function matchFilters(
filters: Filter[],
event: Event
event: Event & {id: string}
): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true

View File

@@ -9,13 +9,10 @@ export * as nip04 from './nip04'
export * as nip05 from './nip05'
export * as nip06 from './nip06'
export * as nip10 from './nip10'
export * as nip13 from './nip13'
export * as nip19 from './nip19'
export * as nip21 from './nip21'
export * as nip26 from './nip26'
export * as nip27 from './nip27'
export * as nip39 from './nip39'
export * as nip42 from './nip42'
export * as nip41 from './nip41'
export * as nip57 from './nip57'
export * as fj from './fakejson'

View File

@@ -17,9 +17,4 @@ test('fetch nip05 profiles', async () => {
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2.relays).toEqual(['wss://relay.damus.io'])
let p3 = await nip05.queryProfile('channel.ninja@channel.ninja')
expect(p3.pubkey).toEqual(
'36e65b503eba8a6b698e724a59137603101166a1cddb45ddc704247fc8aa0fce'
)
})

View File

@@ -36,7 +36,7 @@ export async function queryProfile(
name = '_'
}
if (!name.match(/^[A-Za-z0-9-_.]+$/)) return null
if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
if (!domain.includes('.')) return null
let res

View File

@@ -1,8 +0,0 @@
/* eslint-env jest */
const {nip13} = require('./lib/nostr.cjs')
test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
const difficulty = nip13.getPow(id)
expect(difficulty).toEqual(21)
})

View File

@@ -1,42 +0,0 @@
import * as secp256k1 from '@noble/secp256k1'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(id: string): number {
return getLeadingZeroBits(secp256k1.utils.hexToBytes(id))
}
/**
* Get number of leading 0 bits. Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function getLeadingZeroBits(hash: Uint8Array): number {
let total: number, i: number, bits: number
for (i = 0, total = 0; i < hash.length; i++) {
bits = msb(hash[i])
total += bits
if (bits !== 8) {
break
}
}
return total
}
/**
* Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function msb(b: number) {
let n = 0
if (b === 0) {
return 8
}
// eslint-disable-next-line no-cond-assign
while (b >>= 1) {
n++
}
return 7 - n
}

View File

@@ -100,12 +100,3 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
expect(data.kind).toEqual(30023)
expect(data.identifier).toEqual('banana')
})
test('encode and decode nrelay', () => {
let url = "wss://relay.nostr.example"
let nrelay = nip19.nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/)
let {type, data} = nip19.decode(nrelay)
expect(type).toEqual('nrelay')
expect(data).toEqual(url)
})

View File

@@ -23,16 +23,10 @@ export type AddressPointer = {
relays?: string[]
}
export type DecodeResult =
| {type: 'nprofile'; data: ProfilePointer}
| {type: 'nrelay'; data: string}
| {type: 'nevent'; data: EventPointer}
| {type: 'naddr'; data: AddressPointer}
| {type: 'nsec'; data: string}
| {type: 'npub'; data: string}
| {type: 'note'; data: string}
export function decode(nip19: string): DecodeResult {
export function decode(nip19: string): {
type: string
data: ProfilePointer | EventPointer | AddressPointer | string
} {
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words))
@@ -88,16 +82,6 @@ export function decode(nip19: string): DecodeResult {
}
}
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':
@@ -176,14 +160,6 @@ export function naddrEncode(addr: AddressPointer): string {
return bech32.encode('naddr', words, Bech32MaxSize)
}
export function nrelayEncode(url: string): string {
let data = encodeTLV({
0: [utf8Encoder.encode(url)]
})
let words = bech32.toWords(data)
return bech32.encode('nrelay', words, Bech32MaxSize)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []

View File

@@ -1,42 +0,0 @@
/* eslint-env jest */
const {nip21} = require('./lib/nostr.cjs')
test('test', () => {
expect(
nip21.test(
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(true)
expect(
nip21.test(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
).toBe(true)
expect(
nip21.test(
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(nip21.test('nostr:')).toBe(false)
expect(
nip21.test(
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(nip21.test('gggggg')).toBe(false)
})
test('parse', () => {
const result = nip21.parse(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect(result).toEqual({
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
}
})
})

View File

@@ -1,41 +0,0 @@
import * as nip19 from './nip19'
import * as nip21 from './nip21'
/**
* Bech32 regex.
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
*/
export const BECH32_REGEX =
/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
/** Nostr URI regex, eg `nostr:npub1...` */
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}` {
return (
typeof value === 'string' &&
new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
)
}
/** Parsed Nostr URI data. */
export interface NostrURI {
/** Full URI including the `nostr:` protocol. */
uri: `nostr:${string}`
/** The bech32-encoded data (eg `npub1...`). */
value: string
/** Decoded bech32 string, according to NIP-19. */
decoded: nip19.DecodeResult
}
/** Parse and decode a Nostr URI. */
export function parse(uri: string): NostrURI {
const match = uri.match(new RegExp(`^${nip21.NOSTR_URI_REGEX.source}$`))
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
return {
uri: match[0] as `nostr:${string}`,
value: match[1],
decoded: nip19.decode(match[1])
}
}

View File

@@ -1,49 +0,0 @@
/* eslint-env jest */
const {nip27} = require('./lib/nostr.cjs')
test('matchAll', () => {
const result = nip27.matchAll(
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect([...result]).toEqual([
{
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
decoded: {
type: 'npub',
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6'
},
start: 6,
end: 75
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
},
start: 78,
end: 147
}
])
})
test('replaceAll', () => {
const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = nip27.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')
})

View File

@@ -1,63 +0,0 @@
import * as nip19 from './nip19'
import * as nip21 from './nip21'
/** Regex to find NIP-21 URIs inside event content. */
export const regex = () =>
new RegExp(`\\b${nip21.NOSTR_URI_REGEX.source}\\b`, 'g')
/** Match result for a Nostr URI in event content. */
export interface NostrURIMatch extends nip21.NostrURI {
/** Index where the URI begins in the event content. */
start: number
/** Index where the URI ends in the event content. */
end: number
}
/** Find and decode all NIP-21 URIs. */
export function * matchAll(content: string): Iterable<NostrURIMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
const [uri, value] = match
yield {
uri: uri as `nostr:${string}`,
value,
decoded: nip19.decode(value),
start: match.index!,
end: match.index! + uri.length
}
}
}
/**
* 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: nip21.NostrURI) => string
): string {
return content.replaceAll(regex(), (uri, value) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: nip19.decode(value)
})
})
}

154
nip41.test.js Normal file
View File

@@ -0,0 +1,154 @@
/* eslint-env jest */
const secp256k1 = require('@noble/secp256k1')
const {
getPublicKey,
validateEvent,
verifySignature,
generatePrivateKey,
nip41
} = require('./lib/nostr.cjs')
test('sanity', () => {
let sk = generatePrivateKey()
expect(getPublicKey(sk)).toEqual(secp256k1.Point.fromPrivateKey(sk).toHexX())
})
test('key arithmetics', () => {
expect(
secp256k1.utils.mod(secp256k1.CURVE.n + 1n, secp256k1.CURVE.n)
).toEqual(1n)
let veryHighPoint = secp256k1.Point.fromPrivateKey(
(secp256k1.CURVE.n - 1n).toString(16).padStart(64, '0')
)
let pointAt2 = secp256k1.Point.fromPrivateKey(
2n.toString(16).padStart(64, '0')
)
let pointAt1 = secp256k1.Point.fromPrivateKey(
1n.toString(16).padStart(64, '0')
)
expect(veryHighPoint.add(pointAt2)).toEqual(pointAt1)
expect(
secp256k1.getPublicKey(1n.toString(16).padStart(64, '0'), true)
).toEqual(pointAt1.toRawBytes(true))
})
test('testing getting child keys compatibility', () => {
let sk = '2222222222222222222222222222222222222222222222222222222222222222'
let pk = secp256k1.getPublicKey(sk, true)
let hsk = '3333333333333333333333333333333333333333333333333333333333333333'
let hpk = secp256k1.getPublicKey(hsk, true)
expect(secp256k1.utils.bytesToHex(nip41.getChildPublicKey(pk, hpk))).toEqual(
secp256k1.utils.bytesToHex(
secp256k1.getPublicKey(nip41.getChildPrivateKey(sk, hsk), true)
)
)
})
test('more testing child key derivation', () => {
;[
{
sk: '448aedc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '00ee15a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '02e3990b0eb40452a8ffbd9fe99037deb7beeb6ab26020e8c0e8284f3009a56d0c',
hpk: '029e9cb07f3a3b8abcad629920d4a5460aefb6b7c08704b7f1ced8648b007ef65f'
},
{
sk: '778aedc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '99ee15a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '020d09894e321f53a7ac8bc003cb1563a4857d57ea69c39ab7189e2cccedc17d1b',
hpk: '0358fe19e14c78c4a8c0037a2b9d3e3a714717f2a2d8dd54a5e88d283440dcb28a'
},
{
sk: '2eb5edc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '65d515a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '03dd651a07dc6c9a54b596f6492c9623a595cb48e31af04f8c322d4ce81accb2b0',
hpk: '03b8c98d920141a1e168d21e9315cf933a601872ebf57751b30797fb98526c2f4f'
}
].forEach(({pk, hpk, sk, hsk}) => {
expect(
secp256k1.utils.bytesToHex(secp256k1.getPublicKey(sk, true))
).toEqual(pk)
expect(
secp256k1.utils.bytesToHex(secp256k1.getPublicKey(hsk, true))
).toEqual(hpk)
expect(
secp256k1.utils.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes(pk),
secp256k1.utils.hexToBytes(hpk)
)
)
).toEqual(
secp256k1.utils.bytesToHex(
secp256k1.getPublicKey(nip41.getChildPrivateKey(sk, hsk), true)
)
)
})
})
test('generating a revocation event and validating it', () => {
const mnemonic =
'air property excess weird rare rival fade intact brave office mirror wait'
const firstKey = nip41.getPrivateKeyAtIndex(mnemonic, 9)
// expect(firstKey).toEqual(
// '8495ba55f56485d378aa275604a45e76abbcae177e374fa06af5770c3b8e24af'
// )
const firstPubkey = getPublicKey(firstKey)
// expect(firstPubkey).toEqual(
// '35246813a0dd45e74ce22ecdf052cca8ed47759c8f8d412c281dc2755110956f'
// )
// first key is compromised, revoke it
let {parentPrivateKey, event} = nip41.buildRevocationEvent(
mnemonic,
firstPubkey
)
const secondKey = nip41.getPrivateKeyAtIndex(mnemonic, 8)
expect(parentPrivateKey).toEqual(secondKey)
expect(secondKey).toEqual(
'1b311655ef73bed3bbebc83d0cb3eef42c6aff45f944e3a0c263eb6fdf98c617'
)
expect(event).toHaveProperty('kind', 13)
expect(event.tags).toHaveLength(2)
expect(event.tags[0]).toHaveLength(2)
expect(event.tags[1]).toHaveLength(2)
expect(event.tags[0][0]).toEqual('p')
expect(event.tags[1][0]).toEqual('hidden-key')
let hiddenKey = secp256k1.utils.hexToBytes(event.tags[1][1])
let pubkeyAlt1 = secp256k1.utils
.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes('02' + event.pubkey),
hiddenKey
)
)
.slice(2)
let pubkeyAlt2 = secp256k1.utils
.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes('03' + event.pubkey),
hiddenKey
)
)
.slice(2)
expect([pubkeyAlt1, pubkeyAlt2]).toContain(event.tags[0][1])
// receiver of revocation event can validate it
let secondPubkey = getPublicKey(secondKey)
expect(event.pubkey).toEqual(secondPubkey)
expect(validateEvent(event)).toBeTruthy()
expect(verifySignature(event)).toBeTruthy()
expect(nip41.validateRevocation(event)).toBeTruthy()
})

160
nip41.ts Normal file
View File

@@ -0,0 +1,160 @@
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {mnemonicToSeedSync} from '@scure/bip39'
import {HARDENED_OFFSET, HDKey} from '@scure/bip32'
import {getPublicKey} from './keys'
import {Event, getEventHash, Kind, signEvent, verifySignature} from './event'
const MaxKeys = 256
function getRootFromMnemonic(mnemonic: string): HDKey {
return HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive(
`m/44'/1237'/41'`
)
}
export function getPrivateKeyAtIndex(
mnemonic: string,
targetIdx: number
): string {
let root = getRootFromMnemonic(mnemonic)
let rootPrivateKey = secp256k1.utils.bytesToHex(root.privateKey as Uint8Array)
let currentPrivateKey = rootPrivateKey
for (let idx = 1; idx <= targetIdx; idx++) {
let hiddenPrivateKey = secp256k1.utils.bytesToHex(
root.deriveChild(idx + HARDENED_OFFSET).privateKey as Uint8Array
)
currentPrivateKey = getChildPrivateKey(currentPrivateKey, hiddenPrivateKey)
}
return currentPrivateKey
}
export function getPublicKeyAtIndex(
root: HDKey,
targetIdx: number
): Uint8Array {
let rootPublicKey = root.publicKey as Uint8Array
let currentPublicKey = rootPublicKey
for (let idx = 1; idx <= targetIdx; idx++) {
let hiddenPublicKey = root.deriveChild(idx + HARDENED_OFFSET)
.publicKey as Uint8Array
currentPublicKey = getChildPublicKey(currentPublicKey, hiddenPublicKey)
}
return currentPublicKey
}
function getIndexOfPublicKey(root: HDKey, publicKey: string): number {
let rootPublicKey = root.publicKey as Uint8Array
if (secp256k1.utils.bytesToHex(rootPublicKey).slice(2) === publicKey) return 0
let currentPublicKey = rootPublicKey
for (let idx = 1; idx <= MaxKeys; idx++) {
let hiddenPublicKey = root.deriveChild(idx + HARDENED_OFFSET)
.publicKey as Uint8Array
let pubkeyAtIndex = getChildPublicKey(currentPublicKey, hiddenPublicKey)
if (secp256k1.utils.bytesToHex(pubkeyAtIndex).slice(2) === publicKey)
return idx
currentPublicKey = pubkeyAtIndex
}
throw new Error(
`public key ${publicKey} not in the set of the first ${MaxKeys} public keys`
)
}
export function getChildPublicKey(
parentPublicKey: Uint8Array,
hiddenPublicKey: Uint8Array
): Uint8Array {
if (parentPublicKey.length !== 33 || hiddenPublicKey.length !== 33)
throw new Error(
'getChildPublicKey() requires public keys with the leading differentiator byte.'
)
let hash = sha256(
secp256k1.utils.concatBytes(hiddenPublicKey, parentPublicKey)
)
let hashPoint = secp256k1.Point.fromPrivateKey(hash)
let point = secp256k1.Point.fromHex(hiddenPublicKey).add(hashPoint)
return point.toRawBytes(true)
}
export function getChildPrivateKey(
parentPrivateKey: string,
hiddenPrivateKey: string
): string {
let parentPublicKey = secp256k1.getPublicKey(parentPrivateKey, true)
let hiddenPublicKey = secp256k1.getPublicKey(hiddenPrivateKey, true)
let hash = sha256(
secp256k1.utils.concatBytes(hiddenPublicKey, parentPublicKey)
)
let hashScalar = BigInt(`0x${secp256k1.utils.bytesToHex(hash)}`)
let hiddenPrivateKeyScalar = BigInt(`0x${hiddenPrivateKey}`)
let sumScalar = hiddenPrivateKeyScalar + hashScalar
let modulo = secp256k1.utils.mod(sumScalar, secp256k1.CURVE.n)
return modulo.toString(16).padStart(64, '0')
}
export function buildRevocationEvent(
mnemonic: string,
compromisedKey: string,
content = ''
): {
parentPrivateKey: string
event: Event
} {
let root = getRootFromMnemonic(mnemonic)
let idx = getIndexOfPublicKey(root, compromisedKey)
let hiddenKey = secp256k1.utils.bytesToHex(
root.deriveChild(idx + HARDENED_OFFSET).publicKey as Uint8Array
)
let parentPrivateKey = getPrivateKeyAtIndex(mnemonic, idx - 1)
let parentPublicKey = getPublicKey(parentPrivateKey)
let event: Event = {
kind: 13,
tags: [
['p', compromisedKey],
['hidden-key', hiddenKey]
],
created_at: Math.round(Date.now() / 1000),
content,
pubkey: parentPublicKey
}
event.sig = signEvent(event, parentPrivateKey)
event.id = getEventHash(event)
return {parentPrivateKey, event}
}
export function validateRevocation(event: Event): boolean {
if (event.kind !== Kind.StatelessRevocation) return false
if (!verifySignature(event)) return false
let invalidKeyTag = event.tags.find(([t, v]) => t === 'p' && v)
if (!invalidKeyTag) return false
let invalidKey = invalidKeyTag[1]
let hiddenKeyTag = event.tags.find(([t, v]) => t === 'hidden-key' && v)
if (!hiddenKeyTag) return false
let hiddenKey = secp256k1.utils.hexToBytes(hiddenKeyTag[1])
if (hiddenKey.length !== 33) return false
let currentKeyAlt1 = secp256k1.utils.hexToBytes('02' + event.pubkey)
let currentKeyAlt2 = secp256k1.utils.hexToBytes('03' + event.pubkey)
let childKeyAlt1 = secp256k1.utils
.bytesToHex(getChildPublicKey(currentKeyAlt1, hiddenKey))
.slice(2)
let childKeyAlt2 = secp256k1.utils
.bytesToHex(getChildPublicKey(currentKeyAlt2, hiddenKey))
.slice(2)
return childKeyAlt1 === invalidKey || childKeyAlt2 === invalidKey
}

View File

@@ -1,27 +0,0 @@
/* eslint-env jest */
require('websocket-polyfill')
const {
relayInit,
generatePrivateKey,
finishEvent,
nip42
} = require('./lib/nostr.cjs')
test('auth flow', done => {
const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect()
const sk = generatePrivateKey()
relay.on('auth', async challenge => {
await expect(
nip42.authenticate({
challenge,
relay,
sign: e => finishEvent(e, sk)
})
).rejects.toBeTruthy()
relay.close()
done()
})
})

View File

@@ -1,42 +0,0 @@
import {EventTemplate, Event, Kind} from './event'
import {Relay} from './relay'
/**
* Authenticate via NIP-42 flow.
*
* @example
* const sign = window.nostr.signEvent
* relay.on('auth', challenge =>
* authenticate({ relay, sign, challenge })
* )
*/
export const authenticate = async ({
challenge,
relay,
sign
}: {
challenge: string
relay: Relay
sign: (e: EventTemplate) => Promise<Event>
}): Promise<void> => {
const e: EventTemplate = {
kind: Kind.ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['challenge', challenge]
],
content: ''
}
const pub = relay.auth(await sign(e))
return new Promise((resolve, reject) => {
pub.on('ok', function ok() {
pub.off('ok', ok)
resolve()
})
pub.on('failed', function fail(reason: string) {
pub.off('failed', fail)
reject(reason)
})
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "nostr-tools",
"version": "1.10.1",
"version": "1.8.2",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -16,13 +16,14 @@
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js"
},
"license": "Unlicense",
"license": "Public domain",
"dependencies": {
"@noble/hashes": "1.2.0",
"@noble/secp256k1": "1.7.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.1.4",
"@scure/bip39": "1.1.1"
"@noble/hashes": "1.0.0",
"@noble/secp256k1": "^1.7.1",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.1.5",
"@scure/bip39": "^1.1.1",
"prettier": "^2.8.4"
},
"keywords": [
"decentralization",
@@ -31,11 +32,6 @@
"client",
"nostr"
],
"scripts": {
"build": "node build",
"format": "prettier --plugin-search-dir . --write .",
"test": "node build && jest"
},
"devDependencies": {
"@types/node": "^18.13.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
@@ -48,7 +44,6 @@
"events": "^3.3.0",
"jest": "^29.4.2",
"node-fetch": "^2.6.9",
"prettier": "^2.8.4",
"ts-jest": "^29.0.5",
"tsd": "^0.22.0",
"typescript": "^4.9.5",

View File

@@ -2,7 +2,7 @@ import {Relay, relayInit} from './relay'
import {normalizeURL} from './utils'
import {Filter} from './filter'
import {Event} from './event'
import {SubscriptionOptions, Sub, Pub, CountPayload} from './relay'
import {SubscriptionOptions, Sub, Pub} from './relay'
export class SimplePool {
private _conn: {[url: string]: Relay}
@@ -53,7 +53,7 @@ export class SimplePool {
}
let subs: Sub[] = []
let eventListeners: Set<any> = new Set()
let eventListeners: Set<(event: Event) => void> = new Set()
let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length

View File

@@ -81,7 +81,7 @@ export function parseReferences(evt: Event): Reference[] {
}
case 'a': {
try {
let [kind, pubkey, identifier] = tag[1].split(':')
let [kind, pubkey, identifier] = ref[1].split(':')
references.push({
text: ref[0],
address: {

112
relay.ts
View File

@@ -9,14 +9,9 @@ type RelayEvent = {
disconnect: () => void | Promise<void>
error: () => void | Promise<void>
notice: (msg: string) => void | Promise<void>
auth: (challenge: string) => void | Promise<void>
}
export type CountPayload = {
count: number
}
type SubEvent = {
event: (event: Event) => void | Promise<void>
count: (payload: CountPayload) => void | Promise<void>
eose: () => void | Promise<void>
}
export type Relay = {
@@ -27,12 +22,7 @@ export type Relay = {
sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
count: (
filters: Filter[],
opts?: SubscriptionOptions
) => Promise<CountPayload | null>
publish: (event: Event) => Pub
auth: (event: Event) => Pub
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
@@ -61,32 +51,27 @@ export type Sub = {
export type SubscriptionOptions = {
id?: string
verb?: 'REQ' | 'COUNT'
skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
}
const newListeners = (): {[TK in keyof RelayEvent]: RelayEvent[TK][]} => ({
connect: [],
disconnect: [],
error: [],
notice: [],
auth: []
})
export function relayInit(
url: string,
options: {
getTimeout?: number
listTimeout?: number
countTimeout?: number
} = {}
): Relay {
let {listTimeout = 3000, getTimeout = 3000, countTimeout = 3000} = options
let {listTimeout = 3000, getTimeout = 3000} = options
var ws: WebSocket
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners = newListeners()
var listeners: {[TK in keyof RelayEvent]: RelayEvent[TK][]} = {
connect: [],
disconnect: [],
error: [],
notice: []
}
var subListeners: {
[subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
} = {}
@@ -161,7 +146,7 @@ export function relayInit(
// will naturally be caught by the encompassing try..catch block
switch (data[0]) {
case 'EVENT': {
case 'EVENT':
let id = data[1]
let event = data[2]
if (
@@ -174,14 +159,6 @@ export function relayInit(
;(subListeners[id]?.event || []).forEach(cb => cb(event))
}
return
}
case 'COUNT':
let id = data[1]
let payload = data[2]
if (openSubs[id]) {
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
}
return
case 'EOSE': {
let id = data[1]
if (id in subListeners) {
@@ -206,11 +183,6 @@ export function relayInit(
let notice = data[1]
listeners.notice.forEach(cb => cb(notice))
return
case 'AUTH': {
let challenge = data[1]
listeners.auth?.forEach(cb => cb(challenge))
return
}
}
} catch (err) {
return
@@ -248,7 +220,6 @@ export function relayInit(
const sub = (
filters: Filter[],
{
verb = 'REQ',
skipVerification = false,
alreadyHaveEvent = null,
id = Math.random().toString().slice(2)
@@ -262,7 +233,7 @@ export function relayInit(
skipVerification,
alreadyHaveEvent
}
trySend([verb, subid, ...filters])
trySend(['REQ', subid, ...filters])
return {
sub: (newFilters, newOpts = {}) =>
@@ -282,7 +253,6 @@ export function relayInit(
): void => {
subListeners[subid] = subListeners[subid] || {
event: [],
count: [],
eose: []
}
subListeners[subid][type].push(cb)
@@ -298,29 +268,6 @@ export function relayInit(
}
}
function _publishEvent(event: Event, type: string) {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
trySend([type, event])
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
return {
url,
sub,
@@ -371,28 +318,31 @@ export function relayInit(
resolve(event)
})
}),
count: (filters: Filter[]): Promise<CountPayload | null> =>
new Promise(resolve => {
let s = sub(filters, {...sub, verb: 'COUNT'})
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, countTimeout)
s.on('count', (event: CountPayload) => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
}),
publish(event): Pub {
return _publishEvent(event, 'EVENT')
},
auth(event): Pub {
return _publishEvent(event, 'AUTH')
publish(event: Event): Pub {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
trySend(['EVENT', event])
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
},
connect,
close(): void {
listeners = newListeners()
listeners = {connect: [], disconnect: [], error: [], notice: []}
subListeners = {}
pubListeners = {}
if (ws.readyState === WebSocket.OPEN) {

3989
yarn.lock

File diff suppressed because it is too large Load Diff