Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/peering-peer-auth.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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).
90 changes: 90 additions & 0 deletions docs/PEERING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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", "<challenge>"]` when a WebSocket connects.
2. The peer sends `["AUTH", <kind 22242 event>]` 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. 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).
9 changes: 9 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/@types/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export type IWebSocketAdapter = EventEmitter & {
getAuthenticatedPubkeys(): ReadonlySet<string>
/** 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
Expand Down
21 changes: 21 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions src/adapters/web-socket-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -75,6 +76,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
private alive: boolean
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
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

Expand Down Expand Up @@ -243,6 +246,30 @@ 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, 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 {
const authenticated = this.session.getAuthenticatedPubkeys().has(pubkey)
this.peerPubkey = authenticated && isConfiguredPeeringPeer(this.settings(), pubkey) ? pubkey : undefined
}

private async onClientMessage(raw: Buffer) {
this.alive = true
let abortable = false
Expand Down Expand Up @@ -405,6 +432,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) {
Expand Down
8 changes: 8 additions & 0 deletions src/handlers/auth-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-auth'

const logger = createLogger('auth-message-handler')

Expand Down Expand Up @@ -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, '')
}

Expand Down
Loading
Loading