Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ services:
restart: on-failure
networks:
default:
ipv4_address: 10.10.10.2

nostream-db:
image: postgres:15
Expand Down
1 change: 1 addition & 0 deletions src/@types/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type IWebSocketAdapter = EventEmitter & {
getClientId(): string
getClientAddress(): string
getSubscriptions(): Map<string, SubscriptionFilter[]>
getChallenge(): string
}

export interface ICacheAdapter {
Expand Down
15 changes: 13 additions & 2 deletions src/@types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ export enum MessageType {
OK = 'OK',
COUNT = 'COUNT',
CLOSED = 'CLOSED',
AUTH = 'AUTH',
}

export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage) & {
export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage | AuthMessage) & {
[ContextMetadataKey]?: ContextMetadata
}

export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage
export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage | AuthChallengeMessage

export type SubscribeMessage = {
[index in Range<2, 100>]: SubscriptionFilter
Expand Down Expand Up @@ -89,3 +90,13 @@ export interface ClosedMessage {
1: SubscriptionId
2: string
}

export interface AuthMessage {
0: MessageType.AUTH
1: Event
}

export interface AuthChallengeMessage {
0: MessageType.AUTH
1: string
}
11 changes: 10 additions & 1 deletion src/adapters/web-socket-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { EventEmitter } from 'stream'
import { IncomingMessage as IncomingHttpMessage } from 'http'
import { WebSocket } from 'ws'
import { ZodError } from 'zod'
import { randomBytes } from 'crypto'

import { ContextMetadata, Factory } from '../@types/base'
import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
import { createAuthChallengeMessage, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
import { IncomingMessage, OutgoingMessage } from '../@types/messages'
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
Expand All @@ -32,6 +33,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
private clientAddress: SocketAddress
private alive: boolean
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
private challenge: string

public constructor(
private readonly client: WebSocket,
Expand Down Expand Up @@ -79,6 +81,13 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
.on(WebSocketAdapterEvent.Message, this.sendMessage.bind(this))

logger('client %s connected from %s', this.clientId, this.clientAddress.address)

this.challenge = randomBytes(16).toString('hex')
this.sendMessage(createAuthChallengeMessage(this.challenge))
}

public getChallenge(): string {
return this.challenge
}

public getClientId(): string {
Expand Down
2 changes: 2 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export enum EventKinds {
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
PARAMETERIZED_REPLACEABLE_LAST = 39999,
USER_APPLICATION_FIRST = 40000,
AUTH = 22242,
}

export enum EventTags {
Expand All @@ -52,6 +53,7 @@ export enum EventTags {
Invoice = 'bolt11',
// NIP-03: target event kind on an OpenTimestamps attestation
Kind = 'k',
Challenge = 'challenge',
}

export const ALL_RELAYS = 'ALL_RELAYS'
Expand Down
3 changes: 3 additions & 0 deletions src/factories/message-handler-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters'
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
import { IncomingMessage, MessageType } from '../@types/messages'
import { createSettings } from './settings-factory'
import { AuthMessageHandler } from '../handlers/auth-message-handler'
import { CountMessageHandler } from '../handlers/count-message-handler'
import { EventMessageHandler } from '../handlers/event-message-handler'
import { eventStrategyFactory } from './event-strategy-factory'
Expand Down Expand Up @@ -45,6 +46,8 @@ export const messageHandlerFactory =
return new UnsubscribeMessageHandler(adapter)
case MessageType.COUNT:
return new CountMessageHandler(adapter, eventRepository, createSettings)
case MessageType.AUTH:
return new AuthMessageHandler(adapter, createSettings)
default:
throw new Error(`Unknown message type: ${String(message[0]).substring(0, 64)}`)
}
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/auth-message-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { EventKinds, EventTags } from '../constants/base'
import { IMessageHandler } from '../@types/message-handlers'
import { isEventIdValid, isEventSignatureValid } from '../utils/event'
import { AuthMessage } from '../@types/messages'
import { createLogger } from '../factories/logger-factory'
import { Factory } from '../@types/base'
import { IWebSocketAdapter } from '../@types/adapters'
import { Settings } from '../@types/settings'

const logger = createLogger('auth-message-handler')

export class AuthMessageHandler implements IMessageHandler {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly settings: Factory<Settings>,
) {}

public async handleMessage(message: AuthMessage): Promise<void> {
const event = message[1]
const clientId = this.webSocket.getClientId()

if (event.kind !== EventKinds.AUTH) {
logger('client %s sent invalid auth event kind: %d', clientId, event.kind)
return
}

const isValid = (await isEventIdValid(event)) && (await isEventSignatureValid(event))
if (!isValid) {
logger('client %s sent invalid auth event signature: %s', clientId, event.id)
return
}

const challenge = event.tags.find((tag) => tag[0] === EventTags.Challenge)?.[1]
if (challenge !== this.webSocket.getChallenge()) {
logger('client %s sent invalid auth challenge: expected %s, got %s', clientId, this.webSocket.getChallenge(), challenge)
return
}

const relay = event.tags.find((tag) => tag[0] === EventTags.Relay)?.[1]
const configuredRelayUrl = this.settings().info.relay_url
if (relay !== configuredRelayUrl) {
logger('client %s sent invalid auth relay: expected %s, got %s', clientId, configuredRelayUrl, relay)
return
}

// NIP-42: event must be recent (e.g., within 10 minutes)
const now = Math.floor(Date.now() / 1000)
if (Math.abs(event.created_at - now) > 600) {
logger('client %s sent expired auth event: %d (now: %d)', clientId, event.created_at, now)
return
}

// In a real implementation, we would associate the pubkey with the client session.
logger('client %s authenticated as %s', clientId, event.pubkey)
}
}
4 changes: 3 additions & 1 deletion src/schemas/message-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,6 @@ export const countMessageSchema = z

export const closeMessageSchema = z.tuple([z.literal(MessageType.CLOSE), subscriptionSchema])

export const messageSchema = z.union([eventMessageSchema, reqMessageSchema, closeMessageSchema, countMessageSchema])
export const authMessageSchema = z.tuple([z.literal(MessageType.AUTH), eventSchema])

export const messageSchema = z.union([eventMessageSchema, reqMessageSchema, closeMessageSchema, countMessageSchema, authMessageSchema])
5 changes: 5 additions & 0 deletions src/utils/messages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AuthChallengeMessage,
ClosedMessage,
CountResultMessage,
CountResultPayload,
Expand Down Expand Up @@ -41,6 +42,10 @@ export const createClosedMessage = (queryId: SubscriptionId, reason: string): Cl
return [MessageType.CLOSED, queryId, reason]
}

export const createAuthChallengeMessage = (challenge: string): AuthChallengeMessage => {
return [MessageType.AUTH, challenge]
}

export const createSubscriptionMessage = (
subscriptionId: SubscriptionId,
filters: SubscriptionFilter[],
Expand Down
112 changes: 112 additions & 0 deletions task3.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
const dgram = require('node:dgram')
const crypto = require('node:crypto')

const MULTICAST_GROUP = process.env.MULTICAST_GROUP || '239.255.0.1'
const MULTICAST_PORT = Number(process.env.MULTICAST_PORT || 29999)
const RECEIVE_TIMEOUT_MS = Number(process.env.RECEIVE_TIMEOUT_MS || 5000)

const randomHex = (bytes) => crypto.randomBytes(bytes).toString('hex')

const createDummyNostrEvent = () => {
const createdAt = Math.floor(Date.now() / 1000)
const nonce = randomHex(8)

return {
id: randomHex(32),
pubkey: randomHex(32),
created_at: createdAt,
kind: 1,
tags: [['nonce', nonce], ['client', 'nostream-competency-test']],
content: `UDP multicast competency test @ ${createdAt}`,
sig: randomHex(64),
}
}

const isNostrEvent = (value) => {
if (!value || typeof value !== 'object') {
return false
}

return typeof value.id === 'string'
&& typeof value.pubkey === 'string'
&& typeof value.created_at === 'number'
&& typeof value.kind === 'number'
&& Array.isArray(value.tags)
&& typeof value.content === 'string'
&& typeof value.sig === 'string'
}

function solveTask3() {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
const event = createDummyNostrEvent()
const payload = Buffer.from(JSON.stringify(event), 'utf8')

const timeout = setTimeout(() => {
socket.close()
reject(new Error(`timed out after ${RECEIVE_TIMEOUT_MS}ms without receiving multicast payload`))
}, RECEIVE_TIMEOUT_MS)

socket.on('error', (error) => {
clearTimeout(timeout)
socket.close()
reject(error)
})

socket.on('message', (message, remoteInfo) => {
let parsed
try {
parsed = JSON.parse(message.toString('utf8'))
} catch (error) {
clearTimeout(timeout)
socket.close()
reject(new Error(`received invalid JSON payload: ${error.message}`))
return
}

if (!isNostrEvent(parsed)) {
clearTimeout(timeout)
socket.close()
reject(new Error('received JSON but payload is not a valid Nostr event shape'))
return
}

if (parsed.id !== event.id) {
return
}

clearTimeout(timeout)
console.log('SUCCESS: Received and parsed own multicast payload')
console.log(`From ${remoteInfo.address}:${remoteInfo.port}`)
console.log(parsed)
socket.close()
resolve()
})

socket.bind(MULTICAST_PORT, () => {
socket.setMulticastTTL(1)
socket.setMulticastLoopback(true)
socket.addMembership(MULTICAST_GROUP)

socket.send(payload, MULTICAST_PORT, MULTICAST_GROUP, (error) => {
if (error) {
clearTimeout(timeout)
socket.close()
reject(error)
return
}

console.log(`Sent dummy Nostr event to ${MULTICAST_GROUP}:${MULTICAST_PORT}`)
})
})
})
}

solveTask3()
.then(() => {
process.exitCode = 0
})
.catch((error) => {
console.error('Task 3 failed:', error.message)
process.exitCode = 1
})
30 changes: 30 additions & 0 deletions task4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as secp256k1 from '@noble/secp256k1';

/**
* Verifies a Nostr event signature.
*
* @param event The Nostr event object containing id, pubkey, and sig.
* @returns A promise that resolves to true if the signature is valid, false otherwise.
*/
export async function verifyEventSignature(event: {
id: string;
pubkey: string;
sig: string;
}): Promise<boolean> {
try {
return await secp256k1.schnorr.verify(event.sig, event.id, event.pubkey);
} catch (error) {
console.error('Signature verification failed:', error);
return false;
}
}

// Example usage (uncomment to test):
/*
const mockEvent = {
id: '...', // hex string
pubkey: '...', // hex string
sig: '...', // hex string
};
verifyEventSignature(mockEvent).then(console.log);
*/
1 change: 1 addition & 0 deletions test/unit/adapters/web-socket-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ describe('WebSocketAdapter', () => {
slidingWindowRateLimiter,
settingsFactory,
)
client.send.resetHistory()
})

afterEach(() => {
Expand Down
34 changes: 34 additions & 0 deletions test_task4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { verifyEventSignature } from './task4';
import * as secp256k1 from '@noble/secp256k1';
import * as crypto from 'crypto';

async function testTask4() {
const privKey = '0000000000000000000000000000000000000000000000000000000000000001';
const pubKey = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(privKey, true).subarray(1));

const event: any = {
pubkey: pubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test content',
};

const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
event.id = crypto.createHash('sha256').update(serialized).digest('hex');

const sig = await secp256k1.schnorr.sign(event.id, privKey);
event.sig = secp256k1.utils.bytesToHex(sig);

const isValid = await verifyEventSignature(event);
console.log('Is generated event signature valid?', isValid);
}

testTask4().catch(console.error);