peeza — protocol and cryptography

Lire en français

Document version 3 · 2026-10-05 · wire version peeza-e2e-v1

This document describes how peeza protects a file between the device that sends it and the device that receives it: the keys, the encryption format, how a key reaches the receiver, how two devices pair, and what peeza's relay can and cannot see. It is written for people who want to check the claim "end-to-end encrypted" rather than take it on trust.

What this document is, and is not.

Section 10 lists the limits and exceptions. Read it before quoting section 1.

1. The claims

  1. Content is encrypted on the sending device and decrypted on the receiving device, with AES-256-GCM under a key generated on the sending device.
  2. The relay does not receive that key in the normal flows: it travels in a link's URL fragment, or sealed to the receiving device's public key, or it is derived by two devices on a local network without the relay taking part. Section 10.1 is the one flow where a key is deliberately sent through the relay.
  3. The relay never stores file content. When a direct connection is not possible it forwards encrypted frames, in memory, between two connected peers.
  4. The relay cannot alter a transfer undetected. It cannot change the content, truncate it, change its declared name, size or shape, or report a delivery that did not happen. Each of those fails authentication at one end.
  5. A direct transfer and a relayed transfer are equally confidential. The same sealed frames travel on both paths.

What is not claimed: that metadata is hidden (section 9), that a transfer made through a link has forward secrecy (10.5), or that the browser client is protected against the server that delivers it (10.2).

2. Parties and paths

partywhat it is
Sender, receiverTwo devices running peeza: a native app (macOS, Windows, Linux, iOS/iPadOS, Android) or the browser client. Every native app embeds the same engine.
RelayA server operated by peeza. It introduces devices to each other (signalling), authenticates accounts, and forwards encrypted frames when no direct connection can be made.

A transfer travels on one of three paths. The content encryption of section 4 is identical on all three and does not depend on the transport beneath it.

pathtransportrelay's part
DirectWebRTC data channel between the two devicessignalling only
RelayedWebSocket to the relay, which forwards framessignalling and forwarding
NearbyWebSocket between two devices on the same local networknone; works with no internet

3. Keys and secrets

namewhatsizelifetimewhere it lives
Transfer keyAES-256-GCM key for one shared payload256 bits, from the OS random generatorone share; sharing again mints a new keysender's device; receiver's device once delivered
Device identity keyX25519 key pair naming one install255-bit curvethe life of the installprivate half on the device only, in a file readable by the user alone; public half registered with the relay when signed in, and announced on the local network when Nearby is on
Nearby ephemeral keyX25519 key pair255-bit curveone nearby connectionmemory
Nearby session keyAES-256-GCM key derived by the handshake of section 6256 bitsone nearby connectionmemory

These are not keys and decrypt nothing:

namewhat it does
Link token8 characters (40 random bits, Crockford base32). Names a waiting transfer on the relay. One use, 48 hours.
Account session token192 random bits, issued by the relay at sign-in. The relay stores only its SHA-256.
Device registration secret256 random bits, generated on the device. Proves to the relay that a device id is the same install as before. The relay stores only its SHA-256.

There is no key escrow, no recovery key, and no key the operator holds that opens a transfer.

4. Transfer encryption

4.1 Primitive

AES-256-GCM (FIPS 197, NIST SP 800-38D): 256-bit key, 96-bit nonce, 128-bit tag. Native apps use the Go standard library (crypto/aes, crypto/cipher); the browser client uses WebCrypto (AES-GCM). Both produce identical bytes.

A transfer key is written as 43 characters of unpadded base64url.

4.2 Nonces

Nonces are deterministic. A nonce is 12 bytes:

nonce(domain, index) = [ domain : 1 byte ][ 0x00 0x00 0x00 ][ index : uint64 big-endian ]

The domain byte separates every use of one key, so no two different messages are ever sealed under the same (key, nonce):

domainsealed messageindexadditional authenticated data
0x00a chunk of contentchunk numberVERSION ‖ uint64BE(total)
0x01the file name (kept for older receivers; superseded by 0x08)0VERSION
0x02, 0x03"send only what changed" negotiation, one domain per directionmessage counterVERSION
0x04, 0x05folder-layout negotiation, one domain per directionmessage counterVERSION
0x06, 0x07"is the copy I already have the same file?" check, one domain per directionmessage counterVERSION
0x08the payload manifest0VERSION
0x09the completion receipt0VERSION ‖ uint64BE(total) ‖ room id

VERSION is the 12 ASCII bytes peeza-e2e-v1. It is in the additional data of every sealed message, so two clients that disagree about the format fail authentication instead of mis-decrypting. total is the payload's total plaintext length in bytes.

4.3 Content

The payload is cut into chunks of 64,512 bytes (63 × 1024); the last chunk is shorter. Chunk i covers bytes i × 64512 onward. One frame on the wire:

frame = uint64BE(i) ‖ AES-256-GCM( chunk_i, nonce(0x00, i), VERSION ‖ uint64BE(total) )

that is, 8 bytes of index, the ciphertext, and the 16-byte tag.

Binding total into every chunk means that if anything between the two devices changes the declared size — to pass off a shortened file as complete — every chunk fails to authenticate. A receiver also requires each chunk to be exactly the length its index implies, before writing it.

Because nonces are deterministic, sealing the same chunk of the same payload under the same key always yields the same frame. Two consequences:

4.4 The payload manifest

Everything a receiver must believe about a payload is sealed as one message:

manifest  = JSON { "n": name, "s": size, "t": modified-time-ms?, "p": packed?, "d": directory?, "f": framing-version? }sealed    = base64url( AES-256-GCM( manifest, nonce(0x08, 0), VERSION ) )

p and d say whether the payload is a container to unpack (a folder or several files travel as one uncompressed ZIP stream) and f which version of that container format built it. Fields marked ? are omitted when empty.

A receiver takes the name, the size and the shape only from the manifest. The signalling message that carries it also repeats some of those values in clear, for the relay's accounting; a receiver does not act on them. A message with no manifest is refused. There is no fallback to unauthenticated values.

The nonce is fixed, so a key seals exactly one manifest. The engine enforces that: the manifest is sealed once, when the payload is frozen, stored, and re-sent verbatim; sharing the same file again mints a new key. A device that re-serves a payload (section 7) forwards the original sealed manifest and never seals its own.

4.5 The completion receipt

A receiver that has every byte, authenticated, tells the sender so with a sealed receipt:

receipt = base64url( AES-256-GCM( "peeza-received-v1", nonce(0x09, 0), VERSION ‖ uint64BE(total) ‖ room id ) )

The sender marks a transfer complete only on a receipt that opens. The relay forwards the "done" message but cannot produce one, so it cannot report a delivery that did not happen. The room id binds the receipt to one delivery when several recipients share a key (section 7). On the nearby path the room id is empty; the key there is already unique to the connection.

Signalling messages are also checked for direction: a message meant for the sender is ignored by a receiver and the reverse. A "done" sent to a receiver does nothing.

4.6 The smaller sealed conversations

Three optional exchanges ride beside the content, each sealed with the transfer key in its own pair of domains (table in 4.2), one domain per direction with a message counter as index:

message = uint64BE(counter) ‖ AES-256-GCM( JSON, nonce(domain, counter), VERSION )

They let a receiver that already has an older version, the same folder, or the same file avoid receiving it again. They carry digests and layouts, never keys. The relay can see that one took place, not what was said.

5. How the key reaches the receiver

5.1 A link

https://g.peeza.app/K7Q9F2MX#k=<transfer key>

The path is the link token. The fragment (after #) holds the key. A browser never sends a fragment in an HTTP request, and a native app parses the link itself and presents only the token, so the relay learns the token and not the key.

The link is delivered by whatever channel the sender chooses: a message, an email, a QR code. The link is the secret. Anyone who holds it can receive the file, once. It works for one receiver and expires after 48 hours unused.

5.2 Addressed to a device

When you send to one of your own devices, or to a team member's device, there is no link. The transfer key is sealed to the receiving device's identity public key:

envelope = crypto_box_seal( transfer key, recipient identity public key )

This is the NaCl / libsodium anonymous sealed box: an ephemeral X25519 key pair per envelope, XSalsa20-Poly1305, 48 bytes of overhead. The relay routes the envelope and holds it for up to 7 days if the device is offline. Only the device holding the private key can open it.

The sending device learns the recipient's public key from the relay. It pins that key the first time it uses it, and refuses to seal to a different key for the same device afterwards. See 10.3 for what this does and does not cover.

5.3 Nearby

No transfer key is transported at all. The two devices derive a session key between themselves (section 6).

6. Nearby pairing

Two devices on the same local network, no relay, no account, no internet.

Discovery. Each device announces itself by UDP multicast (239.255.66.66:5354): device id, name, operating system, app version, identity public key, listening port. This is unauthenticated plaintext, visible to the local network, and nothing is trusted because of it. Nearby is off until the user turns it on.

Handshake. The devices connect over a plain WebSocket and each sends a hello with its identity public key I and a fresh ephemeral public key E. Both compute:

transcript  = SHA-256( "peeza-lan-v1" ‖ sort(I_a, I_b) ‖ sort(E_a, E_b) )session key = SHA-256( "peeza-lan-key" ‖ X25519(e_self, E_peer) ‖ transcript )proof_self  = HMAC-SHA-256( key = X25519(i_self, E_peer), message = transcript )code        = decimal( first 4 bytes of SHA-256( "peeza-lan-sas" ‖ transcript ) mod 1,000,000 ), 6 digits

sort puts the two 32-byte keys in byte order, so both sides hash the same input. Lower-case letters are private keys.

First contact. Both screens show the six-digit code and each person confirms it matches. Confirming pins the peer's identity key; the next connection to a pinned device asks nothing. A device that is not signed in keeps no pin: every pairing shows a fresh code.

Content. The session key feeds section 4 unchanged: manifest, chunks and receipt, sealed under the session key instead of a transfer key. The transport is unencrypted; all protection on this path is section 4's.

A six-digit code gives an active attacker one chance in a million per attempt, and each attempt requires the two people to be comparing codes at that moment.

7. Teams, and payloads held by several devices

A team is a group of accounts that share files. A file shared with a team can be fetched later from any member device that holds it, not only from the device that first shared it.

7.1 One key per payload. A shared payload has one transfer key. Every delivery of it to a member device carries that key in its own sealed envelope (5.2), sealed to that device, with the same pinning rule. The relay holds the team's membership and a catalogue of what was shared — names, sizes, who shared it — and never a key.

7.2 Identical frames. Devices that hold the payload and its key refer to it by a public identifier:

payload id = base64url( SHA-256( "peeza-payload-id-v1" ‖ transfer key ) )

The identifier reveals nothing about the key. A holder serves the same frames the original sender would have (4.3). When a device fetches from several holders at once over direct connections, each frame is prefixed with an 8-byte tag (the first 8 bytes of SHA-256 of the payload id) so the receiver can tell payloads apart; that prefix is routing, not security. A frame is accepted because it authenticates, whoever sent it.

7.3 A holder must prove what it re-seals. A holder's copy is an ordinary file in the user's folder, and it may have been edited. Sealing changed content under the original key and chunk number would reuse a nonce. So a receiving device records the authentication tag of every chunk as it arrives, and before serving a chunk it re-seals its copy and compares the tag. A chunk whose tag does not match is not served. A device with no such record does not serve at all. This guards against an edited file; it is not a defence against a holder that is itself malicious, which holds the key and could misbehave in any case.

7.4 What membership means. Every member device that received a payload holds its key. Removing a member stops future deliveries to them. It cannot recall a key or a file they already have.

8. The transport beneath

These protect the connection. They are in addition to section 4, and none of the claims in section 1 depends on them.

legprotection
Device ↔ relayTLS 1.2 or later (HTTPS and WebSocket), library defaults, certificates validated against system roots. No parameter is weakened.
Device ↔ device, directWebRTC data channel: ICE, DTLS, SCTP, with a fresh self-signed certificate per connection whose fingerprint is exchanged through the relay's signalling.
Device ↔ device, nearbynone (section 6)

On a relay that tampers with signalling. In WebRTC, whoever carries the signalling can substitute certificate fingerprints and place itself in the middle of the DTLS connection. peeza does not rely on DTLS for confidentiality. A relay that did this would hold the DTLS session and still see only the sealed frames of section 4, under a key it was never given: the key came in a link fragment, in an envelope sealed to a pinned device key, or from a handshake the relay took no part in. It could drop or delay frames. It could not read, alter, shorten or replace them.

To find a direct path, a device asks STUN servers what its public address is: the relay's own, and public servers run by Google and Cloudflare. A STUN server sees an IP address and nothing about the transfer. peeza uses no TURN server; its own relay is the fallback.

9. What the relay sees

Encryption hides content. It does not hide that a transfer happened. The relay sees or holds:

So: in a transfer made by link between two devices that are not signed in, the relay sees a sealed name and sealed content. Once an account is involved, file names are visible to the relay as part of that account's dashboard and team catalogue. Content never is.

What the relay can do: refuse service, drop or delay a transfer, and observe the above. What it cannot do is in section 1.

10. Limits and exceptions

10.1 Taking a link from another of your devices sends that key through the relay. A signed-in user can ask, from the web dashboard or from another of their devices, for the link of a transfer that one of their devices is offering — to copy it, share it, or show it as a QR code. The device then sends the complete link, key included, to the relay, which hands it to the dashboard and stops serving it after 30 seconds. For that one transfer, at that moment, the relay holds the key. It happens only on that explicit request by the account's owner. It means a compromised relay, or a stolen account session, could request it too.

10.2 The browser client trusts the server that delivers it. A browser receives its code from peeza's servers each time. The key in the fragment is not sent to the server, but the page's script reads it in order to decrypt. A compromised server could deliver a script that sends the key elsewhere. This is true of every browser-based encryption scheme. The native apps do not have this property; they have the one in 10.7.

10.3 The first key for a device comes from the relay. Pinning (5.2) protects every send after the first. On the first send to a device, the sender trusts the public key the relay supplies. A relay that substituted its own key on first contact could open that envelope. Pairing two devices in person (section 6) pins the key without the relay.

10.4 A link is a bearer secret. Whoever obtains the link can receive the file, once. A copied link sits in the clipboard, and anything that can read the clipboard, or the channel the link is sent through, can read the key.

10.5 No forward secrecy for link and addressed transfers. A transfer key is static for the life of a share. Someone who records the sealed frames of a relayed transfer and later obtains the key or the link can decrypt them. Only the nearby path derives a fresh key per connection.

10.6 Keys are files on the device. Identity keys, pinned keys and the keys of transfers still listed in the app are stored in the app's data folder, readable by the user's account and protected by the operating system, not by a hardware keystore. Anything running as the user can read them.

10.7 The engine is closed source. This document, the vectors and the browser client let you check the design and one implementation of it. They do not let you confirm that a given native build implements only this. Native apps are distributed signed: Apple notarization and a signed update feed on macOS, a signed package on Windows, the App Store and Google Play on phones, and published SHA-256 checksums on Linux.

10.8 Metadata. See section 9. IP addresses, sizes and timing are visible to the relay and, for a direct transfer, to the other device.

10.9 Connecting a new device can still be phished from your own network. Connecting a device to an account opens a page in a browser, and nothing in that page ties it to the app that asked for it. Someone who persuades you to finish a connection they started could attach their device to your account. This concerns accounts and the remote dashboard, not the encryption of a transfer, but it combines with 10.1: a device attached to your account can ask your other devices for a transfer's link.

What stands in the way. When a device connects, the relay compares the network the browser finished from with the networks the device started the connection from and collected it from. They match when they share an IPv4 address, or an IPv6 /64; one side on IPv4 and the other on IPv6 counts as different. When they differ, the device receives nothing until the connection is approved from a link emailed to the account's address — which someone who sent you a link cannot read. The email shows both places (country and network operator, never an address), says that a VPN or iCloud Private Relay can make them differ, and says not to approve if someone sent you a link or asked you to connect. The browser that finished connecting shows the same warning instead of its usual page when the connection was started from another network. If the email cannot be sent, or the account has no address, the connection is refused; unanswered, it expires after 15 minutes. Every connection also emails the account a notice naming the device, with a link that disconnects it, once, for 30 days: it revokes that device's session and removes it from the account. Removing a device from the dashboard or from another device does the same.

What remains:

10.10 Versions. A change to the sealed format changes VERSION and is not backwards compatible by design. On 2026-09-28 the manifest and the receipt became mandatory; apps older than that cannot exchange files with current ones, and fail with a message rather than falling back.

10.11 A sender rejoining a room is not yet required to prove it opened it. Apps and pages older than the sender proof (section 9) rejoin a room by naming it, and the relay still accepts that. So anyone who knows a room's id — the receiver included — can take the sender's place in that room. They gain no key and cannot produce a frame the receiver will accept, since every frame is sealed under the transfer key; they can disrupt that one transfer. The relay counts rejoins without a valid proof and will refuse them once the apps in use present it.

11. Algorithms and libraries

purposealgorithmstandardimplementation
Content, manifest, receiptAES-256-GCMFIPS 197, SP 800-38DGo standard library; WebCrypto in the browser
Device identity, key agreementX25519RFC 7748golang.org/x/crypto/curve25519
Envelope to a devicecrypto_box_seal (X25519, XSalsa20-Poly1305)NaCl / libsodiumgolang.org/x/crypto/nacl/box
Nearby key derivation, code, payload idSHA-256FIPS 180-4Go standard library
Nearby identity proofHMAC-SHA-256RFC 2104Go standard library
Random keysoperating-system generator—Go crypto/rand; WebCrypto getRandomValues
Relay connectionTLSRFC 8446, RFC 5246Go standard library; the browser
Direct connectionDTLS, SCTP, ICERFC 6347, RFC 8831, RFC 8445pion

No proprietary cipher or hash is used. peeza's own constructions are the frame and nonce layout (section 4), the nearby derivation (section 6) and the tag check (7.3), all described here in full.

12. Test vectors

Machine-readable copy: protocol-vectors.json, beside this file.

12.1 Transfer

key (base64url)
0123456789abcdefghijklmnopqrstuvwxyzABCDEFG
key (hex)
d35db7e39ebbf3d69b71d79f8218a39259a7a29aabb2dbafc31cb30010831051

Manifest, nonce(0x08, 0), additional data peeza-e2e-v1:

plaintext
{"n":"holiday photos.zip","s":6000968,"t":1789092766750,"p":true,"d":true,"f":1}
sealed
l1LuMwkPNQZZU1eFaO-6IfCWDsq-T163XFOL5DKjhwJCN-KL4dLeoLjE0eDKgigyXWKWdKdnYueu72MuAqLw3xwovxtbLoDl6KgBhbB5gtc4zdQ25w_i_SWnQk6rO2zG

Name, nonce(0x01, 0), additional data peeza-e2e-v1:

plaintext
holiday photos.zip
sealed
UqV9vFmAyQLXm_fBSfscZTFFhWAeVSBQH2aNsrzVZfvivg

Receipt, nonce(0x09, 0), total 6000968, room id 1WMcraeO:

plaintext
peeza-received-v1
sealed
Z-YJtRNvDjM3j6eYPnG3tF4uHTskAIWxrchi4Oh57uHc

Chunk number 2 of a payload whose total length is 200000 bytes:

plaintext
peeza test chunk
nonce
000000000000000000000002
aad
7065657a612d6532652d76310000000000030d40
frame
0000000000000002de6a0e0ac48439cc0dde96867c21744ed5f634f94749c5f461c74df599d7fc4e

12.2 Nearby

Private keys are 32 identical bytes each: A's identity 0x11…, A's ephemeral 0x22…, B's identity 0x33…, B's ephemeral 0x44….

A identity public
7b4e909bbe7ffe44c465a220037d608ee35897d31ef972f07f74892cb0f73f13
A ephemeral public
0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20
B identity public
7b0d47d93427f8311160781c7c733fd89f88970aef490d8aa0ee19a4cb8a1b14
B ephemeral public
ff2ee45601ec1b67310c7790404585ae697331eee1c1f8cf2419731c1fff3e6b
transcript
febacfc413c62c305ca53e9e98cad80171069b29bce55148d2c38bcd0a05ed56
session key
997cd336b63a6bb0a91e8528e5cd57360ded08d8ca2e07a8263005ef2fbe2737
A's proof
d62edfc0a7c7dc90c9844903c5421c268c15c85c4359388c94e779fc9335324a
B's proof
6f7b5a4dbe8c621e6685b4866c4e51ff1ba92d7c7e1526d506fe65691121f2df
code
678689

13. Reporting a problem

Security reports: security@peeza.app. The disclosure policy and safe-harbour terms are at https://peeza.app/security. If this document is wrong about what the apps do, that is a security report too.

14. Changes

versiondatechange
12026-09-30First publication. Describes wire version peeza-e2e-v1 with the mandatory manifest and receipt.
22026-10-04Section 9: the relay issues each room's sender a proof and keeps its hash. 10.11: a sender rejoin is not yet required to present it. The sealed format is unchanged.
32026-10-0510.9: connecting a device from a different network than the browser that finished it waits for an email approval, and every connection is notified by email with a link that disconnects that device; what remains is a phish from the same network, and the apps' half is not released yet. Section 9: the addresses compared, held in memory for 15 minutes, and the email provider. The sealed format is unchanged. The French translation is published with this version.