ird-p2p-swift end-to-end protocol
This document specifies the bytes IrdP2P 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. The golden vectors in Tests/IrdP2PCryptoTests/Vectors/ pin
every derivation and envelope; scripts/vectors/generate.mjs is a
zero-dependency reference implementation of this document on Node's WebCrypto
that regenerates them.
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.
The sender derives id from the payload, not from randomness:
id = b64url( SHA-256( UTF8(enc) ‖ UInt32BE(n) ‖ payload )[0..<8] )
where enc is the literal json or bin, n the slice count as a 4-byte
big-endian integer and payload the unsplit bytes (the serialized msg inner
message for json, the raw bytes for bin). Sending the same payload again
therefore reuses the id, and a retry resumes the receiver's assembly — filling
the slices it lacks — instead of opening a rival one; identical payloads share
an assembly by design. Receivers key assemblies by whatever id arrives and
never recompute it, so senders that pick random ids interoperate unchanged.
Receivers bound reassembly per peer: 8 concurrent ids, 8 MiB in flight
(configurable) and a configurable age, 60 s by default, counted from an
assembly's first slice. A slice that repeats an index with identical bytes is
ignored; one with different bytes, a different n or enc, or i ≥ n drops
the assembly, as does exceeding the byte bound. A new id beyond the 8 is
refused without touching the assemblies in flight. The SDK reports each drop
to the app (assemblyDropped); nothing is sent to the peer.
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.
A room may cap its mesh at N direct peers. Let d(a, b) be the byte-wise XOR
of the two 16-byte peer ids, compared lexicographically (the first differing
byte decides; distinct ids never tie), and near(a) the N verified members
with the smallest d(a, ·). A pair negotiates only when b ∈ near(a) and
a ∈ near(b). The rule is symmetric, so with agreeing rosters the offerer
never offers to a peer that would refuse. While rosters disagree (a join or
leave only one side has seen) an answerer may receive an offer from outside
its budget: it creates nothing and replies restart, and the offerer retries
on the ladder above. Established channels are not closed when a roster change
pushes their peer out of the budget; they are just not renegotiated once lost.
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.