p2p-cljs / PROTOCOL.md
1,473 words

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

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

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