ird-p2p-cljs end-to-end protocol
This document specifies the bytes ird.p2p puts inside the p2p service's
transport — the payload of relay signal frames, the messages on WebRTC data
channels, and the blobs deposited in the offline mailbox. The service itself
(/ws frames, error codes, the /.well-known/ap2p descriptor, TURN and
mailbox HTTP) is specified by the node at
https://<your-app-hostname>/.well-known/ap2p/protocol.md; this document
does not repeat it.
The relay is honest-but-curious: it sees room ids, peer ids, timing and sizes,
and forwards opaque payloads. Everything below exists so that it can see
nothing else, and so that a peer who does not hold the room secret can neither
join a conversation nor forge a sender.
Any implementation that reproduces these rules byte for byte interoperates
with this SDK — and with the Swift SDK (ird-p2p-swift) it is a sibling of.
The golden vectors in test/ird/p2p/vectors/ (copied verbatim from the Swift
SDK, whose scripts/vectors/generate.mjs is a zero-dependency reference
implementation of this document on Node's WebCrypto) pin every derivation and
envelope.
Notation
UTF8(s): the UTF-8 bytes of s.
b64: standard base64 with padding (+, /, =). Used inside envelopes.
b64url: URL-safe base64 without padding (-, _). Used for identifiers and keys.
a ‖ b: concatenation.
HKDF(info): HKDF-SHA256 with ikm = the room secret bytes, salt = UTF8(appSalt),
info = UTF8(info), 32 output bytes.
- JSON on the wire is serialized like JavaScript's
JSON.stringify: no whitespace,
object keys in insertion order, integers without a fraction.
Room secret and app salt
A room secret is at least 16 random bytes (32 recommended), shared out of
band as b64url — typically in the fragment of an invite link, which URL
loaders never send to a server. It is the only thing that guards who can join.
The app salt is a string chosen once per application (e.g. myapp/v1). It
namespaces every derivation: the same secret under two salts yields two
unrelated rooms. Never reuse another application's salt.
Derivations
| Name |
Formula |
Use |
roomId |
b64url(HKDF("roomid")) |
the relay room (43 characters) |
K_sig(P) |
HKDF("signal|" + P) |
AES-256-GCM key for envelopes sent by peer P over the relay |
K_msg(P) |
HKDF("msg|" + P) |
AES-256-GCM key for envelopes sent by peer P over a data channel |
mailboxRoomId |
b64url(HKDF("mailbox|room")) |
the mailbox room; never equal to roomId, so the node cannot correlate them |
K_mbx |
HKDF("mailbox-blob") |
room-wide AES-256-GCM key for mailbox payloads |
P is the sender's relay peer id: 16 bytes as 32 lowercase hex characters,
chosen per connection. Keys are per sender, so a receiver derives K_sig(from)
from the relay-stamped from field, which a peer cannot spoof.
Sealed envelopes
Session sealer
Each sender keeps one sealer per key for the lifetime of its room session.
The nonce (IV) is
iv = epoch ‖ counter epoch: 4 random bytes chosen when the sealer is created
counter: 8 bytes, big-endian, starting at 0, +1 per seal
No API accepts a caller-supplied IV. A receiver tracks, per sender and key, a
sliding window of the last 64 counters of the current epoch (as DTLS does):
a counter already seen, or more than 64 behind the highest seen, is rejected,
and an epoch that was retired never comes back. Frames sealed concurrently may
therefore arrive out of order and still pass; replays do not.
JSON envelope
Used for the relay path and for JSON messages on a data channel:
{"v":1,"iv":"<b64 iv>","ct":"<b64 ciphertext‖tag>"}
exactly those three keys in that order. A receiver accepts an envelope only if
v is exactly 1 and iv, ct are strings. The plaintext is the JSON text of
an inner message (below).
- Relay path (payload of
{"t":"signal","to":…,"payload":…}):
key K_sig(from), AAD UTF8("v1|" + roomId + "|" + from + "|" + to + "|sig").
- Data channel, text message: key
K_msg(from),
AAD UTF8("v1|" + roomId + "|" + from + "|msg"). The same ciphertext may be
sent to every peer (the AAD names no recipient).
Binary envelope (data channel)
Raw application bytes up to 16 000 bytes travel as one binary data-channel
message:
0x01 ‖ iv(12) ‖ ciphertext‖tag
under K_msg(from) with the data-channel AAD above. Anything shorter than
29 bytes or not starting with 0x01 is dropped.
At-rest envelope (mailbox)
Mailbox payloads are sealed under the room-wide K_mbx with AAD
UTF8("v2|" + roomId + "|mbx") and a random 12-byte IV (many sessions
share the key, so counters would collide):
0x02 ‖ iv(12) ‖ ciphertext‖tag
The plaintext is the JSON text of an inner message of kind msg or bin.
Decrypt or drop
Any failure — wrong version, bad base64, authentication failure, unparseable
plaintext — means the envelope is dropped silently. Receivers never distinguish
"tampered" from "not for me", never log the sender, and never answer.
Membership
A peer is a member the moment one of its envelopes decrypts under
K_sig(from) with our own id as to: only the holder of the secret can produce
it. Until then a peer id seen in the relay roster is unverified; the SDK sends it
a hello and ignores it if nothing decryptable arrives within 10 seconds.
Inner messages
Plaintexts are JSON objects with a kind. Unknown kinds and unknown keys are
ignored, so the set can grow without a version bump.
| kind |
fields |
path |
hello |
id?: {pub, sig, name?, hue?, glyph?} |
relay |
offer |
sdp |
relay |
answer |
sdp |
relay |
ice |
candidate (string or null = end of candidates), sdpMid, sdpMLineIndex |
relay |
restart |
— (asks the offerer to renegotiate) |
relay |
msg |
payload: any JSON |
relay, data channel, mailbox |
bin |
d: b64 bytes |
relay, data channel, mailbox |
chunk |
id (b64url, 8 bytes), i, n, enc (json or bin), d (b64 slice) |
relay, data channel |
ping |
— |
data channel |
Chunking
A message whose serialized inner form exceeds the slice size is split into
n chunk messages sharing an id; slice i carries d. The receiver
concatenates slices 0…n-1; for enc = "json" the result is parsed as a msg
inner message, for enc = "bin" it is the raw payload. Slice sizes: 8 192 raw
bytes on a data channel (keeps each SCTP message far below 16 KiB), and
min(32768, (max_frame_bytes − 512) / 1.85) bytes on the relay so every frame
stays under the node's cap. Receivers bound reassembly (8 concurrent ids,
8 MiB in flight, 60 s), dropping anything beyond.
WebRTC negotiation
The peer with the lexicographically lower peer id (on the 32-hex string) is
the offerer; the other only answers. Both create a negotiated data channel
(id 0, label ird, ordered, reliable) before any description is exchanged.
ICE candidates trickle as ice messages; a receiver buffers remote candidates
until it has the remote description. When a channel is lost, the offerer
retries after 2, 5 and 8 seconds and then waits for the app to wake; messages
keep flowing through the relay meanwhile.
Identity (optional)
An identity is a 32-byte Ed25519 seed (b64url, 43 characters). In its
hello a peer may assert that its current session belongs to that identity:
sig = b64( Ed25519_sign(seed, UTF8("sueta-id|v2|" + appSalt + "|" + roomId + "|" + peerId)) )
The tag sueta-id|v2 is a fixed literal shared with earlier implementations;
application separation comes from appSalt. Verifiers accept the signature in
standard or URL-safe base64. Ed25519 signatures may be randomized (RFC 8032
permits hedged nonces); interoperability is verification, not byte equality.
A fingerprint is SHA-256(publicKey): the full lowercase hex digest for
machines, and four emoji for humans, indexed by the digest's first 24 bits in
6-bit groups into this table (some entries carry U+FE0F and must be copied
exactly):
🐢 🦊 🐼 🦁 🐸 🐙 🦉 🐝 🐬 🦄 🐞 🦋 🌵 🌲 🍁 🍄
🌻 🌙 ⭐ 🔥 🌈 ⚡ ❄️ 🌊 🍎 🍋 🍇 🍓 🥑 🌽 🥕 🍩
🍕 🥨 🧀 🍿 ☕ 🍵 🧊 🎈 🎲 🎯 🎸 🎺 🥁 🚀 🚲 ⛵
🚂 🗿 🗝️ 🔔 🧲 🧭 ⏳ 📚 🖍️ 📎 ✂️ 🔍 💎 🛡️ ⚙️ 🎁
Identity never affects membership: a peer without an identity, or with an
assertion that fails to verify, is still a member if its envelopes decrypt. It
simply has no announced identity.
What the node sees
Room ids (which carry no content), peer ids, timing, payload sizes, and — for
the mailbox — the mailbox room id and deposit order. It cannot read any
payload, learn who is talking to whom beyond peer ids, or forge a sender.