From 80e516ac15e2574ed4b6c7cc0b1ed901bd1ca077 Mon Sep 17 00:00:00 2001 From: Zakariyah Ali Date: Wed, 22 Apr 2026 20:25:16 +0100 Subject: [PATCH 1/2] feat: implement NIP-42 (AUTH) challenge-response --- docker-compose.yml | 1 - src/@types/adapters.ts | 1 + src/@types/messages.ts | 15 +++- src/adapters/web-socket-adapter.ts | 11 ++- src/constants/base.ts | 2 + src/factories/message-handler-factory.ts | 3 + src/handlers/auth-message-handler.ts | 56 +++++++++++++ src/schemas/message-schema.ts | 4 +- src/utils/messages.ts | 5 ++ task3.js | 83 +++++++++++++++++++ task4.ts | 30 +++++++ test/unit/adapters/web-socket-adapter.spec.ts | 1 + test_task4.ts | 34 ++++++++ 13 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 src/handlers/auth-message-handler.ts create mode 100644 task3.js create mode 100644 task4.ts create mode 100644 test_task4.ts diff --git a/docker-compose.yml b/docker-compose.yml index ee7fd472..46b50904 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,7 +75,6 @@ services: restart: on-failure networks: default: - ipv4_address: 10.10.10.2 nostream-db: image: postgres:15 diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index 130d7853..88bfd081 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -15,6 +15,7 @@ export type IWebSocketAdapter = EventEmitter & { getClientId(): string getClientAddress(): string getSubscriptions(): Map + getChallenge(): string } export interface ICacheAdapter { diff --git a/src/@types/messages.ts b/src/@types/messages.ts index f95538f8..bdfe9244 100644 --- a/src/@types/messages.ts +++ b/src/@types/messages.ts @@ -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 @@ -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 +} diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 989c6083..5706eb00 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -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' @@ -32,6 +33,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private clientAddress: SocketAddress private alive: boolean private subscriptions: Map + private challenge: string public constructor( private readonly client: WebSocket, @@ -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 { diff --git a/src/constants/base.ts b/src/constants/base.ts index 4c8c6cf6..154f23bb 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -40,6 +40,7 @@ export enum EventKinds { PARAMETERIZED_REPLACEABLE_FIRST = 30000, PARAMETERIZED_REPLACEABLE_LAST = 39999, USER_APPLICATION_FIRST = 40000, + AUTH = 22242, } export enum EventTags { @@ -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' diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 273b5b37..b6943725 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -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' @@ -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)}`) } diff --git a/src/handlers/auth-message-handler.ts b/src/handlers/auth-message-handler.ts new file mode 100644 index 00000000..b894a9c2 --- /dev/null +++ b/src/handlers/auth-message-handler.ts @@ -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, + ) {} + + public async handleMessage(message: AuthMessage): Promise { + 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) + } +} diff --git a/src/schemas/message-schema.ts b/src/schemas/message-schema.ts index 53b8f09f..0a18ad8d 100644 --- a/src/schemas/message-schema.ts +++ b/src/schemas/message-schema.ts @@ -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]) diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 0b98d5f4..bc751a78 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,4 +1,5 @@ import { + AuthChallengeMessage, ClosedMessage, CountResultMessage, CountResultPayload, @@ -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[], diff --git a/task3.js b/task3.js new file mode 100644 index 00000000..37c7086c --- /dev/null +++ b/task3.js @@ -0,0 +1,83 @@ +const WebSocket = require('ws'); +const secp256k1 = require('@noble/secp256k1'); +const crypto = require('crypto'); + +/** + * Task 3: Standalone Node.js script that connects to a relay, + * receives an AUTH challenge, constructs a valid kind 22242 event, + * and sends it back. + */ +async function solveTask3() { + const relayUrl = 'ws://localhost:8008'; + const ws = new WebSocket(relayUrl); + + // Generate a temporary keypair for the demo + const privKey = secp256k1.utils.randomPrivateKey(); + const pubKey = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(privKey, true).subarray(1)); + + console.log('Connecting to', relayUrl, '...'); + + ws.on('open', () => { + console.log('Connected to relay'); + }); + + ws.on('message', async (data) => { + const message = JSON.parse(data.toString()); + console.log('Received from relay:', message); + + if (message[0] === 'AUTH' && typeof message[1] === 'string') { + const challenge = message[1]; + console.log('>>> Received AUTH challenge:', challenge); + + // Construct kind 22242 event (NIP-42) + const event = { + pubkey: pubKey, + created_at: Math.floor(Date.now() / 1000), + kind: 22242, + tags: [ + ['relay', relayUrl], + ['challenge', challenge] + ], + content: '' + }; + + // Calculate ID (Hash) + const serialized = JSON.stringify([ + 0, + event.pubkey, + event.created_at, + event.kind, + event.tags, + event.content + ]); + const id = crypto.createHash('sha256').update(serialized).digest('hex'); + event.id = id; + + // Sign event + console.log('Signing event...'); + const sig = await secp256k1.schnorr.sign(event.id, privKey); + event.sig = secp256k1.utils.bytesToHex(sig); + + // Send back + const authResponse = JSON.stringify(['AUTH', event]); + console.log('>>> Sending AUTH response:', authResponse); + ws.send(authResponse); + + // Wait a bit to see if we get a response (though NIP-42 doesn't mandate one) + setTimeout(() => { + console.log('Closing connection...'); + ws.close(); + }, 2000); + } + }); + + ws.on('error', (err) => { + console.error('WebSocket error:', err); + }); + + ws.on('close', () => { + console.log('Connection closed'); + }); +} + +solveTask3().catch(console.error); diff --git a/task4.ts b/task4.ts new file mode 100644 index 00000000..c0038478 --- /dev/null +++ b/task4.ts @@ -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 { + 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); +*/ diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index a6517509..b38fe87b 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -77,6 +77,7 @@ describe('WebSocketAdapter', () => { slidingWindowRateLimiter, settingsFactory, ) + client.send.resetHistory() }) afterEach(() => { diff --git a/test_task4.ts b/test_task4.ts new file mode 100644 index 00000000..60713702 --- /dev/null +++ b/test_task4.ts @@ -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); From 16e055293694c4df52de669ad49ae8b6184ee421 Mon Sep 17 00:00:00 2001 From: Zakariyah Ali Date: Sat, 25 Apr 2026 10:58:21 +0100 Subject: [PATCH 2/2] feat: add UDP multicast competency test script --- task3.js | 187 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 108 insertions(+), 79 deletions(-) diff --git a/task3.js b/task3.js index 37c7086c..8e82279d 100644 --- a/task3.js +++ b/task3.js @@ -1,83 +1,112 @@ -const WebSocket = require('ws'); -const secp256k1 = require('@noble/secp256k1'); -const crypto = require('crypto'); - -/** - * Task 3: Standalone Node.js script that connects to a relay, - * receives an AUTH challenge, constructs a valid kind 22242 event, - * and sends it back. - */ -async function solveTask3() { - const relayUrl = 'ws://localhost:8008'; - const ws = new WebSocket(relayUrl); - - // Generate a temporary keypair for the demo - const privKey = secp256k1.utils.randomPrivateKey(); - const pubKey = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(privKey, true).subarray(1)); - - console.log('Connecting to', relayUrl, '...'); - - ws.on('open', () => { - console.log('Connected to relay'); - }); - - ws.on('message', async (data) => { - const message = JSON.parse(data.toString()); - console.log('Received from relay:', message); - - if (message[0] === 'AUTH' && typeof message[1] === 'string') { - const challenge = message[1]; - console.log('>>> Received AUTH challenge:', challenge); - - // Construct kind 22242 event (NIP-42) - const event = { - pubkey: pubKey, - created_at: Math.floor(Date.now() / 1000), - kind: 22242, - tags: [ - ['relay', relayUrl], - ['challenge', challenge] - ], - content: '' - }; - - // Calculate ID (Hash) - const serialized = JSON.stringify([ - 0, - event.pubkey, - event.created_at, - event.kind, - event.tags, - event.content - ]); - const id = crypto.createHash('sha256').update(serialized).digest('hex'); - event.id = id; - - // Sign event - console.log('Signing event...'); - const sig = await secp256k1.schnorr.sign(event.id, privKey); - event.sig = secp256k1.utils.bytesToHex(sig); - - // Send back - const authResponse = JSON.stringify(['AUTH', event]); - console.log('>>> Sending AUTH response:', authResponse); - ws.send(authResponse); - - // Wait a bit to see if we get a response (though NIP-42 doesn't mandate one) - setTimeout(() => { - console.log('Closing connection...'); - ws.close(); - }, 2000); - } - }); +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' +} - ws.on('error', (err) => { - console.error('WebSocket error:', err); - }); +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 + } - ws.on('close', () => { - console.log('Connection closed'); - }); + console.log(`Sent dummy Nostr event to ${MULTICAST_GROUP}:${MULTICAST_PORT}`) + }) + }) + }) } -solveTask3().catch(console.error); +solveTask3() + .then(() => { + process.exitCode = 0 + }) + .catch((error) => { + console.error('Task 3 failed:', error.message) + process.exitCode = 1 + })