Peer communication protocol

Reconciled against the code on 2026-09-09.

This document specifies how Tally clients exchange application messages with each other. It is written as a protocol-level spec so the same rules can be implemented on any transport, by any client, and against any compatible relay server.

The core ideas are:

1. Terminology

2. Identity and keys

Each user is identified by an x-only secp256k1 public key (BIP-340). The corresponding private key stays on the client and MUST NOT leave it. Encoded forms:

"User id" and "public key" are interchangeable in this spec; both refer to the npub encoding unless hex is explicitly stated.

Two users who want to communicate MUST already know each other's public key. How they obtain it (QR code, out-of-band exchange, friend request flow) is out of scope for this document.

3. Inner message

Every application-level message a client sends to another user is first built as a plaintext inner message object, then Schnorr-signed, then encrypted into an envelope (§4).

FieldTypeRequiredDescription
idstringyesGlobally unique message id. MUST be generated by the sender and MUST be stable for retries.
typestringyesApplication-specific message type (e.g. friend_request, transaction_created, name_changed).
from_user_idstringyesnpub of the sender.
to_user_idstringyesnpub of the intended recipient.
created_atstringyesISO-8601 timestamp of when the sender created the message.
payloadobjectyesType-specific fields. Opaque to this spec.
signaturestringyesHex-encoded Schnorr signature. See §3.3.

Additional fields MAY be present. Implementations MUST preserve unknown fields when storing or forwarding a message.

3.1 Message ids

id is the unit of deduplication and retry. It MUST:

A recipient who has already processed a given id from a given from_user_id MUST NOT process it again. See §7.

3.2 Transport-only messages

Some messages exist only to manage a live direct channel and have no meaning off that channel (for example ping, pong, and receipts for messages that were just delivered on the same channel). These messages:

Transport-only messages are not persisted in the outbox.

3.3 Authorship proofs

Every durable application inner message carries an authorship proof: a signed Nostr event (kind 177700, proof type tally-nostr-event-v1) whose content commits to a canonical digest of the message, independent of the transport envelope. Before TIP-006 this was a bare BIP-340 signature in a top-level signature field; that form survives as tally-canonical-schnorr-v1 and is still accepted on receipt so older records keep verifying. Both are Schnorr over secp256k1 and cover the same digest — moving to a Nostr event is what lets the key live in an external signer (see TIP-006).

Why prove authorship in addition to encrypting? The envelope's AEAD proves that whoever holds the ECDH shared secret for (sender, recipient) produced the ciphertext. That is sufficient for live delivery between two parties. However, when a ledger entry from a third user is forwarded during sync recovery — for example, Bob sends Alice a transaction_created message that Carol needs to recover — the AES-GCM on the sync envelope only proves Bob produced the batch, not that the enclosed entry originated with Alice. A proof on each entry establishes the original from_user_id as its author independent of the transport.

Digest construction:

  1. Take the inner message object with signature removed (or empty string).
  2. Produce a canonical JSON string: keys sorted lexicographically, no extra whitespace.
  3. Compute digest = SHA-256(canonical_json) (32 bytes, hex-encoded).

Signing:

signature_hex = schnorr_sign(digest_hex, sender_private_key_hex)

Uses BIP-340 Schnorr signatures on secp256k1.

Verification:

ok = schnorr_verify(signature_hex, digest_hex, sender_public_key_hex)

The public key is derived from from_user_id (decoded from npub to 32-byte hex if needed).

When signatures are produced and stored:

4. Envelope

An envelope is the object actually transmitted on the wire whenever an inner message has to cross any untrusted boundary (a direct channel carrying data to a second user, or a relay server). It looks like this:

{
  "type": "peer_envelope",
  "envelope_version": 2,
  "id": "<inner.id>",
  "from_user_id": "<inner.from_user_id>",
  "to_user_id": "<inner.to_user_id>",
  "ciphertext": "<base64 of iv || aead(signed_inner_json)>"
}
FieldTypeRequiredDescription
typestringyesConstant string "peer_envelope".
envelope_versionnumberyesInteger version of this envelope format. Current version is 3; 2 is accepted on receipt only.
idstringyesCopy of inner.id. Used for routing, dedup, and acknowledgements without decrypting.
from_user_idstringyesCopy of inner.from_user_id. Needed by the recipient to pick the right key for decryption.
to_user_idstringyesCopy of inner.to_user_id. Used by the relay server and by the recipient to verify addressing.
ciphertextstringyesBase64 of the AEAD ciphertext. Contents defined in §5.

The envelope's plaintext fields are claims. A recipient MUST verify those claims against the authenticated inner fields after decryption (see §5.3). A relay server MAY rely on these fields for routing but MUST treat them as untrusted.

Version history. Version 2 moved the signed inner message (including its proof) inside the ciphertext. Version 3 replaces the cipher suite with NIP-44 v2 (§5.3). Clients MUST send version 3 and MUST reject any version they do not recognise; they SHOULD still decrypt version 2 so envelopes queued on a relay before the upgrade can be drained. Version 1 is not accepted.

Implementations MAY add non-normative fields (e.g. queued_at hints from a relay). Unknown fields MUST be ignored by receivers.

5. Encryption

5.1 Shared secret (ECDH)

For each ordered pair of users (A, B), both sides derive the same shared secret from their own private key and the other user's public key:

shared = ECDH(A.private, B.public) = ECDH(B.private, A.public)

The shared secret is the x-coordinate of the resulting secp256k1 curve point. Because public keys are x-only (BIP-340), receivers MUST lift them to full curve points choosing the even-y point before the scalar multiplication.

The ECDH shared secret is symmetric — both sides derive the same value — so a single key covers both directions of the conversation.

5.2 Symmetric key derivation

A symmetric encryption key is derived from the shared secret:

key = SHA-256(shared_x_coordinate)   // 32 bytes

The derived key MUST be treated as sensitive and SHOULD be cached in memory per peer to avoid repeated scalar multiplication.

5.3 AEAD encryption

Envelope version 3 encrypts the inner message with NIP-44 v2 (ChaCha20 + HMAC-SHA256, with NIP-44's own conversation-key derivation and padding). This is the single scheme for every key provider, because an external signer (NIP-07) exposes nip44.encrypt/decrypt and never the raw key — so the hand-rolled suite in §§5.1–5.2 could not be used with one. Those sections describe the version 2 suite, kept because clients still decrypt v2 envelopes on receipt.

Version 2 (legacy, decrypt-only): the signed inner message is encrypted with AES-256-GCM (96-bit IVs).

Encryption:

  1. Build the authorship proof (§3.3) and attach it to the inner message.
  2. Serialize the signed inner message to UTF-8 JSON.
  3. Generate a fresh 12-byte IV from a cryptographic random source. IVs MUST NOT be reused with the same key.
  4. Compute aead_output = AES_256_GCM_Encrypt(key, iv, plaintext). The output includes the 16-byte authentication tag.
  5. Set ciphertext = base64(iv || aead_output).

Decryption:

  1. base64_decode the ciphertext and split the first 12 bytes as iv, the remainder as aead_output.
  2. Compute plaintext = AES_256_GCM_Decrypt(key, iv, aead_output). If decryption fails (auth tag mismatch), drop the envelope.
  3. Parse plaintext as JSON into an inner message object.
  4. Verify that inner.id, inner.from_user_id, and inner.to_user_id match the envelope's plaintext fields. If any mismatch, drop the envelope.
  5. Verify that inner.to_user_id is the receiving user's own public key. If not, drop the envelope.
  6. Verify the authorship proof on inner against inner.from_user_id (§3.3). If invalid, drop the envelope.

Steps 2–5 establish that the ECDH peer produced the ciphertext and that the outer envelope was not tampered with. Step 6 additionally proves that the original from_user_id authored the inner content — this is what makes forwarded ledger entries trustworthy during sync (§12).

5.4 Algorithm negotiation

envelope_version is the single negotiation point for the envelope format and cryptographic suite. A client MUST reject any envelope whose envelope_version it does not recognize. Future versions of this spec MAY introduce new values and new cipher suites.

6. Delivery paths

A message can travel from sender to recipient along any of these paths. A client MAY try several paths for the same message; the recipient's deduplication (§7) makes this safe.

6.1 Direct channel (preferred)

When the sender's client has an open direct channel to the recipient's client (e.g. a WebRTC data channel), it sends the envelope as a single JSON frame over that channel. The channel carries both encrypted envelopes and plaintext transport-only messages.

Direct channels are typically negotiated using a signaling relay (§6.3) but may come from any source that lets two clients exchange raw bytes.

6.2 Relay server outbox (fallback)

When the sender cannot deliver an envelope directly — because the recipient is offline, or because no direct channel is available — it hands the envelope to a relay server. A relay server MUST implement at minimum these operations:

The relay server behaves as follows:

  1. On receiving queue_peer_envelope:
    1. Validate that the envelope has id, from_user_id, to_user_id, and ciphertext.
    2. If the recipient is currently connected, immediately push peer_envelope to them. If the push succeeds, the server MAY drop the envelope without storing it.
    3. Otherwise, store the envelope in a per-recipient queue keyed by envelope.id (see §6.4 for limits).
  2. On receiving a register from a client that announces its own user id:
    1. Flush every envelope stored for that user id by pushing peer_envelope messages, one per envelope.
    2. After flushing the entire queue, push a queue_drained signal to the client.
    3. After a successful push, remove the envelope from the queue.

The relay server MUST NOT modify envelopes. Clients MUST assume the server might reorder, drop, or duplicate envelopes; dedup and retries live on the clients.

The relay server is an untrusted intermediary. It learns from, to, and id for every message routed through it, plus approximate timing. It does not learn the inner message contents.

6.3 Signaling for direct channels

When direct channels are WebRTC-based, the relay server also acts as a signaling relay: it announces peer presence, tells two clients to open a direct channel (peer_connect / peer_disconnect signals), and forwards WebRTC offer/answer/ICE payloads between them. Application messages — as opposed to signaling — always travel inside envelopes.

Signaling is device-scoped. Because a user MAY be online on several devices at the same time (same user_id, different WebSocket sessions), every peer-scoped signaling message carries both peer_user_id and peer_device_id:

MessageRequired fields
peer_connectpeer_user_id, peer_device_id, initiator (bool)
peer_disconnectpeer_user_id, peer_device_id
webrtc_signalpeer_user_id, peer_device_id, signal (offer / answer / ICE)

Clients MUST thread the peer_device_id they received on peer_connect through every subsequent webrtc_signal so the relay can route the ICE candidates to the exact device that sent the matching offer. The relay MAY fall back to user-level routing when the target user has a single active device, but clients MUST NOT rely on this.

Envelopes (peer_envelope) are not device-scoped: when a sender hands an envelope to the relay, the relay MUST deliver a copy to every online device of the recipient user. Each recipient device independently decrypts and dedups against its own processed_peer_message_ids (§7) so overlapping deliveries are safe.

6.4 Server queue bounds

Relay servers MUST bound their per-recipient queue to avoid unbounded growth. When the queue for a recipient is full:

Either policy is conformant as long as the limit is documented. Clients MUST be prepared for their envelope to be silently dropped and rely on retries (§8.2) rather than any server-side guarantee.

6.5 Multiple relay servers

A client MAY be connected to more than one relay server at the same time, and a given envelope MAY be handed to several of them. Recipients rely on message-id deduplication to make this safe. There is no coordination between relay servers.

7. Deduplication

Every client maintains a set of processed message ids. On every inbound inner message — regardless of which path delivered it — the client MUST:

  1. Validate the envelope (see §5.3).
  2. If the message id is already in the processed set, drop the message and SHOULD still send a receipt (§8.1) so a stuck sender can progress.
  3. Otherwise, apply the message to local state and add the id to the processed set.

The processed set MUST persist across client restarts. It MAY be pruned by age or size; a pruning policy is conformant as long as no message id is dropped from the set before any sender could reasonably stop retrying it.

Dedup applies equally to:

8. Acknowledgements and retries

8.1 Receipts

For every inner message a client accepts and processes, it SHOULD send a receipt back to the original sender. A receipt is itself an inner message with:

Receipts are inner messages and MUST follow the same encryption rules. They MAY be delivered via any path:

A receipt MUST NOT itself generate a receipt. On receiving an inner message with type = "received", a client processes it and stops — no acknowledgement is sent.

8.2 Outbox semantics

The sender keeps every unacknowledged inner message in its persistent outbox until a matching receipt arrives. While an id is in the outbox the sender MAY:

Once a receipt for id arrives, the sender removes the message from its outbox and stops retrying.

If a client has previously handed an envelope to a relay server and then observes that the relay server has restarted (for example, by detecting a new WebSocket session), it MUST be prepared to re-queue the envelope. In practice this means the client SHOULD clear any in-memory "already queued on server" tracking whenever it (re-)registers with a relay server.

8.3 Sender-side dedup hint

A client MAY keep a per-session cache of envelope ids it has already handed to the relay server in the current session, to avoid spamming the relay with duplicates of the same envelope while no new events have changed its state. This cache is an optimisation only and MUST be reset on every (re)registration with the relay.

9. Data flows

This section walks through the four combinations of sender and recipient online state.

Legend:

9.1 Both online, direct channel available

A                          Relay                           B
|   sign + wrap inner as [msg]                             |
|------------------------- [msg] -------------------------->|
|                                                          |   decrypt, verify sig, apply
|                                                          |   add id to processed set
|<------------------------ [receipt] ----------------------|
|   or over the same direct channel as plaintext           |
|   remove id from outbox                                  |

The relay server is not involved. This is the common fast path.

9.2 Both online, no direct channel (yet)

A                          Relay                           B
|--- queue_peer_envelope([msg]) --->|                      |
|                                   |-- peer_envelope([msg]) -->|
|                                                          |   decrypt, verify sig, apply
|                                                          |<-- queue_peer_envelope([receipt]) --|
|<-- peer_envelope([receipt]) ------|                      |

The relay forwards the envelope immediately instead of storing it. Same protocol on the receipt path.

9.3 Sender online, recipient offline

A                          Relay                           B (offline)
|--- queue_peer_envelope([msg]) --->|                      .
|                                   |   store under B      .
|                                   |                      .
                                    .                      B comes online, registers
                                    |<----- queue_drained ----|
                                    |-- peer_envelope([msg]) -->|
                                    |                       |   decrypt, verify sig, apply
                                    |<-- queue_peer_envelope([receipt]) --|
A                                   |                       |
|<-- peer_envelope([receipt]) ------|                       |
|   remove id from outbox                                   |

The relay stores the envelope in B's queue until B registers, then flushes it and sends queue_drained. B sends the receipt back the same way.

9.4 Both offline at different times

A                          Relay                           B
|--- queue_peer_envelope([msg]) --->|                      .
|   (A goes offline)                |   store under B      .
                                    |                      .
                                    .                      B registers
                                    |<----- queue_drained ----|
                                    |-- peer_envelope([msg]) -->|
                                    |                       |   decrypt, verify sig, apply
                                    |<-- queue_peer_envelope([receipt]) --|
                                    |   store under A      |
                                    |                      |   (B goes offline)
A reconnects                        |                      .
|-- register --------------------->|                       .
|<-- peer_envelope([receipt]) ------|                      .
|   remove id from outbox            |                      .

Neither user is ever directly connected to the other. The relay holds both the original message and its receipt until each side comes back.

9.5 Racing paths

If A has both a direct channel to B and a relay route, A MAY send the envelope on both. B will decrypt whichever arrives first, apply the message, and drop the second as a duplicate (§7). B's receipt may likewise arrive at A via both paths; A processes whichever arrives first and treats the second as a no-op.

10. Security properties and non-goals

Properties provided by this protocol:

Properties not provided:

11. Versioning

This document describes version 2 of the envelope format (added Schnorr signatures inside the ciphertext). Any incompatible change — new required fields, different crypto suite, different serialization — MUST increment envelope_version. Receivers MUST reject unknown versions rather than trying to guess.

Inner message type values are application-level and evolve independently; adding a new type is not a protocol change.

12. Sync protocol (ledger recovery)

The sync protocol allows a client whose local state has been wiped or partially lost to reconstruct its connection history from peers who hold signed ledger entries. It runs over an established direct channel after the relay's queued envelope delivery is complete.

12.1 Motivation

A client that resets its data (intentionally or due to corruption) loses knowledge of existing friendships and transactions. Because peers hold signed copies of every message they exchanged, they can replay that history to the recovering client. The Schnorr signature on each entry guarantees that the recovering client can trust the replayed content without trusting the forwarder.

12.2 Prerequisites

Before syncing, a client MUST wait for the queue_drained signal from the relay server. This ensures any envelopes the relay was holding are processed before the client requests a diff from peers — otherwise the client may ask for entries it is about to receive via the normal path.

12.3 Message types

Sync messages travel as regular encrypted inner messages (wrapped in envelopes, Schnorr-signed). Two new types are defined:

TypeDirectionPurpose
sync_helloinitiator → peerAdvertise the set of entry ids the initiator already knows.
sync_datapeer → initiatorSend the entries the initiator is missing.

Sync messages are not application messages in the domain sense — they carry no business event — and do not appear in processed_peer_message_ids. They also do not generate receipts.

12.4 Flow

A (recovering)                             B (has history)
|                                          |
|  (queue_drained received from relay)     |
|                                          |
|--- sync_hello { known_ids: [...] } ----->|
|                                          |   filter own ledger for A<->B entries
|                                          |   subtract known_ids
|<--- sync_data { entries: [...] } --------|
|                                          |
|  for each entry (chronological):         |
|    verify authorship proof               |
|    apply to local state                  |
|  persist updated state                   |
|  update peerIds snapshot                 |

12.5 sync_hello payload

{
  "known_ids": ["<entry id>", ...]
}

known_ids is the list of ledger entry ids the initiator already holds for the conversation with this peer (entries where from_user_id or to_user_id matches the peer).

12.6 sync_data payload

{
  "entries": [
    {
      "id": "...",
      "type": "friend_accept",
      "from_user_id": "npub1...",
      "to_user_id": "npub1...",
      "originated_at": "ISO-8601",
      "timestamp": "ISO-8601",
      "signature": "<hex>",
      "payload": { ... }
    }
  ]
}

The sender filters its ledger for entries involving the two parties, removes any ids listed in sync_hello.known_ids, and sends the remainder.

12.7 Entry classification on receipt

When a client receives sync_data, it classifies each entry:

Entries that fail authorship verification are silently dropped. A compromised or malicious peer cannot inject forged history.

12.8 Remote peer allowance (friend mesh only)

When a client receives a peer_connect signal from the relay for a peer it does not currently recognise (the peer's id is not in its accepted friendships), it:

  1. Creates a WebRTC peer connection and completes the sync protocol normally.
  2. Starts a 15-second remote allowance timer.
  3. If sync succeeds and the peer is added to accepted friendships before the timer expires, the timer is cancelled and the peer connection remains open.
  4. If the timer expires before the peer is recognised, the connection is closed.

This lets a resetting client recover friendships from arriving peers without opening a permanent connection to unknown parties.

The allowance timer applies only to the friend mesh. Self-peers (§13) are always allowed and are never reaped by this mechanism.

13. Multi-device (same-user) sync

A single user MAY be online on multiple devices at the same time. Those devices MUST converge to the same local state so that an action taken on one device is visible on every other device — even if the triggering peer message was received on only one of them.

This is achieved by running a second self-mesh overlay alongside the friend mesh. The two overlays share almost all machinery; this section describes the differences.

13.1 Connection establishment

The relay server signals peer_connect whenever it has two or more connected clients with the same user_id (one peer_connect per remote device). The client routes the signal to one of two meshes based on the claimed peer_user_id:

ConditionMeshKey used inside the mesh
peer_user_id !== my_user_idFriend meshpeer_user_id
peer_user_id === my_user_idSelf-meshpeer_device_id

Using peer_device_id as the self-mesh key ensures that each remote device gets its own peer entry even though they all share the same user id.

Re-pairing works like the friend mesh: when a self-peer that had connected drops unexpectedly, it sends connect_peer with its own user_id. The relay answers with a fresh peer_connect for every other device of that user (none if they are offline).

WebRTC negotiation, data channels, JSON framing, and envelope encoding are identical to the friend mesh. The polite/impolite role is still decided by lexicographic comparison of the local and remote key; on the self-mesh this compares device ids.

13.2 Encryption for self-envelopes

The envelope format (§4) and the ECDH key derivation (§5.1) are unchanged. Because the "peer" is another device of the same user, both ends hold the same private key, so:

shared = ECDH(my_priv, my_pub)
key    = SHA-256(shared)

Both devices derive identical keys from the local key pair alone and can decrypt each other's envelopes with no additional key exchange. from_user_id and to_user_id inside the envelope are both set to the user's own npub.

13.3 Signatures on self-messages

Self-mesh messages MUST still carry valid Schnorr signatures (§3.3). Reuse of the same signing code keeps the inner-message construction identical to friend-mesh messages and lets any ledger entry delivered via self-sync pass the same verification on the receiving device (§12.7 of the Ledger spec).

13.4 Remote allowance

Self-peers are trusted on arrival: there is no 15-second allowance timer and no requirement that they appear in any "accepted" set. A self-peer connection persists for as long as the relay reports the other device online.

13.5 Live ledger broadcast

Whenever the local client appends a new ledger entry (outbound or inbound), it MUST also broadcast that entry to every currently-connected self-peer so all of the user's devices converge on the same ledger. The broadcast piggybacks on the existing sync protocol:

13.6 Initial self-sync (on connection)

Immediately after a self-peer data channel opens, both devices exchange a sync_hello (§12.5) carrying the ids they already hold, and respond with sync_data carrying the missing entries. Unlike friend-mesh sync, the entry filter is not bilateral: a self-peer returns all ledger entries whose id is not in the initiator's known_ids.

On receipt, entries are classified using the same rule as friend sync (§12.7): inbound-like entries run through the application message handler; outbound-only entries are inserted into the ledger without re-application. Chronological replay is still required so dependent entries apply in order.

13.7 Receipts

Inner messages delivered via self-sync (both the initial catch-up and live broadcast) do not generate receipts. After convergence both devices share identical processed_peer_message_ids, so acknowledgement is redundant and a receipt from device A to device A' would itself need to be broadcast, causing an unbounded fan-out.

13.8 Relay fallback

There is no TURN server, so on some networks (carrier-grade NAT, routers without hairpinning or with mDNS blocked) two devices of the same user never get a WebRTC channel. Self-sync then runs through the relay instead:

The relay learns the timing and size of self-sync traffic, never its contents.

13.9 Security considerations