diff --git a/packages/app/src/context.ts b/packages/app/src/context.ts index 21ff29c..50a29bf 100644 --- a/packages/app/src/context.ts +++ b/packages/app/src/context.ts @@ -2,10 +2,10 @@ import {partition} from "@welshman/lib" import {defaultOptimizeSubscriptions, getDefaultNetContext as originalGetDefaultNetContext} from "@welshman/net" import type {Subscription, RelaysAndFilters, NetContext} from "@welshman/net" import {WRAP, unionFilters, isSignedEvent, hasValidSignature} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" +import type {TrustedEvent, StampedEvent} from "@welshman/util" import {tracker, repository} from './core' import {makeRouter, getFilterSelections} from './router' -import {onAuth, getSession} from './session' +import {getSession, signer} from './session' import type {Router} from './router' import {loadProfile} from './profiles' @@ -20,7 +20,7 @@ export type AppContext = { export const getDefaultNetContext = (overrides: Partial = {}) => ({ ...originalGetDefaultNetContext(), - onAuth: onAuth, + signEvent: (event: StampedEvent) => signer.get()?.sign(event), onEvent: (url: string, event: TrustedEvent) => { tracker.track(event.id, url) repository.publish(event) diff --git a/packages/app/src/relays.ts b/packages/app/src/relays.ts index 47a0e80..38f1ed6 100644 --- a/packages/app/src/relays.ts +++ b/packages/app/src/relays.ts @@ -3,7 +3,7 @@ import {withGetter} from '@welshman/store' import {ctx, groupBy, indexBy, batch, now, uniq, batcher, postJson} from '@welshman/lib' import type {RelayProfile} from "@welshman/util" import {normalizeRelayUrl, displayRelayUrl, displayRelayProfile} from "@welshman/util" -import {AuthStatus, asMessage, type Connection, type SocketMessage} from '@welshman/net' +import {asMessage, type Connection, type SocketMessage} from '@welshman/net' import {collection} from './collection' export type RelayStats = { @@ -13,7 +13,6 @@ export type RelayStats = { publish_count: number connect_count: number recent_errors: number[] - last_auth_status: AuthStatus } // Relays @@ -25,7 +24,6 @@ export const makeRelayStats = (): RelayStats => ({ publish_count: 0, connect_count: 0, recent_errors: [], - last_auth_status: AuthStatus.Pending, }) export type Relay = { @@ -136,14 +134,6 @@ const onConnectionReceive = ({url}: Connection, socketMessage: SocketMessage) => if (verb === 'EVENT') { updateRelayStats([url, stats => ++stats.event_count]) - } else if (verb === 'OK') { - updateRelayStats([url, stats => { - stats.last_auth_status = AuthStatus.Ok - }]) - } else if (verb === 'AUTH') { - updateRelayStats([url, stats => { - stats.last_auth_status = AuthStatus.Unauthorized - }]) } } diff --git a/packages/app/src/router.ts b/packages/app/src/router.ts index 7c5446c..f8f5d72 100644 --- a/packages/app/src/router.ts +++ b/packages/app/src/router.ts @@ -7,7 +7,7 @@ import { PROFILE, RELAYS, INBOX_RELAYS, FOLLOWS, LOCAL_RELAY_URL, WRAP, } from '@welshman/util' import type {TrustedEvent, Filter} from '@welshman/util' -import {ConnectionStatus} from '@welshman/net' +import {ConnectionStatus, AuthStatus} from '@welshman/net' import type {RelaysAndFilters} from '@welshman/net' import {pubkey} from './session' import {relaySelectionsByPubkey, inboxRelaySelectionsByPubkey, getReadRelayUrls, getWriteRelayUrls, getRelayUrls} from './relaySelections' @@ -415,15 +415,21 @@ export const getRelayQuality = (url: string) => { return Math.max(0, Math.min(0.5, (now() - oneMinute - lastFault) / oneHour)) } - return switcher(connection.meta.getStatus(), { - [ConnectionStatus.Unauthorized]: 0.5, - [ConnectionStatus.Forbidden]: 0, + const authScore = switcher(connection.auth.status, { + [AuthStatus.Forbidden]: 0, + [AuthStatus.Ok]: 1, + default: 0.5, + }) + + const connectionScore = switcher(connection.meta.getStatus(), { [ConnectionStatus.Error]: 0, [ConnectionStatus.Closed]: 0.6, [ConnectionStatus.Slow]: 0.5, [ConnectionStatus.Ok]: 1, default: clamp([0.5, 1], connect_count / 1000), }) + + return authScore * connectionScore } export const getPubkeyRelays = (pubkey: string, mode?: string) => { diff --git a/packages/app/src/session.ts b/packages/app/src/session.ts index a0a349f..d6734a6 100644 --- a/packages/app/src/session.ts +++ b/packages/app/src/session.ts @@ -1,6 +1,5 @@ import {derived} from "svelte/store" -import {ctx, memoize, omit, equals, assoc} from "@welshman/lib" -import {createEvent} from "@welshman/util" +import {memoize, omit, equals, assoc} from "@welshman/lib" import {withGetter, synced} from "@welshman/store" import {type Nip46Handler} from "@welshman/signer" import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer" @@ -60,29 +59,6 @@ export const getSigner = memoize((session: Session) => { export const signer = withGetter(derived(session, getSigner)) -export const authChallenges = new Set() - -export const onAuth = async (url: string, challenge: string) => { - if (authChallenges.has(challenge) || !signer.get()) { - return - } - - authChallenges.add(challenge) - - const event = await signer.get()!.sign( - createEvent(22242, { - tags: [ - ["relay", url], - ["challenge", challenge], - ], - }), - ) - - ctx.net.pool.get(url).send(["AUTH", event]) - - return event -} - export const nip44EncryptToSelf = (payload: string) => { const $pubkey = pubkey.get() const $signer = signer.get() diff --git a/packages/net/src/Connection.ts b/packages/net/src/Connection.ts index 159bd87..155880d 100644 --- a/packages/net/src/Connection.ts +++ b/packages/net/src/Connection.ts @@ -1,7 +1,8 @@ -import {ctx, Emitter, Worker, sleep} from '@welshman/lib' -import {AuthStatus, ConnectionMeta} from './ConnectionMeta' +import {Emitter, Worker, sleep} from '@welshman/lib' +import {ConnectionMeta} from './ConnectionMeta' +import {ConnectionAuth, AuthStatus} from './ConnectionAuth' import {Socket, isMessage, asMessage} from './Socket' -import type {SocketMessage, Message} from './Socket' +import type {SocketMessage} from './Socket' export class Connection extends Emitter { url: string @@ -9,6 +10,7 @@ export class Connection extends Emitter { sender: Worker receiver: Worker meta: ConnectionMeta + auth: ConnectionAuth constructor(url: string) { super() @@ -18,6 +20,7 @@ export class Connection extends Emitter { this.sender = this.createSender() this.receiver = this.createReceiver() this.meta = new ConnectionMeta(this) + this.auth = new ConnectionAuth(this) this.setMaxListeners(100) } @@ -45,7 +48,7 @@ export class Connection extends Emitter { } // Only defer for auth if we're not multiplexing - if (isMessage(message) && ![AuthStatus.Ok, AuthStatus.Pending].includes(this.meta.authStatus)) { + if (isMessage(message) && ![AuthStatus.None, AuthStatus.Ok].includes(this.auth.status)) { return true } @@ -93,14 +96,6 @@ export class Connection extends Emitter { } onReceive = (message: SocketMessage) => { - const [verb, ...extra] = asMessage(message) - - if (verb === 'AUTH') { - const [challenge] = extra - - ctx.net.onAuth(this.url, challenge) - } - this.emit('receive', this, message) } @@ -121,26 +116,6 @@ export class Connection extends Emitter { } } - ensureAuth = async ({timeout = 3000} = {}) => { - await this.ensureConnected() - - if ([AuthStatus.Unauthorized, AuthStatus.Pending].includes(this.meta.authStatus)) { - await Promise.race([ - sleep(timeout), - new Promise(resolve => { - const onReceive = (cxn: Connection, message: Message) => { - if (message[0] === 'OK' && message[2]) { - this.off('receive', onReceive) - resolve() - } - } - - this.on('receive', onReceive) - }) - ]) - } - } - disconnect() { this.socket.disconnect() this.sender.clear() diff --git a/packages/net/src/ConnectionAuth.ts b/packages/net/src/ConnectionAuth.ts new file mode 100644 index 0000000..89a69b8 --- /dev/null +++ b/packages/net/src/ConnectionAuth.ts @@ -0,0 +1,131 @@ +import {ctx, sleep} from '@welshman/lib' +import {CLIENT_AUTH, createEvent} from '@welshman/util' +import type {Connection} from './Connection' +import type {SocketMessage} from './Socket' +import {asMessage} from './Socket' + +export enum AuthMode { + Implicit = 'implicit', + Explicit = 'explicit', +} + +export enum AuthStatus { + None = 'none', + Requested = 'requested', + PendingSignature = 'pending_signature', + DeniedSignature = 'denied_signature', + PendingResponse = 'pending_response', + Forbidden = 'forbidden', + Ok = 'ok', +} + +const { + None, + Requested, + PendingSignature, + DeniedSignature, + PendingResponse, + Forbidden, + Ok, +} = AuthStatus + +export class ConnectionAuth { + challenge: string | undefined + request: string | undefined + message: string | undefined + status = None + + constructor(readonly connection: Connection) { + this.connection.on('receive', this.#onReceive) + } + + #onReceive = (connection: Connection, message: SocketMessage) => { + const [verb, ...extra] = asMessage(message) + + if (verb === 'OK') { + const [id, ok, message] = extra + + if (id === this.request) { + this.challenge = undefined + this.request = undefined + this.message = message + this.status = ok ? Ok : Forbidden + } + } + + if (verb === 'AUTH' && extra[0] !== this.challenge) { + this.challenge = extra[0] + this.request = undefined + this.message = undefined + this.status = Requested + + if (ctx.net.authMode === AuthMode.Implicit) { + this.attempt() + } + } + } + + attempt = async () => { + if (!this.challenge) { + throw new Error("Attempted to authenticate with no challenge") + } + + if (this.status !== Requested) { + throw new Error(`Attempted to authenticate when auth is already ${this.status}`) + } + + this.status = PendingSignature + + const template = createEvent(CLIENT_AUTH, { + tags: [ + ["relay", this.connection.url], + ["challenge", this.challenge], + ], + }) + + const [event] = await Promise.all([ + ctx.net.signEvent(template), + this.connection.ensureConnected(), + ]) + + if (event) { + this.request = event.id + this.connection.send(['AUTH', event]) + this.status = PendingResponse + } else { + this.status = DeniedSignature + } + } + + attemptIfRequested = async () => { + if (this.status === Requested) { + await this.attempt() + } + } + + wait = async ({timeout = 3000}: {timeout?: number} = {}) => { + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + await sleep(100) + + if ([None, Requested].includes(this.status)) { + throw new Error("Auth flow reset while waiting for auth") + } + + if ([DeniedSignature, Forbidden, Ok].includes(this.status)) { + break + } + } + } + + waitIfPending = async ({timeout = 3000}: {timeout?: number} = {}) => { + if ([PendingSignature, PendingResponse].includes(this.status)) { + await this.wait({timeout}) + } + } + + destroy = () => { + this.connection.off('recieve', this.#onReceive) + } +} diff --git a/packages/net/src/ConnectionMeta.ts b/packages/net/src/ConnectionMeta.ts index 5560194..d4ffe94 100644 --- a/packages/net/src/ConnectionMeta.ts +++ b/packages/net/src/ConnectionMeta.ts @@ -13,16 +13,7 @@ export type RequestMeta = { eoseReceived: boolean } -export enum AuthStatus { - Ok = 'ok', - Pending = 'pending', - Unauthorized = 'unauthorized', - Forbidden = 'forbidden', -} - export enum ConnectionStatus { - Unauthorized = 'unauthorized', - Forbidden = 'forbidden', Error = 'error', Closed = 'closed', Slow = 'slow', @@ -30,7 +21,6 @@ export enum ConnectionStatus { } export class ConnectionMeta { - authStatus = AuthStatus.Pending pendingPublishes = new Map() pendingRequests = new Map() publishCount = 0 @@ -96,12 +86,8 @@ export class ConnectionMeta { } onReceiveOk([verb, eventId, ok, notice]: Message) { - const publish = this.pendingPublishes.get(eventId) - - if (ok) { - this.authStatus = AuthStatus.Ok - } else if (notice?.startsWith('auth-required:')) { - // Re-enqueue pending events when auth challenge is received + // Re-enqueue pending events when auth challenge is received + if (notice?.startsWith('auth-required:')) { const pub = this.pendingPublishes.get(eventId) if (pub) { @@ -109,6 +95,8 @@ export class ConnectionMeta { } } + const publish = this.pendingPublishes.get(eventId) + if (publish) { this.responseCount++ this.responseTimer += Date.now() - publish.sent @@ -116,8 +104,7 @@ export class ConnectionMeta { } } - onReceiveAuth([verb, eventId]: Message) { - this.authStatus = AuthStatus.Unauthorized + onReceiveAuth(message: Message) { this.lastAuth = Date.now() } @@ -139,8 +126,8 @@ export class ConnectionMeta { } onReceiveClosed([verb, id, notice]: Message) { - if (notice.startsWith('auth-required:')) { - // Re-enqueue pending reqs when auth challenge is received + // Re-enqueue pending reqs when auth challenge is received + if (notice?.startsWith('auth-required:')) { const req = this.pendingRequests.get(id) if (req) { @@ -163,24 +150,11 @@ export class ConnectionMeta { getStatus = () => { const socket = this.cxn.socket - if (this.authStatus === AuthStatus.Unauthorized) return ConnectionStatus.Unauthorized - if (this.authStatus === AuthStatus.Forbidden) return ConnectionStatus.Forbidden if (socket.isNew()) return ConnectionStatus.Closed if (this.lastFault && this.lastFault > this.lastOpen) return ConnectionStatus.Error if (socket.isClosed() || socket.isClosing()) return ConnectionStatus.Closed - if (this.getSpeed() > 1000) return ConnectionStatus.Slow + if (this.getSpeed() > 2000) return ConnectionStatus.Slow return ConnectionStatus.Ok } - - getDescription = () => { - switch (this.getStatus()) { - case ConnectionStatus.Unauthorized: return 'Logging in' - case ConnectionStatus.Forbidden: return 'Failed to log in' - case ConnectionStatus.Error: return 'Failed to connect' - case ConnectionStatus.Closed: return 'Waiting to reconnect' - case ConnectionStatus.Slow: return 'Slow Connection' - case ConnectionStatus.Ok: return 'Connected' - } - } } diff --git a/packages/net/src/Context.ts b/packages/net/src/Context.ts index f7180a5..0f1eabd 100644 --- a/packages/net/src/Context.ts +++ b/packages/net/src/Context.ts @@ -1,16 +1,18 @@ import {ctx, uniq, noop, always} from '@welshman/lib' import {matchFilters, unionFilters, isSignedEvent, hasValidSignature} from '@welshman/util' -import type {Filter, TrustedEvent} from '@welshman/util' +import type {StampedEvent, SignedEvent, Filter, TrustedEvent} from '@welshman/util' import {Pool} from "./Pool" import {Executor} from "./Executor" +import {AuthMode} from "./ConnectionAuth" import {Relays} from "./target/Relays" import type {Subscription, RelaysAndFilters} from "./Subscribe" export type NetContext = { pool: Pool - getExecutor: (relays: string[]) => Executor + authMode: AuthMode, onEvent: (url: string, event: TrustedEvent) => void - onAuth: (url: string, challenge: string) => void + signEvent: (event: StampedEvent) => Promise + getExecutor: (relays: string[]) => Executor isDeleted: (url: string, event: TrustedEvent) => boolean isValid: (url: string, event: TrustedEvent) => boolean matchFilters: (url: string, filters: Filter[], event: TrustedEvent) => boolean @@ -27,9 +29,10 @@ export const defaultOptimizeSubscriptions = (subs: Subscription[]) => }) export const getDefaultNetContext = (overrides: Partial = {}) => ({ - onAuth: noop, - onEvent: noop, pool: new Pool(), + authMode: AuthMode.Implicit, + onEvent: noop, + signEvent: noop, isDeleted: always(false), isValid: (url: string, event: TrustedEvent) => isSignedEvent(event) && hasValidSignature(event), getExecutor: (relays: string[]) => new Executor(new Relays(relays.map((relay: string) => ctx.net.pool.get(relay)))), diff --git a/packages/net/src/Subscribe.ts b/packages/net/src/Subscribe.ts index 9074ec8..2c34081 100644 --- a/packages/net/src/Subscribe.ts +++ b/packages/net/src/Subscribe.ts @@ -291,7 +291,7 @@ const _executeSubscription = (sub: Subscription) => { Promise.all( executor.target.connections.map(async (connection: Connection) => { if (authTimeout) { - await connection.ensureAuth({timeout: authTimeout}) + await connection.auth.waitIfPending({timeout: authTimeout}) } }) ).then(() => { diff --git a/packages/net/src/index.ts b/packages/net/src/index.ts index 355e058..0f66ceb 100644 --- a/packages/net/src/index.ts +++ b/packages/net/src/index.ts @@ -1,4 +1,5 @@ export * from "./Connection" +export * from "./ConnectionAuth" export * from "./ConnectionMeta" export * from "./Context" export * from "./Executor"