draft optional author:riccardobl
NIP-DC defines a resilient way for peers to discover each other over Nostr, establish a connection, and exchange binary payloads.
It supports two transports:
- WebRTC, for direct peer-to-peer connectivity when possible.
- WebSocket-based TURN with signed headers, for relay-based transport when direct connectivity is unavailable or unstable.
Both transports use the same room, peer, and authorization model, so applications see one logical connection rather than two unrelated transports.
The logical connection is transport-resilient:
- implementations SHOULD prefer direct WebRTC when possible,
- MAY degrade to one or more TURN relays when direct connectivity fails,
- MAY later switch back to WebRTC when conditions improve,
- and, if no path is currently usable, MAY temporarily pause delivery and transparently resume when connectivity returns.
In short, NIP-DC is not only a connection setup protocol. It defines a resilient peer connection that can survive transport failure, transport switching, temporary disconnection, and later recovery.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in BCP 14, RFC 2119 and RFC 8174, when, and only when, they appear in all capitals.
A room is identified by a Nostr keypair shared by all parties allowed to participate.
roomPubkeyis carried in thePtag.- Possession of the room private key represents authorization to participate in that room.
When a peer joins a room, other peers in that room SHOULD attempt to connect to it.
A peer is represented by:
- a Nostr keypair, ideally random or ephemeral,
- an optional session id
d, allowing multiple simultaneous sessions for the same peer key.
In practice, a peer instance is identified by (pubkey, d).
A channel is a persistent logical communication path between two peers that has its own packet ordering, queuing, and retransmission state.
- For WebRTC, a channel is identified by the RTC data channel used for the payloads, including its label.
- For TURN, a channel is identified by the virtual socket
channelLabel.
Peers MUST automatically open a single RTC data channel labeled default. That channel MUST be ordered and reliable.
Applications MAY open additional custom channels on demand. Any custom channel SHOULD be opened on both sides before it is used.
NIP-DC does not define any further automatic channel creation, other than the default channel. Additional channel setup MAY be coordinated at the application layer using the default channel.
In this document, channel-local means scoped to one logical application channel.
roomproof proves that the sender had access to the room private key.
It is attached to events that require room authorization using:
["roomproof", "<id>", "<sig>"]
Where:
<id>is a 32-byte lowercase hex SHA-256 digest of the preimage below.<sig>is a 64-byte lowercase hex Schnorr signature of<id>using the room private key.
Sender and verifier compute:
id = sha256( JSON.stringify([
0,
<room pubkey hex>,
<created_at>,
<kind>,
<event pubkey hex>,
<challenge>,
""
]) )
Where:
<room pubkey hex>is the room public key in lowercase hex, 32 bytes<created_at>is the eventcreated_atin unix seconds<kind>is the event kind<event pubkey hex>is the eventpubkeyin lowercase hex, 32 bytes<challenge>is event-specific and defined by the message type
Serialization MUST follow NIP-01 canonical JSON rules:
- UTF-8
- no extra whitespace
- proper escaping
sig = schnorr_sign(id, roomPrivKey)
The verifier MUST recompute id from the received event and MUST verify sig against roomPubkey.
Every application payload sent through NIP-DC is wrapped in a small binary envelope used for fragmentation and deduplication.
All integers are big-endian.
PACKET_ID uint64
FRAGMENT_ID int16
FRAGMENT_COUNT int16
PAYLOAD uint8[*]
PACKET_IDis a sender-generated channel-local unique packet id. A simple incrementing counter per channel is sufficient.
Maximum payload size is 65523 bytes.
If a payload is larger, the sender MUST fragment it and the receiver MUST reassemble it using FRAGMENT_ID and FRAGMENT_COUNT.
Rules:
FRAGMENT_IDMUST start at0FRAGMENT_IDMUST increment by1- the last fragment id MUST be
FRAGMENT_COUNT - 1 - all fragments of the same logical payload MUST use the same
PACKET_ID
Receivers SHOULD keep a reasonable window of recently fully assembled PACKET_ID values per logical channel in order to suppress duplicates.
A receiver MUST NOT assume that PACKET_ID values are unique across different logical channels.
This matters because the same logical packet may be retried after reconnection, resent through a different TURN relay, or delivered again after switching back from TURN to WebRTC.
Receivers MAY discard incomplete payload state after a reasonable timeout from the first fragment received.
NIP-DC allows retransmission of the same logical packet when delivery is uncertain or known to have failed.
Examples include:
- a direct WebRTC send operation fails,
- a TURN
delivery_ackis not received before a sender-defined timeout, - a transport becomes unavailable while delivery is in progress,
- a peer switches from WebRTC to TURN, from one TURN relay to another, or back to WebRTC.
Retransmission policy is implementation-specific.
- A sender MAY retry a packet zero or more times.
- A sender MAY retry over the same transport or over a different currently available transport.
- A sender MAY stop retrying after any implementation-defined limit, timeout, or failure policy.
To remain the same logical packet, a retransmission SHOULD preserve:
- the same
PACKET_ID, - the same
FRAGMENT_COUNT, - and, for each fragment, the same
FRAGMENT_ID.
A retransmission using a different PACKET_ID is a different logical packet and is therefore not deduplicated as a retry of the earlier one.
Receivers are expected to tolerate different retransmission strategies automatically through the deduplication rules in Section 3.2.
- If multiple copies of the same logical packet arrive within the receiver deduplication window for that channel, the receiver SHOULD suppress duplicates using
PACKET_IDafter full reassembly. - Retries MAY arrive over different transports or after a transport switch.
- Different implementations MAY use different retry timing, retry counts, or transport-selection logic, and receivers SHOULD NOT depend on any specific retransmission strategy beyond the packet identity rules above.
If a retransmission arrives after the receiver’s deduplication window for that channel has expired, the receiver MAY treat it as a new packet, because the earlier packet identity is no longer guaranteed to be remembered.
Signaling is performed through Nostr events of kind 25050.
Different signaling message types are identified by the t tag.
For:
offeranswerroute
the content MUST be encrypted using NIP-44 with a conversation key derived from:
- sender private key
- receiver public key
For broadcast events:
connectdisconnect
the content is plaintext.
Peers periodically broadcast presence so other peers can discover them.
{
"kind": 25050,
"content": "<optional message>",
"tags": [
["t", "connect"],
["P", "<room hex pubkey>"],
["d", "<session id>"],
["i", "<protocol identifier>"],
["version", "dc3"],
["y", "<application id>"],
["expiration", "<unix timestamp seconds>"]
]
}version MUST be dc3.
A peer SHOULD refresh presence before expiration.
After expiration, other peers MAY consider that peer offline and MAY close connections.
When a peer leaves the room, it broadcasts:
{
"kind": 25050,
"content": "<optional message>",
"tags": [
["t", "disconnect"],
["P", "<room hex pubkey>"],
["d", "<session id>"],
["i", "<protocol identifier>"],
["y", "<app id>"]
]
}When a peer discovers another peer, it MAY attempt to connect by sending an offer.
The offer contains the WebRTC SDP offer and related connection metadata in encrypted form.
{
"kind": 25050,
"content": "<nip44 encrypted offer>",
"tags": [
["t", "offer"],
["P", "<room hex pubkey>"],
["d", "<session id>"],
["i", "<protocol identifier>"],
["y", "<app id>"],
["p", "<receiver pubkey>"],
["roomproof", "<id>", "<sig>"]
]
}roomproof for this event MUST use this challenge:
JSON.stringify([<receiver pubkey>, <nip44 encrypted offer>])
An answer responds to an offer with the WebRTC SDP answer.
{
"kind": 25050,
"content": "<nip44 encrypted answer>",
"tags": [
["t", "answer"],
["P", "<room hex pubkey>"],
["d", "<session id>"],
["i", "<protocol identifier>"],
["y", "<app id>"],
["p", "<receiver pubkey>"],
["roomproof", "<id>", "<sig>"]
]
}roomproof for this event MUST use this challenge:
JSON.stringify([<receiver pubkey>, <nip44 encrypted answer>])
Peers MAY exchange ICE candidates before and after the offer/answer exchange.
This supports trickle ICE and MAY also carry a TURN URL.
{
"kind": 25050,
"content": nip44encrypted(JSON.stringify(
{
"candidates": [
{ "candidate": "<ice candidate>", "sdpMid": "<sdpMid>" }
],
"turn": "<optional NIP-DC TURN server URL>"
}
)),
"tags": [
["t", "route"],
["P", "<room hex pubkey>"],
["d", "<session id>"],
["i", "<protocol identifier>"],
["y", "<app id>"],
["p", "<receiver pubkey>"],
["roomproof", "<id>", "<sig>"]
]
}roomproof for this event MUST use this challenge:
JSON.stringify([<receiver pubkey>, <nip44 encrypted route>])
The signaling above allows peers to discover each other and exchange everything needed to establish a connection.
Implementations SHOULD behave as follows:
- discover peers through
connectpresence - exchange
offer,answer, androute - prefer WebRTC if it succeeds
- if WebRTC fails or becomes unstable, continue through one or more TURN relays if available
- if direct connectivity becomes available again, switch back to WebRTC
- if no transport is currently usable, delivery MAY pause and MAY later resume transparently when connectivity is restored
In all cases, the application continues to use the same logical channel and the same Payload Envelope.
The same logical channel is identified across transports by its channel label: the RTC data channel label when using WebRTC, and the virtual socket channelLabel when using TURN.
Once a WebRTC Data Channel is established, peers exchange binary payloads directly over that channel.
Those bytes MUST carry the same Payload Envelope described in Section 3.
The RTC data channel label identifies the logical channel used by those payloads. When the same logical channel is carried over TURN, the TURN channelLabel MUST match the RTC data channel label for that channel.
When WebRTC is used as the transport, transport-level encryption is handled automatically by the WebRTC stack. NIP-DC therefore does not define any additional transport encryption layer for payloads sent over WebRTC.
Packet ordering over WebRTC depends on the properties of the underlying RTC data channel.
- If the selected RTC data channel is ordered, packets MUST be delivered in channel order.
- If the selected RTC data channel is unordered, packets MAY arrive out of order.
- If the selected RTC data channel is unreliable or partially reliable, packet loss MAY occur and send failure or missing delivery MAY trigger retransmission according to Section 3.4.
If a WebRTC send fails, the sender MAY retransmit the same logical packet according to Section 3.4, including over a different transport if available.
When direct WebRTC connectivity fails, becomes unstable, or is temporarily unavailable, peers MAY relay binary payloads through one or more TURN servers.
TURN is not only a one-way fallback. A connection MAY:
- move from WebRTC to TURN,
- move across different TURN relays if needed,
- later return to WebRTC when conditions improve,
- or pause and transparently resume when no route is temporarily usable.
The TURN URL MAY be learned from route events.
If the turn field is missing or empty, no TURN attempt is possible for that route and the connection attempt MUST fail unless another usable route is available.
TURN is end-to-end encrypted:
- the relay sees routing metadata in signed headers
- the relay sees opaque encrypted payloads
- the relay does not understand the application protocol
TURN support is OPTIONAL. Libraries MAY implement only the WebRTC portion, but full resilience requires transport switching and recovery support.
TURN runs over a secure WebSocket between client and server.
One WebSocket MAY carry multiple virtual sockets.
A virtual socket represents one logical channel. Its channelLabel names that channel and, when the same logical channel also exists over WebRTC, the TURN channelLabel MUST match the RTC data channel label used for that channel.
A virtual socket is uniquely identified by:
(roomPubkey, channelLabel, clientSessionId, targetSessionId, protocolId, applicationId, clientPubkey, targetPubkey)
After receiving the initial challenge, the client MAY create additional virtual sockets by sending additional connect messages with different parameters, without solving a new challenge for each socket.
Every TURN message is framed in this binary envelope.
All integers are big-endian.
VERSION uint8
VSOCKET_ID int64
MESSAGE_ID int32
HEADER_SIZE uint16
HEADER_BYTES uint8[HEADER_SIZE]
NUM_PAYLOADS uint16
for (i=0; i<NUM_PAYLOADS; i++):
PAYLOAD_SIZE_i uint32
PAYLOAD_i uint8[PAYLOAD_SIZE_i]
This envelope is for TURN routing and is distinct from the end-to-end Payload Envelope.
- the TURN envelope is visible to the relay
- the Payload Envelope is end-to-end and meaningful only to the peers
Field meaning:
VERSIONis always2VSOCKET_IDis a client-generated unique identifier for the virtual socket, scoped to the WebSocket connection0is reserved and used only for the initialchallengeMESSAGE_IDis a client-generated identifier used for deduplication and acknowledgementsHEADER_SIZEis the UTF-8 byte size of the header eventHEADER_BYTESis the UTF-8 JSON stringified header eventNUM_PAYLOADSis the number of encrypted payload blobs in the frame
Rules for MESSAGE_ID:
- for
challenge,connect,ack, anddisconnect,MESSAGE_IDMUST be0 - for
dataanddelivery_ack,MESSAGE_IDMUST be non-zero and unique per(sender socket, direction)
The TURN header MUST be a valid Nostr event of kind 25051.
{
"kind": 25051,
"content": <event specific payload>,
"tags": [
["t", "<challenge|connect|ack|disconnect|data|delivery_ack>"],
["r", "<optional redirect URL>"],
["P", "<roomPubkeyHex>"],
["d", "<source sessionId>"],
["i", "<protocolId>"],
["y", "<applicationId>"],
["p", "<remote target peer pubkey>", "<channel label>", "<remote target sessionId>"],
["enc", "nip44-v2", "<nip44 encrypted secret for symmetric encryption>"],
["nonce", "<nonce>", "<difficulty>"],
["roomproof", "<id>", "<sig>"],
["expiration", "<unix timestamp in seconds>"]
]
}Not every tag is required for every message type.
server -> client
Sent immediately after the WebSocket opens.
It communicates:
- the PoW difficulty
- a random challenge token
- optionally a redirect URL
VSOCKET_IDMUST be0MESSAGE_IDMUST be0
{
"kind": 25051,
"tags": [
["t", "challenge"]
# ["r", "<optional redirect URL>"]
],
"content": JSON.stringify({
"difficulty": 13,
"challenge": "<random token>"
})
}Meaning:
content.difficultyis the required PoW difficulty in leading zero bitscontent.challengeis the token the client must copy into the nextconnectr, if present, is an optional redirect URL for another TURN server
Behavior:
- if the client supports redirects and
ris present, it SHOULD switch to the provided URL - otherwise it MUST continue on the current connection
- challenge validity is scoped to the lifetime of the WebSocket that received it
- once that WebSocket closes, the challenge is no longer valid
- servers SHOULD enforce a timeout for idle unauthenticated WebSockets that never send a valid
connect
No payloads.
client -> server
Requests creation of a virtual socket.
It MUST include:
- PoW satisfying the announced difficulty
- valid
roomproof - the copied challenge token in
content - a client-generated
vsocketIdboth incontentand in the envelope - routing identifiers for source and destination sessions
VSOCKET_IDMUST be non-zeroMESSAGE_IDMUST be0
{
"kind": 25051,
"tags": [
["t", "connect"],
["P", "<room pub key>"],
["d", "<source sessionId>"],
["i", "<protocolId>"],
["y", "<applicationId>"],
["p", "<remote target peer pubkey>", "<channel label>", "<remote target sessionId>"],
["nonce", "<nonce>", "<difficulty>"],
["roomproof", "<roomproof.id>", "<roomproof.sig>"]
],
"content": JSON.stringify({
"challenge": "<challenge token>",
"vsocketId": "<int64 != 0>"
})
}Rules:
content.vsocketIdMUST exactly match envelopeVSOCKET_IDroomproofchallenge for this event MUST be the rawchallengestring copied intocontent
The server MUST reject connect if:
VSOCKET_ID == 0content.vsocketIddoes not match the envelopeVSOCKET_IDcollides with an already active socket on that WebSocketdis missing or blankpdoes not include a non-empty destination session id as its third value
No payloads.
server -> client
Confirms that the virtual socket was accepted.
This is only for virtual-socket establishment, not data delivery.
VSOCKET_IDMUST be the accepted non-zero value fromconnectMESSAGE_IDMUST be0
{
"kind": 25051,
"tags": [
["t", "ack"]
],
"content": ""
}No payloads.
server <-> client
Terminates a virtual socket.
VSOCKET_IDMUST be non-zeroMESSAGE_IDMUST be0
The server MUST ignore disconnect messages carrying:
VSOCKET_ID == 0- unknown
VSOCKET_ID - stale
VSOCKET_ID
{
"kind": 25051,
"tags": [
["t", "disconnect"]
],
"content": JSON.stringify({
"reason": "<human readable reason>",
"error": <true|false>
})
}reasonis a human-readable explanation such as"peer unreachable","protocol error", or"normal closure"errorindicates whether the disconnection is due to an error or normal shutdown
If the server closes because offline queue limits or timeouts were hit before the other side connected, it MUST use:
{"reason":"peer unreachable","error":true}No payloads.
client1 <-> server <-> client2
Carries encrypted payload bytes.
The relay routes by reciprocal socket matching and MUST NOT interpret the payload.
VSOCKET_IDMUST be non-zero- the server MUST ignore
datamessages carryingVSOCKET_ID == 0, unknownVSOCKET_ID, or staleVSOCKET_ID MESSAGE_IDMUST be non-zero and unique per(sender socket, direction)at least within the retransmission or timeout window
The payload is encrypted using a NIP-44-like scheme:
- generate a random 32-byte symmetric secret
- use it as the conversation key to encrypt the raw binary payload
- do not base64-encode the output
That secret is then hex-encoded, encrypted with regular NIP-44 using the sender/receiver conversation key, and included in the enc tag.
Example:
let secret = bytesToHex(randomBytes(32));
const conversationKey = deriveConversationKey(senderPrivKey, receiverPubKey);
const enc = ["enc", "nip44-v2", nip44Encrypt(secret, conversationKey)];{
"kind": 25051,
"tags": [
["t", "data"],
["enc", "nip44-v2", "<nip44 encrypted secret for symmetric encryption>"]
],
"content": ""
}One or more encrypted binary blobs.
-
the server MUST process
dataonly for sockets previously accepted withack -
a receiver socket is reciprocal only if room, protocol, application, and channel match, and the identities are inverted:
(clientPubkey, sourceSessionId)on one side matches(targetPubkey, targetSessionId)on the other side- and vice versa
-
when forwarding to the reciprocal socket, the server MUST rewrite only envelope
VSOCKET_IDto the receiver socket id -
the server MUST preserve
MESSAGE_ID -
TURN delivery MUST preserve packet order within each virtual socket, and therefore within each logical channel carried by that virtual socket
-
packets forwarded over TURN on the same virtual socket MUST NOT be delivered out of order
-
if the target peer is offline, the server MAY queue payloads
-
queued payloads MUST be delivered only after the target peer connects and completes a matching accepted
connect -
if the queue grows too large or the target does not connect in time, the server MAY
disconnectwithreason="peer unreachable", error=true -
if delivery is not confirmed or the selected transport fails, the sender MAY retransmit the same logical packet according to Section 3.4
Because payloads are opaque, replay protection, if needed, MUST be handled by the application protocol inside the encrypted payload.
The sender MAY reuse the same header for multiple data messages as long as the required fields remain valid.
A relay MAY optimize for this by caching and byte-comparing the header before doing full parse and validation.
Header reuse is allowed only for data and delivery_ack. All other TURN message types MUST use a unique header per message.
client2 -> server -> client1
Acknowledges delivery of a specific data message identified by MESSAGE_ID.
VSOCKET_IDMUST be non-zeroMESSAGE_IDMUST be non-zeroMESSAGE_IDMUST match a previously receiveddatamessage on the reciprocal socket
{
"kind": 25051,
"tags": [
["t", "delivery_ack"]
],
"content": ""
}No payloads.
- the receiver MUST emit
delivery_ackonly after the correspondingdatapayload has been fully delivered to the receiver-side application or channel API - the sender MAY treat receipt of
delivery_ackas write completion for thatMESSAGE_ID - if
delivery_ackis not received before a sender-defined timeout, the sender SHOULD fail the pending write for thatMESSAGE_ID - the server MUST route
delivery_ackusing reciprocal socket matching - the server MUST rewrite only
VSOCKET_ID - the server MUST preserve
MESSAGE_ID
The sender MAY reuse the same header for multiple delivery_ack messages as long as the required fields remain valid.
Header reuse is allowed only for data and delivery_ack. All other TURN message types MUST use a unique header per message.
A straightforward implementation order is:
- implement room identity, peer identity, and
roomproof - implement presence via
connectanddisconnect - implement encrypted
offer,answer, androute - establish WebRTC and send the Payload Envelope over the data channel
- add fragmentation, deduplication, timeout, and retransmission handling
- optionally add TURN fallback
- add offline queueing and
delivery_ack - finally add transport migration and resume behavior so the logical connection can move between WebRTC and TURN transparently