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);