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
83 changes: 83 additions & 0 deletions task3.js
Original file line number Diff line number Diff line change
@@ -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);
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);