|
| 1 | +import { EventKinds, EventTags } from '../constants/base' |
| 2 | +import { isEventIdValid, isEventSignatureValid } from '../utils/event' |
| 3 | +import { AuthMessage } from '../@types/messages' |
| 4 | +import { createCommandResult } from '../utils/messages' |
| 5 | +import { createLogger } from '../factories/logger-factory' |
| 6 | +import { IMessageHandler } from '../@types/message-handlers' |
| 7 | +import { IWebSocketAdapter } from '../@types/adapters' |
| 8 | +import { Settings } from '../@types/settings' |
| 9 | +import { WebSocketAdapterEvent } from '../constants/adapter' |
| 10 | + |
| 11 | +const logger = createLogger('auth-message-handler') |
| 12 | + |
| 13 | +const AUTH_EVENT_KIND = EventKinds.AUTH // 22242 |
| 14 | +const MAX_TIMESTAMP_DELTA_SECONDS = 600 // 10 minutes |
| 15 | + |
| 16 | +export class AuthMessageHandler implements IMessageHandler { |
| 17 | + public constructor( |
| 18 | + private readonly webSocket: IWebSocketAdapter, |
| 19 | + private readonly settings: () => Settings, |
| 20 | + ) {} |
| 21 | + |
| 22 | + public async handleMessage(message: AuthMessage): Promise<void> { |
| 23 | + const event = message[1] |
| 24 | + |
| 25 | + if (event.kind !== AUTH_EVENT_KIND) { |
| 26 | + this.sendResult(event.id, false, 'invalid: auth event must be kind 22242') |
| 27 | + return |
| 28 | + } |
| 29 | + |
| 30 | + if (!(await isEventIdValid(event))) { |
| 31 | + this.sendResult(event.id, false, 'invalid: event id does not match') |
| 32 | + return |
| 33 | + } |
| 34 | + |
| 35 | + if (!(await isEventSignatureValid(event))) { |
| 36 | + this.sendResult(event.id, false, 'invalid: event signature verification failed') |
| 37 | + return |
| 38 | + } |
| 39 | + |
| 40 | + const now = Math.floor(Date.now() / 1000) |
| 41 | + const delta = Math.abs(now - event.created_at) |
| 42 | + if (delta > MAX_TIMESTAMP_DELTA_SECONDS) { |
| 43 | + this.sendResult(event.id, false, 'invalid: created_at is too far from the current time') |
| 44 | + return |
| 45 | + } |
| 46 | + |
| 47 | + const challengeTag = event.tags.find( |
| 48 | + (tag) => tag.length >= 2 && tag[0] === EventTags.Challenge, |
| 49 | + ) |
| 50 | + if (!challengeTag || challengeTag[1] !== this.webSocket.getChallenge()) { |
| 51 | + this.sendResult(event.id, false, 'invalid: challenge does not match') |
| 52 | + return |
| 53 | + } |
| 54 | + |
| 55 | + const relayTag = event.tags.find( |
| 56 | + (tag) => tag.length >= 2 && tag[0] === EventTags.AuthRelay, |
| 57 | + ) |
| 58 | + const relayUrl = this.settings().info.relay_url |
| 59 | + if (!relayTag || !this.isRelayUrlMatch(relayTag[1], relayUrl)) { |
| 60 | + this.sendResult(event.id, false, 'invalid: relay url does not match') |
| 61 | + return |
| 62 | + } |
| 63 | + |
| 64 | + logger('client %s authenticated as %s', this.webSocket.getClientId(), event.pubkey) |
| 65 | + this.webSocket.addAuthenticatedPubkey(event.pubkey) |
| 66 | + this.sendResult(event.id, true, '') |
| 67 | + } |
| 68 | + |
| 69 | + private sendResult(eventId: string, success: boolean, message: string): void { |
| 70 | + this.webSocket.emit( |
| 71 | + WebSocketAdapterEvent.Message, |
| 72 | + createCommandResult(eventId, success, message), |
| 73 | + ) |
| 74 | + } |
| 75 | + |
| 76 | + // NIP-42 says domain-match is sufficient for relay URL comparison |
| 77 | + private isRelayUrlMatch(clientRelay: string, serverRelay: string): boolean { |
| 78 | + try { |
| 79 | + const clientHost = new URL(clientRelay).hostname.toLowerCase() |
| 80 | + const serverHost = new URL(serverRelay).hostname.toLowerCase() |
| 81 | + return clientHost === serverHost |
| 82 | + } catch { |
| 83 | + return false |
| 84 | + } |
| 85 | + } |
| 86 | +} |
0 commit comments