From 9b585db93796b4149d3932487686a46a597b7643 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 2 Nov 2026 01:49:22 +0530 Subject: [PATCH 1/2] feat(peering): authenticate peer relays with NIP-42 (#933) --- .changeset/peering-peer-auth.md | 16 ++ CONFIGURATION.md | 10 ++ docs/PEERING.md | 84 +++++++++ resources/default-settings.yaml | 9 + src/@types/adapters.ts | 8 + src/@types/settings.ts | 21 +++ src/adapters/web-socket-adapter.ts | 16 ++ src/handlers/auth-message-handler.ts | 8 + src/services/peering-auth-service.ts | 132 +++++++++++++++ src/utils/peering-identity.ts | 81 +++++++++ src/utils/settings-config.ts | 55 ++++++ test/unit/adapters/web-socket-adapter.spec.ts | 24 +++ .../handlers/auth-message-handler.spec.ts | 33 ++++ .../services/peering-auth-service.spec.ts | 159 ++++++++++++++++++ test/unit/utils/peering-identity.spec.ts | 101 +++++++++++ test/unit/utils/settings-config.spec.ts | 54 ++++++ 16 files changed, 811 insertions(+) create mode 100644 .changeset/peering-peer-auth.md create mode 100644 docs/PEERING.md create mode 100644 src/services/peering-auth-service.ts create mode 100644 src/utils/peering-identity.ts create mode 100644 test/unit/services/peering-auth-service.spec.ts create mode 100644 test/unit/utils/peering-identity.spec.ts diff --git a/.changeset/peering-peer-auth.md b/.changeset/peering-peer-auth.md new file mode 100644 index 000000000..3e374edd7 --- /dev/null +++ b/.changeset/peering-peer-auth.md @@ -0,0 +1,16 @@ +--- +"nostream": minor +--- + +feat(peering): authenticate peer relays with NIP-42 using the relay keypair + +Adds the peering identity layer: a configured `peering.peers` trust list keyed +by relay pubkey (hex or `npub`), outbound NIP-42 AUTH signed with the relay's own +keypair, and inbound recognition of a verified peer pubkey on the connection +state. Peer status is tied to the NIP-42 session, so it expires or clears with +the socket; a valid AUTH from an unconfigured pubkey stays an ordinary client. + +`peering.peers[].relayUrl` is the outbound address only — identity is always the +verified pubkey, never the URL. Relay key storage and the AUTH exchange are +documented in `docs/PEERING.md`, and the new fields are validated with the rest +of the settings document. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 787d10aa3..c177ca376 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -287,6 +287,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta | peering.enabled | Advertise a `peering` section in the NIP-11 document. This only expresses feeds and terms; it does not accept peers. Defaults to false. | | peering.endpoint | WebSocket endpoint (`ws://` or `wss://`) peers should connect to, advertised to other relays. At least one of `contact` or `endpoint` is required when peering is enabled. | | peering.minRequestPowDifficulty | NIP-13 proof-of-work bits required on peering requests, advertised as `peering.min_pow_difficulty`. `0` (default) means none. Applies to peering requests only, never to operational traffic. | +| peering.peers[].enabled | Whether this configured peer is active. Defaults to true. | +| peering.peers[].pubkey | Peer relay's own pubkey (hex or `npub1...`). Peers are identified by this key: a valid NIP-42 AUTH from any other key gets no peer recognition. | +| peering.peers[].relayUrl | WebSocket URL (`ws://` or `wss://`) used when connecting outbound to this peer. Its host must match the peer's advertised `info.relay_url`, because NIP-42 verifies the `relay` tag host. Inbound recognition uses the pubkey, never the URL. | | peering.scopes[].kinds | Event kinds this feed covers, as kinds or `[min, max]` ranges. Required for every scope. At least one scope is required when peering is enabled. | | peering.scopes[].since | Inclusive lower bound of the feed's time range as a unix timestamp in seconds. Omit for no lower bound. Mutually exclusive with `windowSeconds`. | | peering.scopes[].until | Inclusive upper bound of the feed's time range as a unix timestamp in seconds. Omit for a feed that stays current. Mutually exclusive with `windowSeconds`. | @@ -331,3 +334,10 @@ signal, not a zero amount. `min_pow_difficulty` is the NIP-13 work a peering request must carry; `0` means there is no requirement. When peering is disabled, the section is published as `{ "enabled": false }` and no scopes, terms, or contact details are exposed. + +## Peering identity + +`peering.peers` is the inbound trust list. A connection is recognized as a peer +only when a NIP-42 AUTH event proves one of those pubkeys; matching a URL or +self-declaring a key is not enough. The relay's own identity, key storage, and +the AUTH exchange are documented in [docs/PEERING.md](docs/PEERING.md). diff --git a/docs/PEERING.md b/docs/PEERING.md new file mode 100644 index 000000000..48e5aa659 --- /dev/null +++ b/docs/PEERING.md @@ -0,0 +1,84 @@ +# Peering identity + +Relay peering lets one relay serve feeds to another. Before any feed exists, each +side must be able to prove which relay it is: a URL is an address, not an +identity. Nostream uses NIP-42 AUTH signed with the relay's own keypair for that +proof, and a configured pubkey list for trust. + +This phase covers identity only. Feed treatment, sync, on-demand requests, +payments, and the admin console build on it. + +## Relay keypair + +The relay already has a signing key: + +- `RELAY_PRIVATE_KEY` (hex), when set, wins. +- Otherwise the key is derived from `SECRET`. +- `SECRET` and `RELAY_PRIVATE_KEY` are environment variables, documented in + [CONFIGURATION.md](../CONFIGURATION.md#environment-variables). The private key + is never written to `settings.yaml` and never sent to another relay. + +`info.self` is optional. When set, it is the pubkey advertised as `self` in the +NIP-11 document and must match the key the relay actually signs with. When unset +or unparseable, NIP-11 advertises the derived signing pubkey instead. Run: + +```bash +nostream info +``` + +to print the relay's derived `self` pubkey. Back up `SECRET` (or +`RELAY_PRIVATE_KEY`) like any other credential; losing it changes the relay's +identity and invalidates peers that trust the old pubkey. + +## Configuring peers + +Peers are configured by pubkey, with the URL used to reach them outbound: + +```yaml +peering: + enabled: true + # ... this relay's own advertisement (contact, endpoint, scopes, terms) ... + peers: + - pubkey: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + relayUrl: wss://peer.example.com + enabled: true + - pubkey: npub1... + relayUrl: wss://archive.example.com +``` + +`pubkey` accepts hex or `npub1...`. `enabled` defaults to true. Duplicate pubkeys +are rejected by settings validation, including the same key written once as hex +and once as an `npub`. `relayUrl` is also the value used for the NIP-42 `relay` +tag, so its host must match the peer's advertised `info.relay_url`; connect to a +relay through a hostname it does not advertise and AUTH will be rejected. + +`peering.enabled` controls the NIP-11 advertisement. `peering.peers` is the +trust list and is independent of it: a relay can keep configured peers while its +advertisement is turned off. + +## Inbound: recognizing a peer + +1. The relay sends `["AUTH", ""]` when a WebSocket connects. +2. The peer sends `["AUTH", ]` with `relay` and `challenge` + tags, signed by its relay key. +3. `AuthMessageHandler` verifies the event id, signature, timestamp window, + challenge, and relay-URL domain match. +4. If verification succeeds, the authenticated pubkey is added to the + connection's NIP-42 session. +5. If that pubkey is an enabled `peering.peers` entry, the connection is marked + as a peer. The peer pubkey is attached to the ephemeral connection state. + +Peer status is cleared when the NIP-42 session expires or the socket closes. +Any other pubkey — including a valid signature from an unconfigured relay — is +authenticated as an ordinary client and receives no peer recognition. + +## Outbound: authenticating to a peer + +`authenticateToPeeringRelay` connects to a peer, waits for its AUTH challenge, +builds the same kind 22242 event with this relay's keypair, sends it, and +resolves only after the peer answers `OK true`. The returned socket is open and +authenticated for the caller to use for a feed or negentropy session. + +Only the public half of the keypair is ever visible on the wire. Both sides +check the event id and signature; neither trusts the other's claimed URL or +pubkey without that proof. diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 19abd3e93..44d375add 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -196,6 +196,15 @@ peering: # seconds. An empty list means settlement-free peering. # Example: [{ amount: 1000, unit: msats, period: 2592000 }] terms: [] + # Peering peers trusted by their relay pubkey (hex or npub), never by URL + # alone. A connection that proves one of these keys with NIP-42 AUTH is + # recognized as a peer; any other key gets no peer recognition. + # Example: + # peers: + # - pubkey: replace-with-peer-relay-pubkey-in-hex + # relayUrl: wss://peer.example.com + # enabled: true + peers: [] network: maxPayloadSize: 524288 # Uncomment only when using a trusted reverse proxy and configuring trustedProxies. diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index dfcb36e29..cd6cb96f9 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -20,6 +20,14 @@ export type IWebSocketAdapter = EventEmitter & { getAuthenticatedPubkeys(): ReadonlySet /** Returns false if this AUTH event id was already accepted on this socket. */ addAuthenticatedPubkey(pubkey: string, authEventId: string): boolean + /** + * Configured peering peer proven by NIP-42 AUTH on this socket, or undefined. + * Clears with the AUTH session, so an expired/closed session cannot keep peer + * treatment. + */ + getPeerPubkey(): string | undefined + /** Mark `pubkey` as the peer identity for this socket. Ignored if it is not authenticated. */ + setPeerPubkey(pubkey: string): void drainAndClose(reason?: string): void /** NIP-77 sessions of this connection. */ getNegentropySessions(): INegentropySessions diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 133c03f98..eaf5c71bd 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -550,6 +550,21 @@ export interface PeeringTerm { period: number } +export interface PeeringPeer { + /** + * The peer relay's own pubkey (hex or `npub1...`). Peers are identified by + * this key, never by URL alone. + */ + pubkey: Pubkey + /** + * WebSocket URL used when connecting to this peer. Inbound AUTH is matched + * by `pubkey`; the URL is where this relay reaches it outbound. + */ + relayUrl: string + /** Whether this configured peer is active. Defaults to true. */ + enabled?: boolean +} + export interface PeeringSettings { /** * Whether this relay offers peering feeds. Advertised as `peering.enabled` @@ -580,6 +595,12 @@ export interface PeeringSettings { * `period`). An empty list means settlement-free peering. */ terms?: PeeringTerm[] + /** + * Configured peering peers, trusted by pubkey. A connection that proves one + * of these pubkeys with NIP-42 AUTH is recognized as a peer; any other + * pubkey receives no peer recognition, even with a valid signature. + */ + peers?: PeeringPeer[] } export interface Settings { diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 8befbe7aa..7a957baae 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -75,6 +75,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private alive: boolean private subscriptions: Map private readonly session: Nip42SessionManager + /** Configured peering peer proven by AUTH on this socket, if any. */ + private peerPubkey: string | undefined // NIP-77 private readonly negentropySessions: NegentropySessions @@ -243,6 +245,19 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter return this.session.authenticate(pubkey, authEventId) } + public getPeerPubkey(): string | undefined { + if (!this.peerPubkey) { + return undefined + } + + // Peer status lives and dies with its AUTH session. + return this.session.getAuthenticatedPubkeys().has(this.peerPubkey) ? this.peerPubkey : undefined + } + + public setPeerPubkey(pubkey: string): void { + this.peerPubkey = this.session.getAuthenticatedPubkeys().has(pubkey) ? pubkey : undefined + } + private async onClientMessage(raw: Buffer) { this.alive = true let abortable = false @@ -405,6 +420,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter this.subscriptions.clear() this.negentropySessions.closeAll() this.session.clear() + this.peerPubkey = undefined const handlers = abortableMessageHandlers.get(this.client) if (Array.isArray(handlers) && handlers.length) { diff --git a/src/handlers/auth-message-handler.ts b/src/handlers/auth-message-handler.ts index 538f412ff..15d1de280 100644 --- a/src/handlers/auth-message-handler.ts +++ b/src/handlers/auth-message-handler.ts @@ -7,6 +7,7 @@ import { IMessageHandler } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' import { Settings } from '../@types/settings' import { WebSocketAdapterEvent } from '../constants/adapter' +import { isConfiguredPeeringPeer } from '../utils/peering-identity' const logger = createLogger('auth-message-handler') @@ -66,6 +67,13 @@ export class AuthMessageHandler implements IMessageHandler { this.sendResult(event.id, false, 'invalid: auth event already used') return } + + // Peer identity is a verified AUTH from a configured pubkey, never a URL + // or a self-declared key. Unconfigured pubkeys stay ordinary clients. + if (isConfiguredPeeringPeer(this.settings(), event.pubkey)) { + this.webSocket.setPeerPubkey(event.pubkey) + } + this.sendResult(event.id, true, '') } diff --git a/src/services/peering-auth-service.ts b/src/services/peering-auth-service.ts new file mode 100644 index 000000000..90371c26f --- /dev/null +++ b/src/services/peering-auth-service.ts @@ -0,0 +1,132 @@ +import WebSocket, { RawData } from 'ws' + +import { Event } from '../@types/event' +import { buildPeeringAuthEvent } from '../utils/peering-identity' + +export const PEERING_AUTH_TIMEOUT_MS = 10_000 + +export interface AuthenticatePeerOptions { + relayUrl: string + privateKey: string + timeoutMs?: number + createdAt?: number +} + +export interface AuthenticatedPeerConnection { + /** Open and authenticated socket; the caller owns it from here. */ + socket: WebSocket + challenge: string + authEvent: Event +} + +const closeQuietly = (socket: WebSocket): void => { + try { + socket.close() + } catch { + // The socket may already be closed; there is nothing left to clean up. + } +} + +/** + * Connects to a peer relay and performs NIP-42 AUTH with this relay's own + * keypair. Resolves only after the peer answers `OK true` for the AUTH event, + * leaving the socket open for the caller (a live feed, negentropy session, ...). + */ +export const authenticateToPeeringRelay = (options: AuthenticatePeerOptions): Promise => { + const { relayUrl, privateKey, timeoutMs = PEERING_AUTH_TIMEOUT_MS, createdAt } = options + + return new Promise((resolve, reject) => { + let socket: WebSocket + try { + socket = new WebSocket(relayUrl) + } catch (error) { + reject(error instanceof Error ? error : new Error('Unable to open the peering socket')) + return + } + + let challenge: string | undefined + let authEvent: Event | undefined + let settled = false + let timer: NodeJS.Timeout | undefined + + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + socket.off('message', onMessage) + socket.off('close', onClose) + socket.off('error', onError) + + if (error) { + // A close during the handshake can still emit an `error` later; keep a + // no-op listener so an abandoned connect cannot crash the process. + socket.on('error', () => {}) + closeQuietly(socket) + reject(error) + return + } + + resolve({ socket, challenge: challenge as string, authEvent: authEvent as Event }) + } + + const onMessage = (data: RawData): void => { + let message: unknown + try { + message = JSON.parse(data.toString()) + } catch { + return + } + + if (!Array.isArray(message)) { + return + } + + // The relay sends its challenge as soon as the socket opens. + if (message[0] === 'AUTH' && typeof message[1] === 'string' && challenge === undefined) { + challenge = message[1] + void buildPeeringAuthEvent({ relayUrl, challenge, privateKey, createdAt }).then( + (event) => { + if (settled) { + return + } + authEvent = event + socket.send(JSON.stringify(['AUTH', event])) + }, + (error) => finish(error instanceof Error ? error : new Error('Unable to sign peering AUTH event')), + ) + return + } + + if (message[0] === 'OK' && authEvent !== undefined && message[1] === authEvent.id) { + if (message[2] === true) { + finish() + return + } + + const reason = typeof message[3] === 'string' && message[3] ? `: ${message[3]}` : '' + finish(new Error(`peering AUTH rejected${reason}`)) + } + } + + const onClose = (): void => { + finish(new Error('connection closed before peering AUTH completed')) + } + + const onError = (error: Error): void => { + finish(error) + } + + timer = setTimeout(() => { + finish(new Error('timed out waiting for peering AUTH')) + }, timeoutMs) + timer.unref() + + socket.on('message', onMessage) + socket.on('close', onClose) + socket.on('error', onError) + }) +} diff --git a/src/utils/peering-identity.ts b/src/utils/peering-identity.ts new file mode 100644 index 000000000..e014d4778 --- /dev/null +++ b/src/utils/peering-identity.ts @@ -0,0 +1,81 @@ +import { Tag } from '../@types/base' +import { Event, UnidentifiedEvent } from '../@types/event' +import { PeeringPeer, Settings } from '../@types/settings' +import { EventKinds, EventTags } from '../constants/base' +import { getPublicKey, identifyEvent, signEvent } from './event' +import { parseRelayPubkey } from './nip43-invites' + +export interface PeeringAuthEventOptions { + relayUrl: string + challenge: string + privateKey: string + createdAt?: number +} + +/** + * Normalizes a peer pubkey to lowercase hex. Returns undefined for anything + * that is not a 32-byte hex pubkey or an npub, so malformed configured entries + * are skipped rather than throwing during AUTH. + */ +export const parsePeeringPeerPubkey = (value: unknown): string | undefined => { + if (typeof value !== 'string' || value.length === 0) { + return undefined + } + + try { + return parseRelayPubkey(value) + } catch { + return undefined + } +} + +export const findConfiguredPeeringPeer = ( + settings: Pick | undefined, + pubkey: string, +): PeeringPeer | undefined => { + const normalized = parsePeeringPeerPubkey(pubkey) + if (!normalized) { + return undefined + } + + return settings?.peering?.peers?.find( + (peer) => peer.enabled !== false && parsePeeringPeerPubkey(peer.pubkey) === normalized, + ) +} + +/** True only when `pubkey` is an enabled entry in the configured peer list. */ +export const isConfiguredPeeringPeer = (settings: Pick | undefined, pubkey: string): boolean => + typeof findConfiguredPeeringPeer(settings, pubkey) !== 'undefined' + +/** + * Builds the NIP-42 AUTH event that proves this relay's own identity to a + * source relay. Signed with the relay keypair, so the event's pubkey is the + * derived key, not whatever `info.self` happens to say. + */ +export const buildPeeringAuthEvent = async ({ + relayUrl, + challenge, + privateKey, + createdAt = Math.floor(Date.now() / 1000), +}: PeeringAuthEventOptions): Promise => { + if (!relayUrl.trim()) { + throw new Error('relayUrl is required to build a peering AUTH event') + } + + if (!challenge.trim()) { + throw new Error('challenge is required to build a peering AUTH event') + } + + const unidentified: UnidentifiedEvent = { + pubkey: getPublicKey(privateKey), + created_at: createdAt, + kind: EventKinds.AUTH, + tags: [ + [EventTags.AuthRelay, relayUrl], + [EventTags.Challenge, challenge], + ] as Tag[], + content: '', + } + + return signEvent(privateKey)(await identifyEvent(unidentified)) +} diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index c0421c398..8c5b401dc 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -8,6 +8,7 @@ import { createLogger } from '../factories/logger-factory' import { EVENT_STORE_BACKENDS, isEventStoreBackend } from './event-store' import { GRANDFATHERED_PLAN_ID } from '../@types/user-subscription' import { Settings } from '../@types/settings' +import { parsePeeringPeerPubkey } from './peering-identity' import { getConfigBaseDir, getDefaultSettingsFilePath, @@ -843,6 +844,7 @@ const PEERING_KIND_MAX = 65535 const PEERING_POW_MAX_BITS = 256 const PEERING_SCOPE_KEYS = new Set(['kinds', 'since', 'until', 'windowSeconds']) const PEERING_TERM_KEYS = new Set(['amount', 'unit', 'period']) +const PEERING_PEER_KEYS = new Set(['pubkey', 'relayUrl', 'enabled']) const isValidPeeringKindValue = (value: unknown): boolean => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= PEERING_KIND_MAX @@ -965,6 +967,58 @@ const validatePeeringTerms = (terms: unknown, issues: ValidationIssue[]): void = }) } +const validatePeeringPeers = (peers: unknown, issues: ValidationIssue[]): void => { + if (!Array.isArray(peers)) { + return + } + + const seenPubkeys = new Set() + + peers.forEach((peer, index) => { + const prefix = `peering.peers[${index}]` + + if (!isPlainObject(peer)) { + issues.push({ path: prefix, message: 'peer must be an object' }) + return + } + + for (const key of Object.keys(peer)) { + if (!PEERING_PEER_KEYS.has(key)) { + issues.push({ path: `${prefix}.${key}`, message: 'Unknown setting key' }) + } + } + + const pubkey = parsePeeringPeerPubkey(peer.pubkey) + if (typeof peer.pubkey !== 'string' || !peer.pubkey.trim()) { + issues.push({ path: `${prefix}.pubkey`, message: 'pubkey is required' }) + } else if (!pubkey) { + issues.push({ path: `${prefix}.pubkey`, message: 'must be a 32-byte hex pubkey or npub' }) + } else if (seenPubkeys.has(pubkey)) { + issues.push({ path: `${prefix}.pubkey`, message: 'pubkey must be unique' }) + } else { + seenPubkeys.add(pubkey) + } + + const relayUrl = typeof peer.relayUrl === 'string' ? peer.relayUrl.trim() : '' + if (!relayUrl) { + issues.push({ path: `${prefix}.relayUrl`, message: 'relayUrl is required' }) + } else { + try { + const parsed = new URL(relayUrl) + if (!['ws:', 'wss:'].includes(parsed.protocol) || !parsed.host) { + issues.push({ path: `${prefix}.relayUrl`, message: 'must be a ws:// or wss:// URL' }) + } + } catch { + issues.push({ path: `${prefix}.relayUrl`, message: 'must be a valid ws:// or wss:// URL' }) + } + } + + if (peer.enabled !== undefined && typeof peer.enabled !== 'boolean') { + issues.push({ path: `${prefix}.enabled`, message: 'must be a boolean' }) + } + }) +} + const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => { const peering = settings.peering @@ -1022,6 +1076,7 @@ const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => validatePeeringScopes(peering.scopes, issues) validatePeeringTerms(peering.terms, issues) + validatePeeringPeers(peering.peers, issues) if (!enabled) { return diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index 76bc776a5..59b002f1e 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -991,6 +991,30 @@ describe('WebSocketAdapter', () => { expect(adapter.addAuthenticatedPubkey(pubkey, eventId)).to.equal(false) }) + it('exposes a peer pubkey only after it authenticates', () => { + const pubkey = 'a'.repeat(64) + + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(undefined) + + adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64)) + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(pubkey) + }) + + it('drops peer status when the AUTH session expires', () => { + const clock = sandbox.useFakeTimers({ now: 1_700_000_000_000 }) + settingsFactory.returns({ nip42: { sessionExpirySeconds: 60 } }) + const pubkey = 'a'.repeat(64) + + adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64)) + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(pubkey) + + clock.tick(60_000) + expect(adapter.getPeerPubkey()).to.equal(undefined) + }) + it('expires authenticated pubkeys after sessionExpirySeconds', () => { const clock = sandbox.useFakeTimers({ now: 1_700_000_000_000 }) settingsFactory.returns({ nip42: { sessionExpirySeconds: 60 } }) diff --git a/test/unit/handlers/auth-message-handler.spec.ts b/test/unit/handlers/auth-message-handler.spec.ts index e9b6d1769..b8606ad13 100644 --- a/test/unit/handlers/auth-message-handler.spec.ts +++ b/test/unit/handlers/auth-message-handler.spec.ts @@ -81,6 +81,8 @@ describe('AuthMessageHandler', () => { getChallenge: Sinon.stub().returns(challenge), getAuthenticatedPubkeys: Sinon.stub().returns(new Set()), addAuthenticatedPubkey: Sinon.stub().returns(true), + getPeerPubkey: Sinon.stub().returns(undefined), + setPeerPubkey: Sinon.stub(), } as any as IWebSocketAdapter settingsFactory = Sinon.stub().returns({ @@ -241,5 +243,36 @@ describe('AuthMessageHandler', () => { expect(args[1][2]).to.equal(false) expect(args[1][3]).to.include('auth event already used') }) + + it('marks a configured peer pubkey as the connection peer', async () => { + settingsFactory.returns({ + info: { relay_url: relayUrl }, + peering: { + enabled: true, + peers: [{ pubkey, relayUrl: 'wss://peer.example.com', enabled: true }], + }, + } as Partial) + const message = await createAuthEvent() + + await handler.handleMessage(message) + + expect(webSocket.setPeerPubkey as Sinon.SinonStub).to.have.been.calledOnceWithExactly(pubkey) + }) + + it('does not mark an unconfigured pubkey as a peer', async () => { + settingsFactory.returns({ + info: { relay_url: relayUrl }, + peering: { + enabled: true, + peers: [{ pubkey: getPublicKey('b'.repeat(64)), relayUrl: 'wss://peer.example.com' }], + }, + } as Partial) + const message = await createAuthEvent() + + await handler.handleMessage(message) + + expect(webSocket.setPeerPubkey as Sinon.SinonStub).not.to.have.been.called + expect(emitStub.firstCall.args[1][2]).to.equal(true) + }) }) }) diff --git a/test/unit/services/peering-auth-service.spec.ts b/test/unit/services/peering-auth-service.spec.ts new file mode 100644 index 000000000..b59f4e8b9 --- /dev/null +++ b/test/unit/services/peering-auth-service.spec.ts @@ -0,0 +1,159 @@ +import { AddressInfo } from 'node:net' + +import { expect } from 'chai' +import Sinon from 'sinon' +import { WebSocket, WebSocketServer } from 'ws' +import { AuthMessage } from '../../../src/@types/messages' +import { WebSocketAdapterEvent } from '../../../src/constants/adapter' +import { EventKinds, EventTags } from '../../../src/constants/base' +import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler' +import { authenticateToPeeringRelay } from '../../../src/services/peering-auth-service' +import { getPublicKey, isEventIdValid, isEventSignatureValid } from '../../../src/utils/event' + +describe('peering-auth-service', () => { + const privateKey = 'a'.repeat(64) + const pubkey = getPublicKey(privateKey) + + let server: WebSocketServer | undefined + let client: WebSocket | undefined + + const startServer = async (onConnection: (socket: WebSocket) => void): Promise => { + const created = new WebSocketServer({ port: 0 }) + await new Promise((resolve) => created.once('listening', () => resolve())) + created.on('connection', onConnection) + server = created + const address = created.address() as AddressInfo + + return `ws://127.0.0.1:${address.port}` + } + + afterEach(async () => { + client?.terminate() + client = undefined + + const current = server + server = undefined + if (!current) { + return + } + + await new Promise((resolve) => { + for (const socket of current.clients) { + socket.terminate() + } + current.close(() => resolve()) + }) + }) + + it('authenticates to a relay as this relay keypair', async () => { + const challenge = 'server-challenge' + let received: unknown + + const relayUrl = await startServer((socket) => { + socket.send(JSON.stringify(['AUTH', challenge])) + socket.on('message', (raw) => { + received = JSON.parse(raw.toString()) + const event = (received as [string, { id: string }])[1] + socket.send(JSON.stringify(['OK', event.id, true, ''])) + }) + }) + + const result = await authenticateToPeeringRelay({ relayUrl, privateKey, timeoutMs: 2_000 }) + client = result.socket + + expect(result.challenge).to.equal(challenge) + expect(result.authEvent.pubkey).to.equal(pubkey) + + const message = received as [string, typeof result.authEvent] + expect(message[0]).to.equal('AUTH') + expect(message[1].kind).to.equal(EventKinds.AUTH) + expect(message[1].tags).to.deep.equal([ + [EventTags.AuthRelay, relayUrl], + [EventTags.Challenge, challenge], + ]) + expect(await isEventIdValid(message[1])).to.equal(true) + expect(await isEventSignatureValid(message[1])).to.equal(true) + }) + + it('is recognized as a peer by another nostream relay', async () => { + const challenge = 'nostream-challenge' + let recognized: string | undefined + + const created = new WebSocketServer({ port: 0 }) + await new Promise((resolve) => created.once('listening', () => resolve())) + server = created + const relayUrl = `ws://127.0.0.1:${(created.address() as AddressInfo).port}` + + created.on('connection', (socket) => { + const adapter = { + getClientId: () => 'peer-test', + getChallenge: () => challenge, + addAuthenticatedPubkey: () => true, + setPeerPubkey: (peer: string) => { + recognized = peer + }, + emit: (event: string, payload: unknown) => { + if (event === WebSocketAdapterEvent.Message) { + socket.send(JSON.stringify(payload)) + } + }, + } as any + + const handler = new AuthMessageHandler( + adapter, + () => + ({ + info: { relay_url: relayUrl }, + peering: { + enabled: true, + peers: [{ pubkey, relayUrl: 'wss://peer.example.com', enabled: true }], + }, + }) as any, + ) + + socket.send(JSON.stringify(['AUTH', challenge])) + socket.on('message', (raw) => { + void handler.handleMessage(JSON.parse(raw.toString()) as AuthMessage) + }) + }) + + const result = await authenticateToPeeringRelay({ relayUrl, privateKey, timeoutMs: 2_000 }) + client = result.socket + + expect(result.authEvent.pubkey).to.equal(pubkey) + expect(recognized).to.equal(pubkey) + }) + + it('rejects when the relay refuses the AUTH event', async () => { + const relayUrl = await startServer((socket) => { + socket.send(JSON.stringify(['AUTH', 'server-challenge'])) + socket.on('message', (raw) => { + const event = JSON.parse(raw.toString())[1] as { id: string } + socket.send(JSON.stringify(['OK', event.id, false, 'blocked: peer not allowed'])) + }) + }) + + const error = await authenticateToPeeringRelay({ relayUrl, privateKey, timeoutMs: 2_000 }).then( + () => undefined, + (err: Error) => err, + ) + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.include('peering AUTH rejected') + expect(error?.message).to.include('blocked: peer not allowed') + }) + + it('times out when the relay never sends an AUTH challenge', async () => { + const relayUrl = await startServer(() => { + // Deliberately send nothing. + }) + + const error = await authenticateToPeeringRelay({ relayUrl, privateKey, timeoutMs: 50 }).then( + () => undefined, + (err: Error) => err, + ) + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.include('timed out waiting for peering AUTH') + }) +}) diff --git a/test/unit/utils/peering-identity.spec.ts b/test/unit/utils/peering-identity.spec.ts new file mode 100644 index 000000000..cf75093ab --- /dev/null +++ b/test/unit/utils/peering-identity.spec.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai' + +import { EventKinds, EventTags } from '../../../src/constants/base' +import { getPublicKey, isEventIdValid, isEventSignatureValid } from '../../../src/utils/event' +import { + buildPeeringAuthEvent, + findConfiguredPeeringPeer, + isConfiguredPeeringPeer, + parsePeeringPeerPubkey, +} from '../../../src/utils/peering-identity' +import { toBech32 } from '../../../src/utils/transform' + +describe('peering-identity', () => { + const privateKey = 'a'.repeat(64) + const pubkey = getPublicKey(privateKey) + const otherPubkey = getPublicKey('b'.repeat(64)) + + const settingsWithPeers = (peers: unknown[]) => + ({ + peering: { + enabled: true, + peers, + }, + }) as any + + describe('parsePeeringPeerPubkey', () => { + it('normalizes hex and npub pubkeys to lowercase hex', () => { + expect(parsePeeringPeerPubkey(pubkey)).to.equal(pubkey) + expect(parsePeeringPeerPubkey(pubkey.toUpperCase())).to.equal(pubkey) + expect(parsePeeringPeerPubkey(toBech32('npub')(pubkey))).to.equal(pubkey) + }) + + it('rejects values that are not a 32-byte pubkey', () => { + expect(parsePeeringPeerPubkey('not-a-pubkey')).to.equal(undefined) + expect(parsePeeringPeerPubkey('npub1broken')).to.equal(undefined) + expect(parsePeeringPeerPubkey(42)).to.equal(undefined) + }) + }) + + describe('configured peer lookup', () => { + it('recognizes an enabled configured peer by hex or npub', () => { + const settings = settingsWithPeers([ + { pubkey: toBech32('npub')(pubkey), relayUrl: 'wss://peer.example.com', enabled: true }, + ]) + + expect(isConfiguredPeeringPeer(settings, pubkey)).to.equal(true) + expect(findConfiguredPeeringPeer(settings, pubkey)?.relayUrl).to.equal('wss://peer.example.com') + }) + + it('does not recognize disabled or unconfigured pubkeys', () => { + const settings = settingsWithPeers([ + { pubkey, relayUrl: 'wss://peer.example.com', enabled: false }, + { pubkey: otherPubkey, relayUrl: 'wss://other.example.com' }, + ]) + + expect(isConfiguredPeeringPeer(settings, pubkey)).to.equal(false) + expect(isConfiguredPeeringPeer(settings, getPublicKey('c'.repeat(64)))).to.equal(false) + }) + + it('returns false when peering or the peer list is absent', () => { + expect(isConfiguredPeeringPeer({} as any, pubkey)).to.equal(false) + expect(isConfiguredPeeringPeer({ peering: { enabled: true } } as any, pubkey)).to.equal(false) + }) + }) + + describe('buildPeeringAuthEvent', () => { + it('builds a signed kind 22242 event with relay and challenge tags', async () => { + const event = await buildPeeringAuthEvent({ + relayUrl: 'wss://peer.example.com', + challenge: 'challenge-value', + privateKey, + createdAt: 1_700_000_000, + }) + + expect(event.kind).to.equal(EventKinds.AUTH) + expect(event.pubkey).to.equal(pubkey) + expect(event.created_at).to.equal(1_700_000_000) + expect(event.tags).to.deep.equal([ + [EventTags.AuthRelay, 'wss://peer.example.com'], + [EventTags.Challenge, 'challenge-value'], + ]) + expect(await isEventIdValid(event)).to.equal(true) + expect(await isEventSignatureValid(event)).to.equal(true) + }) + + it('refuses to build an event without a relay URL or challenge', async () => { + for (const options of [ + { relayUrl: '', challenge: 'challenge', privateKey }, + { relayUrl: 'wss://peer.example.com', challenge: ' ', privateKey }, + ]) { + let error: Error | undefined + try { + await buildPeeringAuthEvent(options) + } catch (err) { + error = err as Error + } + expect(error).to.be.instanceOf(Error) + } + }) + }) +}) diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index c16e80ff8..7430b2e39 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -19,6 +19,7 @@ import { withSettingsWriteLock, } from '../../../src/utils/settings-config' import { getSettingsWriteLockPath } from '../../../src/utils/settings-paths' +import { toBech32 } from '../../../src/utils/transform' import { getGuidedSettingCategory, getGuidedSettingField, @@ -248,6 +249,59 @@ describe('settings-config', () => { expect(issues.some((issue) => issue.path === 'peering.minRequestPowDifficulty')).to.equal(true) }) + + it('accepts configured peers by hex or npub', () => { + expect( + peeringIssues({ + ...settlementFreePeering, + peers: [ + { pubkey: 'a'.repeat(64), relayUrl: 'wss://peer.example.com' }, + { pubkey: toBech32('npub')('b'.repeat(64)), relayUrl: 'wss://peer-two.example.com', enabled: false }, + ], + }), + ).to.deep.equal([]) + }) + + it('rejects a peer pubkey that is not a 32-byte key', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + peers: [{ pubkey: 'not-a-pubkey', relayUrl: 'wss://peer.example.com' }], + }) + + expect(issues.some((issue) => issue.path === 'peering.peers[0].pubkey')).to.equal(true) + }) + + it('rejects a peer URL that is not ws or wss', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + peers: [{ pubkey: 'a'.repeat(64), relayUrl: 'https://peer.example.com' }], + }) + + expect(issues.some((issue) => issue.path === 'peering.peers[0].relayUrl')).to.equal(true) + }) + + it('rejects the same peer pubkey in hex and npub form', () => { + const pubkey = 'a'.repeat(64) + const issues = peeringIssues({ + ...settlementFreePeering, + peers: [ + { pubkey, relayUrl: 'wss://peer.example.com' }, + { pubkey: toBech32('npub')(pubkey), relayUrl: 'wss://peer.example.com' }, + ], + }) + + expect(issues.some((issue) => issue.path === 'peering.peers[1].pubkey')).to.equal(true) + }) + + it('rejects unknown peer keys and a non-boolean enabled flag', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + peers: [{ pubkey: 'a'.repeat(64), relayUrl: 'wss://peer.example.com', note: 'x', enabled: 'yes' }], + }) + + expect(issues.some((issue) => issue.path === 'peering.peers[0].note')).to.equal(true) + expect(issues.some((issue) => issue.path === 'peering.peers[0].enabled')).to.equal(true) + }) }) it('rejects invalid nip66 history retention settings', () => { From da69913a520843543e1877a2518e30ff3e03eb1c Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 2 Nov 2026 02:11:38 +0530 Subject: [PATCH 2/2] fix(peering): harden peer recognition and document AUTH scope --- docs/PEERING.md | 16 ++-- src/adapters/web-socket-adapter.ts | 18 +++- src/handlers/auth-message-handler.ts | 2 +- .../peering-auth.ts} | 83 ++++++++++++++++++- src/utils/peering-identity.ts | 81 ------------------ src/utils/settings-config.ts | 2 +- test/unit/adapters/web-socket-adapter.spec.ts | 27 +++++- .../peering-auth-client.spec.ts} | 4 +- ...-identity.spec.ts => peering-auth.spec.ts} | 4 +- 9 files changed, 139 insertions(+), 98 deletions(-) rename src/{services/peering-auth-service.ts => utils/peering-auth.ts} (59%) delete mode 100644 src/utils/peering-identity.ts rename test/unit/{services/peering-auth-service.spec.ts => utils/peering-auth-client.spec.ts} (97%) rename test/unit/utils/{peering-identity.spec.ts => peering-auth.spec.ts} (97%) diff --git a/docs/PEERING.md b/docs/PEERING.md index 48e5aa659..375b7c702 100644 --- a/docs/PEERING.md +++ b/docs/PEERING.md @@ -77,8 +77,14 @@ authenticated as an ordinary client and receives no peer recognition. `authenticateToPeeringRelay` connects to a peer, waits for its AUTH challenge, builds the same kind 22242 event with this relay's keypair, sends it, and resolves only after the peer answers `OK true`. The returned socket is open and -authenticated for the caller to use for a feed or negentropy session. - -Only the public half of the keypair is ever visible on the wire. Both sides -check the event id and signature; neither trusts the other's claimed URL or -pubkey without that proof. +authenticated for the caller to use for a feed or negentropy session. This +proves our identity to the peer; it does not verify the peer's identity in +return. + +Only the public half of the keypair is ever visible on the wire. Inbound AUTH is +verified by `AuthMessageHandler`: event id, signature, timestamp, challenge, and +relay host. Outbound AUTH proves this relay's identity to the peer; it does not +authenticate the remote relay, because NIP-42 is a client-to-relay proof. The +remote relay's identity is established separately by the configured +`peering.peers` pubkey (and, in later phases, by discovery and owner +attestation). diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 7a957baae..3f64e5f55 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -27,6 +27,7 @@ import { Event } from '../@types/event' import { getRemoteAddress } from '../utils/http' import { createReadAuthorizationGuard } from '../utils/nip42' import { Nip42SessionManager } from '../utils/nip42-session' +import { isConfiguredPeeringPeer } from '../utils/peering-auth' import { IRateLimiter } from '../@types/utils' import { isEventMatchingFilter } from '../utils/event' import { isHidden } from '../utils/hidden-content-cache' @@ -250,12 +251,23 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter return undefined } - // Peer status lives and dies with its AUTH session. - return this.session.getAuthenticatedPubkeys().has(this.peerPubkey) ? this.peerPubkey : undefined + // Peer status lives and dies with its AUTH session, and with the operator's + // current trust list: disabling or removing the peer revokes it on the next + // read even if the socket stays authenticated. + if ( + !this.session.getAuthenticatedPubkeys().has(this.peerPubkey) || + !isConfiguredPeeringPeer(this.settings(), this.peerPubkey) + ) { + this.peerPubkey = undefined + return undefined + } + + return this.peerPubkey } public setPeerPubkey(pubkey: string): void { - this.peerPubkey = this.session.getAuthenticatedPubkeys().has(pubkey) ? pubkey : undefined + const authenticated = this.session.getAuthenticatedPubkeys().has(pubkey) + this.peerPubkey = authenticated && isConfiguredPeeringPeer(this.settings(), pubkey) ? pubkey : undefined } private async onClientMessage(raw: Buffer) { diff --git a/src/handlers/auth-message-handler.ts b/src/handlers/auth-message-handler.ts index 15d1de280..88241007f 100644 --- a/src/handlers/auth-message-handler.ts +++ b/src/handlers/auth-message-handler.ts @@ -7,7 +7,7 @@ import { IMessageHandler } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' import { Settings } from '../@types/settings' import { WebSocketAdapterEvent } from '../constants/adapter' -import { isConfiguredPeeringPeer } from '../utils/peering-identity' +import { isConfiguredPeeringPeer } from '../utils/peering-auth' const logger = createLogger('auth-message-handler') diff --git a/src/services/peering-auth-service.ts b/src/utils/peering-auth.ts similarity index 59% rename from src/services/peering-auth-service.ts rename to src/utils/peering-auth.ts index 90371c26f..623eb437a 100644 --- a/src/services/peering-auth-service.ts +++ b/src/utils/peering-auth.ts @@ -1,10 +1,21 @@ import WebSocket, { RawData } from 'ws' -import { Event } from '../@types/event' -import { buildPeeringAuthEvent } from '../utils/peering-identity' +import { Tag } from '../@types/base' +import { Event, UnidentifiedEvent } from '../@types/event' +import { PeeringPeer, Settings } from '../@types/settings' +import { EventKinds, EventTags } from '../constants/base' +import { getPublicKey, identifyEvent, signEvent } from './event' +import { parseRelayPubkey } from './nip43-invites' export const PEERING_AUTH_TIMEOUT_MS = 10_000 +export interface PeeringAuthEventOptions { + relayUrl: string + challenge: string + privateKey: string + createdAt?: number +} + export interface AuthenticatePeerOptions { relayUrl: string privateKey: string @@ -19,6 +30,74 @@ export interface AuthenticatedPeerConnection { authEvent: Event } +/** + * Normalizes a peer pubkey to lowercase hex. Returns undefined for anything + * that is not a 32-byte hex pubkey or an npub, so malformed configured entries + * are skipped rather than throwing during AUTH. + */ +export const parsePeeringPeerPubkey = (value: unknown): string | undefined => { + if (typeof value !== 'string' || value.length === 0) { + return undefined + } + + try { + return parseRelayPubkey(value) + } catch { + return undefined + } +} + +export const findConfiguredPeeringPeer = ( + settings: Pick | undefined, + pubkey: string, +): PeeringPeer | undefined => { + const normalized = parsePeeringPeerPubkey(pubkey) + if (!normalized) { + return undefined + } + + return settings?.peering?.peers?.find( + (peer) => peer.enabled !== false && parsePeeringPeerPubkey(peer.pubkey) === normalized, + ) +} + +/** True only when `pubkey` is an enabled entry in the configured peer list. */ +export const isConfiguredPeeringPeer = (settings: Pick | undefined, pubkey: string): boolean => + typeof findConfiguredPeeringPeer(settings, pubkey) !== 'undefined' + +/** + * Builds the NIP-42 AUTH event that proves this relay's own identity to a + * source relay. Signed with the relay keypair, so the event's pubkey is the + * derived key, not whatever `info.self` happens to say. + */ +export const buildPeeringAuthEvent = async ({ + relayUrl, + challenge, + privateKey, + createdAt = Math.floor(Date.now() / 1000), +}: PeeringAuthEventOptions): Promise => { + if (!relayUrl.trim()) { + throw new Error('relayUrl is required to build a peering AUTH event') + } + + if (!challenge.trim()) { + throw new Error('challenge is required to build a peering AUTH event') + } + + const unidentified: UnidentifiedEvent = { + pubkey: getPublicKey(privateKey), + created_at: createdAt, + kind: EventKinds.AUTH, + tags: [ + [EventTags.AuthRelay, relayUrl], + [EventTags.Challenge, challenge], + ] as Tag[], + content: '', + } + + return signEvent(privateKey)(await identifyEvent(unidentified)) +} + const closeQuietly = (socket: WebSocket): void => { try { socket.close() diff --git a/src/utils/peering-identity.ts b/src/utils/peering-identity.ts deleted file mode 100644 index e014d4778..000000000 --- a/src/utils/peering-identity.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Tag } from '../@types/base' -import { Event, UnidentifiedEvent } from '../@types/event' -import { PeeringPeer, Settings } from '../@types/settings' -import { EventKinds, EventTags } from '../constants/base' -import { getPublicKey, identifyEvent, signEvent } from './event' -import { parseRelayPubkey } from './nip43-invites' - -export interface PeeringAuthEventOptions { - relayUrl: string - challenge: string - privateKey: string - createdAt?: number -} - -/** - * Normalizes a peer pubkey to lowercase hex. Returns undefined for anything - * that is not a 32-byte hex pubkey or an npub, so malformed configured entries - * are skipped rather than throwing during AUTH. - */ -export const parsePeeringPeerPubkey = (value: unknown): string | undefined => { - if (typeof value !== 'string' || value.length === 0) { - return undefined - } - - try { - return parseRelayPubkey(value) - } catch { - return undefined - } -} - -export const findConfiguredPeeringPeer = ( - settings: Pick | undefined, - pubkey: string, -): PeeringPeer | undefined => { - const normalized = parsePeeringPeerPubkey(pubkey) - if (!normalized) { - return undefined - } - - return settings?.peering?.peers?.find( - (peer) => peer.enabled !== false && parsePeeringPeerPubkey(peer.pubkey) === normalized, - ) -} - -/** True only when `pubkey` is an enabled entry in the configured peer list. */ -export const isConfiguredPeeringPeer = (settings: Pick | undefined, pubkey: string): boolean => - typeof findConfiguredPeeringPeer(settings, pubkey) !== 'undefined' - -/** - * Builds the NIP-42 AUTH event that proves this relay's own identity to a - * source relay. Signed with the relay keypair, so the event's pubkey is the - * derived key, not whatever `info.self` happens to say. - */ -export const buildPeeringAuthEvent = async ({ - relayUrl, - challenge, - privateKey, - createdAt = Math.floor(Date.now() / 1000), -}: PeeringAuthEventOptions): Promise => { - if (!relayUrl.trim()) { - throw new Error('relayUrl is required to build a peering AUTH event') - } - - if (!challenge.trim()) { - throw new Error('challenge is required to build a peering AUTH event') - } - - const unidentified: UnidentifiedEvent = { - pubkey: getPublicKey(privateKey), - created_at: createdAt, - kind: EventKinds.AUTH, - tags: [ - [EventTags.AuthRelay, relayUrl], - [EventTags.Challenge, challenge], - ] as Tag[], - content: '', - } - - return signEvent(privateKey)(await identifyEvent(unidentified)) -} diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 8c5b401dc..cdf8c68b9 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -8,7 +8,7 @@ import { createLogger } from '../factories/logger-factory' import { EVENT_STORE_BACKENDS, isEventStoreBackend } from './event-store' import { GRANDFATHERED_PLAN_ID } from '../@types/user-subscription' import { Settings } from '../@types/settings' -import { parsePeeringPeerPubkey } from './peering-identity' +import { parsePeeringPeerPubkey } from './peering-auth' import { getConfigBaseDir, getDefaultSettingsFilePath, diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index 59b002f1e..1c5f53d45 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -993,6 +993,9 @@ describe('WebSocketAdapter', () => { it('exposes a peer pubkey only after it authenticates', () => { const pubkey = 'a'.repeat(64) + settingsFactory.returns({ + peering: { enabled: true, peers: [{ pubkey, relayUrl: 'wss://peer.example.com' }] }, + }) adapter.setPeerPubkey(pubkey) expect(adapter.getPeerPubkey()).to.equal(undefined) @@ -1004,8 +1007,11 @@ describe('WebSocketAdapter', () => { it('drops peer status when the AUTH session expires', () => { const clock = sandbox.useFakeTimers({ now: 1_700_000_000_000 }) - settingsFactory.returns({ nip42: { sessionExpirySeconds: 60 } }) const pubkey = 'a'.repeat(64) + settingsFactory.returns({ + nip42: { sessionExpirySeconds: 60 }, + peering: { enabled: true, peers: [{ pubkey, relayUrl: 'wss://peer.example.com' }] }, + }) adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64)) adapter.setPeerPubkey(pubkey) @@ -1015,6 +1021,25 @@ describe('WebSocketAdapter', () => { expect(adapter.getPeerPubkey()).to.equal(undefined) }) + it('revokes peer status when the peer is disabled or removed from settings', () => { + const pubkey = 'a'.repeat(64) + const peer = { pubkey, relayUrl: 'wss://peer.example.com' } + settingsFactory.returns({ peering: { enabled: true, peers: [peer] } }) + + adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64)) + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(pubkey) + + settingsFactory.returns({ peering: { enabled: true, peers: [{ ...peer, enabled: false }] } }) + expect(adapter.getPeerPubkey()).to.equal(undefined) + + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(undefined) + + settingsFactory.returns({ peering: { enabled: true, peers: [] } }) + expect(adapter.getPeerPubkey()).to.equal(undefined) + }) + it('expires authenticated pubkeys after sessionExpirySeconds', () => { const clock = sandbox.useFakeTimers({ now: 1_700_000_000_000 }) settingsFactory.returns({ nip42: { sessionExpirySeconds: 60 } }) diff --git a/test/unit/services/peering-auth-service.spec.ts b/test/unit/utils/peering-auth-client.spec.ts similarity index 97% rename from test/unit/services/peering-auth-service.spec.ts rename to test/unit/utils/peering-auth-client.spec.ts index b59f4e8b9..efa9cd14b 100644 --- a/test/unit/services/peering-auth-service.spec.ts +++ b/test/unit/utils/peering-auth-client.spec.ts @@ -7,10 +7,10 @@ import { AuthMessage } from '../../../src/@types/messages' import { WebSocketAdapterEvent } from '../../../src/constants/adapter' import { EventKinds, EventTags } from '../../../src/constants/base' import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler' -import { authenticateToPeeringRelay } from '../../../src/services/peering-auth-service' import { getPublicKey, isEventIdValid, isEventSignatureValid } from '../../../src/utils/event' +import { authenticateToPeeringRelay } from '../../../src/utils/peering-auth' -describe('peering-auth-service', () => { +describe('peering-auth client', () => { const privateKey = 'a'.repeat(64) const pubkey = getPublicKey(privateKey) diff --git a/test/unit/utils/peering-identity.spec.ts b/test/unit/utils/peering-auth.spec.ts similarity index 97% rename from test/unit/utils/peering-identity.spec.ts rename to test/unit/utils/peering-auth.spec.ts index cf75093ab..15ea40e44 100644 --- a/test/unit/utils/peering-identity.spec.ts +++ b/test/unit/utils/peering-auth.spec.ts @@ -7,10 +7,10 @@ import { findConfiguredPeeringPeer, isConfiguredPeeringPeer, parsePeeringPeerPubkey, -} from '../../../src/utils/peering-identity' +} from '../../../src/utils/peering-auth' import { toBech32 } from '../../../src/utils/transform' -describe('peering-identity', () => { +describe('peering-auth identity', () => { const privateKey = 'a'.repeat(64) const pubkey = getPublicKey(privateKey) const otherPubkey = getPublicKey('b'.repeat(64))