p2p-swift / PROTOCOL.md
1,777 words

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

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).

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.

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/p2p-swift.git