mirror of
https://github.com/Cameri/nostream.git
synced 2025-03-26 17:52:30 +01:00
feat: refactor and add integration tests
This commit is contained in:
parent
5610a9fde1
commit
320d67389b
@ -16,8 +16,8 @@ export type IWebSocketAdapter = EventEmitter & {
|
||||
getClientId(): string
|
||||
getClientAddress(): string
|
||||
getSubscriptions(): Map<string, SubscriptionFilter[]>
|
||||
getClientAuthChallengeData?(): { challenge: string, createdAt: Date },
|
||||
setClientToAuthenticated?()
|
||||
getClientAuthChallengeData(): { challenge: string, createdAt: Date } | undefined
|
||||
setClientToAuthenticated(): void
|
||||
}
|
||||
|
||||
export interface ICacheAdapter {
|
||||
|
@ -24,6 +24,7 @@ export type IncomingMessage = (
|
||||
|
||||
export type OutgoingMessage =
|
||||
| OutgoingEventMessage
|
||||
| OutgoingAuthMessage
|
||||
| EndOfStoredEventsNotice
|
||||
| NoticeMessage
|
||||
| CommandResult
|
||||
@ -51,6 +52,11 @@ export interface OutgoingEventMessage {
|
||||
2: Event
|
||||
}
|
||||
|
||||
export interface OutgoingAuthMessage {
|
||||
0: MessageType.AUTH
|
||||
1: Event
|
||||
}
|
||||
|
||||
export interface UnsubscribeMessage {
|
||||
0: MessageType.CLOSE
|
||||
1: SubscriptionId
|
||||
|
@ -116,6 +116,10 @@ export interface Limits {
|
||||
message?: MessageLimits
|
||||
}
|
||||
|
||||
export interface Authentication {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface Worker {
|
||||
count: number
|
||||
}
|
||||
@ -171,6 +175,7 @@ export interface Mirroring {
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
authentication: Authentication
|
||||
info: Info
|
||||
payments?: Payments
|
||||
paymentsProcessors?: PaymentsProcessors
|
||||
|
@ -1,18 +1,18 @@
|
||||
import cluster from 'cluster'
|
||||
import { ContextMetadataKey } from '../constants/base'
|
||||
import { EventEmitter } from 'stream'
|
||||
import { IncomingMessage as IncomingHttpMessage } from 'http'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { WebSocket } from 'ws'
|
||||
|
||||
import { ContextMetadata, Factory } from '../@types/base'
|
||||
import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
|
||||
import { createAuthEventMessage, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
|
||||
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
|
||||
import { IncomingMessage, OutgoingMessage } from '../@types/messages'
|
||||
import { IncomingMessage, MessageType, OutgoingMessage } from '../@types/messages'
|
||||
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
|
||||
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
|
||||
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter'
|
||||
import { attemptValidation } from '../utils/validation'
|
||||
import { ContextMetadataKey } from '../constants/base'
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { Event } from '../@types/event'
|
||||
import { getRemoteAddress } from '../utils/http'
|
||||
@ -33,7 +33,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
|
||||
private clientAddress: SocketAddress
|
||||
private alive: boolean
|
||||
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
|
||||
private authChallenge: { createdAt: Date, challenge: string }
|
||||
private authChallenge: { createdAt: Date, challenge: string } | undefined
|
||||
private authenticated: boolean
|
||||
|
||||
public constructor(
|
||||
@ -100,14 +100,18 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
|
||||
}
|
||||
|
||||
public setNewAuthChallenge() {
|
||||
const challenge = randomBytes(16).toString('hex')
|
||||
this.authChallenge = {
|
||||
createdAt: new Date(),
|
||||
challenge: randomBytes(32).toString('hex'),
|
||||
challenge,
|
||||
}
|
||||
|
||||
return challenge
|
||||
}
|
||||
|
||||
public setClientToAuthenticated() {
|
||||
this.authenticated = true
|
||||
this.authChallenge = undefined
|
||||
}
|
||||
|
||||
public getClientAuthChallengeData() {
|
||||
@ -177,6 +181,20 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
|
||||
|
||||
const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8')))
|
||||
|
||||
if (
|
||||
!this.authenticated
|
||||
&& this.settings().authentication.enabled
|
||||
&& message[0] !== MessageType.AUTH
|
||||
) {
|
||||
const challenge = this.setNewAuthChallenge()
|
||||
this.webSocketServer.emit(
|
||||
WebSocketServerAdapterEvent.Broadcast,
|
||||
createAuthEventMessage(challenge)
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
message[ContextMetadataKey] = {
|
||||
remoteAddress: this.clientAddress,
|
||||
} as ContextMetadata
|
||||
@ -270,6 +288,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
|
||||
}
|
||||
|
||||
private onClientClose() {
|
||||
this.authenticated = false
|
||||
this.alive = false
|
||||
this.subscriptions.clear()
|
||||
|
||||
|
@ -6,6 +6,7 @@ import { isValidSignedAuthEvent } from '../../utils/event'
|
||||
import { IWebSocketAdapter } from '../../@types/adapters'
|
||||
import { WebSocketAdapterEvent } from '../../constants/adapter'
|
||||
|
||||
const permittedChallengeResponseTimeDelayMs = (1000 * 60 * 10) // 10 min
|
||||
const debug = createLogger('default-event-strategy')
|
||||
|
||||
export class SignedAuthEventStrategy implements IEventStrategy<Event, Promise<void>> {
|
||||
@ -18,7 +19,6 @@ export class SignedAuthEventStrategy implements IEventStrategy<Event, Promise<vo
|
||||
const { challenge, createdAt } = this.webSocket.getClientAuthChallengeData()
|
||||
const verified = await isValidSignedAuthEvent(event, challenge)
|
||||
|
||||
const permittedChallengeResponseTimeDelayMs = (1000 * 60 * 10) // 10 min
|
||||
const timeIsWithinBounds = (createdAt.getTime() + permittedChallengeResponseTimeDelayMs) < Date.now()
|
||||
|
||||
if (verified && timeIsWithinBounds) {
|
||||
|
@ -11,6 +11,12 @@ export const eventMessageSchema = Schema.array().ordered(
|
||||
)
|
||||
.label('EVENT message')
|
||||
|
||||
export const authEventMessageSchema = Schema.array().ordered(
|
||||
Schema.string().valid('AUTH').required(),
|
||||
eventSchema.required(),
|
||||
)
|
||||
.label('AUTH message')
|
||||
|
||||
export const reqMessageSchema = Schema.array()
|
||||
.ordered(Schema.string().valid('REQ').required(), Schema.string().max(256).required().label('subscriptionId'))
|
||||
.items(filterSchema.required().label('filter')).max(12)
|
||||
@ -36,5 +42,9 @@ export const messageSchema = Schema.alternatives()
|
||||
is: Schema.array().ordered(Schema.string().equal(MessageType.CLOSE)).items(Schema.any()),
|
||||
then: closeMessageSchema,
|
||||
},
|
||||
{
|
||||
is: Schema.array().ordered(Schema.string().equal(MessageType.AUTH)).items(Schema.any()),
|
||||
then: authEventMessageSchema,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
@ -10,7 +10,6 @@ import { EventKindsRange } from '../@types/settings'
|
||||
import { fromBuffer } from './transform'
|
||||
import { getLeadingZeroBits } from './proof-of-work'
|
||||
import { isGenericTagQuery } from './filter'
|
||||
import { MessageType } from '../@types/messages'
|
||||
import { RuneLike } from './runes/rune-like'
|
||||
import { SubscriptionFilter } from '../@types/subscription'
|
||||
import { WebSocketServerAdapterEvent } from '../constants/adapter'
|
||||
@ -116,14 +115,29 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Eve
|
||||
}
|
||||
|
||||
export const isSignedAuthEvent = (event: Event): boolean => {
|
||||
if (!event.content || !event.tags) return false
|
||||
const evenKindIsValid = event.kind === EventKinds.AUTH
|
||||
if (!evenKindIsValid) return false
|
||||
|
||||
const evenKindIsValid = event.kind !== EventKinds.AUTH
|
||||
const hasAuthDeclaration = event.content[0] === MessageType.AUTH && event.content[1]
|
||||
const relay = event.tags.some((tag) => tag.length === 2 && tag[0] === EventTags.Relay)
|
||||
const challenge = event.tags.some((tag) => tag.length === 2 && tag[0] === EventTags.Challenge)
|
||||
let relay
|
||||
let challenge
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
const tag = event.tags[i]
|
||||
if (tag.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
return Boolean(evenKindIsValid && hasAuthDeclaration && relay && challenge)
|
||||
if (tag[0] === EventTags.Challenge) {
|
||||
if (relay) return true
|
||||
challenge = true
|
||||
}
|
||||
|
||||
if (tag[0] === EventTags.Relay) {
|
||||
if (challenge) return true
|
||||
relay = true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const isValidSignedAuthEvent = async (event: Event, challenge: string): Promise<boolean> => {
|
||||
@ -132,10 +146,7 @@ export const isValidSignedAuthEvent = async (event: Event, challenge: string): P
|
||||
if (signedAuthEvent) {
|
||||
const sig = event.tags.find(tag => tag.length >= 2 && tag[0] === EventTags.Challenge)
|
||||
|
||||
const isValidSignedChallenge = secp256k1.schnorr.verify(sig[1], challenge, event.pubkey)
|
||||
if (isValidSignedChallenge) {
|
||||
return true
|
||||
}
|
||||
return secp256k1.schnorr.verify(sig[1], challenge, event.pubkey)
|
||||
}
|
||||
|
||||
return false
|
||||
|
@ -34,6 +34,11 @@ export const createCommandResult = (eventId: EventId, successful: boolean, messa
|
||||
return [MessageType.OK, eventId, successful, message]
|
||||
}
|
||||
|
||||
// NIP-42
|
||||
export const createAuthEventMessage = (challenge) => {
|
||||
return [MessageType.AUTH, challenge]
|
||||
}
|
||||
|
||||
export const createSubscriptionMessage = (
|
||||
subscriptionId: SubscriptionId,
|
||||
filters: SubscriptionFilter[]
|
||||
|
@ -3,7 +3,7 @@ import { createHash, createHmac, Hash } from 'crypto'
|
||||
import { Observable } from 'rxjs'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
import { CommandResult, MessageType, OutgoingMessage } from '../../../src/@types/messages'
|
||||
import { CommandResult, MessageType, OutgoingAuthMessage, OutgoingMessage } from '../../../src/@types/messages'
|
||||
import { Event } from '../../../src/@types/event'
|
||||
import { serializeEvent } from '../../../src/utils/event'
|
||||
import { streams } from './shared'
|
||||
@ -195,6 +195,18 @@ export async function waitForNotice(ws: WebSocket): Promise<string> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForAuth(ws: WebSocket): Promise<OutgoingAuthMessage> {
|
||||
return new Promise<OutgoingAuthMessage>((resolve) => {
|
||||
const observable = streams.get(ws) as Observable<OutgoingMessage>
|
||||
|
||||
observable.subscribe((message: OutgoingMessage) => {
|
||||
if (message[0] === MessageType.AUTH) {
|
||||
resolve(message)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForCommand(ws: WebSocket): Promise<CommandResult> {
|
||||
return new Promise<CommandResult>((resolve) => {
|
||||
const observable = streams.get(ws) as Observable<OutgoingMessage>
|
||||
|
6
test/integration/features/nip-42/nip-42.feature
Normal file
6
test/integration/features/nip-42/nip-42.feature
Normal file
@ -0,0 +1,6 @@
|
||||
Feature: NIP-42
|
||||
Scenario: Alice gets an event by ID
|
||||
Given someone called Alice
|
||||
And the relay requires the client to authenticate
|
||||
When Alice sends a text_note event with content "hello nostr"
|
||||
Then Alice receives an authentication challenge
|
28
test/integration/features/nip-42/nip-42.feature.ts
Normal file
28
test/integration/features/nip-42/nip-42.feature.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import {
|
||||
Given,
|
||||
Then,
|
||||
World,
|
||||
} from '@cucumber/cucumber'
|
||||
import chai from 'chai'
|
||||
import { EventKinds } from '../../../../src/constants/base'
|
||||
import { SettingsStatic } from '../../../../src/utils/settings'
|
||||
import sinonChai from 'sinon-chai'
|
||||
import { waitForAuth } from '../helpers'
|
||||
import { WebSocket } from 'ws'
|
||||
|
||||
chai.use(sinonChai)
|
||||
const { expect } = chai
|
||||
|
||||
Given(/the relay requires the client to authenticate/, async function (this: World<Record<string, any>>) {
|
||||
const settings = SettingsStatic.createSettings()
|
||||
settings.authentication.enabled = true
|
||||
})
|
||||
|
||||
Then(/(\w+) receives an authentication challenge/, async function (name: string) {
|
||||
const ws = this.parameters.clients[name] as WebSocket
|
||||
|
||||
const outgoingAuthMessage = await waitForAuth(ws)
|
||||
const event = outgoingAuthMessage[1]
|
||||
expect(event.kind).to.equal(EventKinds.AUTH)
|
||||
})
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { expect } from 'chai'
|
||||
|
||||
import { CanonicalEvent, Event } from '../../../src/@types/event'
|
||||
import { EventKinds, EventTags } from '../../../src/constants/base'
|
||||
import {
|
||||
getEventExpiration,
|
||||
isDelegatedEvent,
|
||||
@ -13,9 +14,10 @@ import {
|
||||
isExpiredEvent,
|
||||
isParameterizedReplaceableEvent,
|
||||
isReplaceableEvent,
|
||||
isSignedAuthEvent,
|
||||
isValidSignedAuthEvent,
|
||||
serializeEvent,
|
||||
} from '../../../src/utils/event'
|
||||
import { EventKinds } from '../../../src/constants/base'
|
||||
|
||||
describe('NIP-01', () => {
|
||||
describe('serializeEvent', () => {
|
||||
@ -564,4 +566,108 @@ describe('NIP-40', () => {
|
||||
expect(isExpiredEvent(event)).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSignedAuthEvent', () => {
|
||||
it('returns true if event is valid client auth event', () => {
|
||||
event.kind = EventKinds.AUTH
|
||||
event.tags = [
|
||||
[EventTags.Relay, 'wss://eden.nostr.land'],
|
||||
[EventTags.Challenge, 'signedChallenge'],
|
||||
]
|
||||
|
||||
expect(isSignedAuthEvent(event)).to.equal(true)
|
||||
})
|
||||
|
||||
it('returns false if relay tag is missing', () => {
|
||||
event.kind = EventKinds.AUTH
|
||||
event.tags = [
|
||||
[EventTags.Challenge, 'signedChallenge'],
|
||||
]
|
||||
|
||||
expect(isSignedAuthEvent(event)).to.equal(false)
|
||||
})
|
||||
|
||||
it('returns false if chaellenge tag is missing', () => {
|
||||
event.kind = EventKinds.AUTH
|
||||
event.tags = [
|
||||
[EventTags.Relay, 'wss://eden.nostr.land'],
|
||||
]
|
||||
|
||||
expect(isSignedAuthEvent(event)).to.equal(false)
|
||||
})
|
||||
|
||||
it('returns false if event kind is not AUTH', () => {
|
||||
event.kind = EventKinds.DELETE
|
||||
event.tags = [
|
||||
[EventTags.Relay, 'wss://eden.nostr.land'],
|
||||
[EventTags.Challenge, 'signedChallenge'],
|
||||
]
|
||||
|
||||
expect(isSignedAuthEvent(event)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidSignedAuthEvent', async () => {
|
||||
it('returns true if event is valid client auth event', async () => {
|
||||
const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039'
|
||||
const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8'
|
||||
|
||||
event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154'
|
||||
event.kind = EventKinds.AUTH
|
||||
event.tags = [
|
||||
[EventTags.Relay, 'wss://eden.nostr.land'],
|
||||
[EventTags.Challenge, signedHexChallenge],
|
||||
]
|
||||
|
||||
expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidSignedAuthEvent', async () => {
|
||||
it('returns false if challenge is different', async () => {
|
||||
const challengeHex = '6468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039'
|
||||
const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8'
|
||||
|
||||
event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154'
|
||||
event.kind = EventKinds.AUTH
|
||||
event.tags = [
|
||||
[EventTags.Relay, 'wss://eden.nostr.land'],
|
||||
[EventTags.Challenge, signedHexChallenge],
|
||||
]
|
||||
|
||||
expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidSignedAuthEvent', async () => {
|
||||
it('returns false if signed challenge is incorrect', async () => {
|
||||
const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039'
|
||||
const signedHexChallenge = '0161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8'
|
||||
|
||||
event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154'
|
||||
event.kind = EventKinds.AUTH
|
||||
event.tags = [
|
||||
[EventTags.Relay, 'wss://eden.nostr.land'],
|
||||
[EventTags.Challenge, signedHexChallenge],
|
||||
]
|
||||
|
||||
expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidSignedAuthEvent', async () => {
|
||||
it('returns true if event is valid client auth event', async () => {
|
||||
const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039'
|
||||
const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8'
|
||||
|
||||
event.pubkey = 'a9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154'
|
||||
event.kind = EventKinds.AUTH
|
||||
event.tags = [
|
||||
[EventTags.Relay, 'wss://eden.nostr.land'],
|
||||
[EventTags.Challenge, signedHexChallenge],
|
||||
]
|
||||
|
||||
expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user