feat: refactor and add integration tests

This commit is contained in:
Anton Livaja 2023-02-07 19:38:37 -05:00 committed by antonleviathan
parent 5610a9fde1
commit 320d67389b
No known key found for this signature in database
GPG Key ID: 44A86CFF1FDF0E85
12 changed files with 229 additions and 21 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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) {

View File

@ -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,
},
],
})

View File

@ -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

View File

@ -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[]

View File

@ -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>

View 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

View 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)
})

View File

@ -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)
})
})
})