peeza — protocol and cryptography

Lire en français

Document version 23 · 2026-11-06 · wire version peeza-e2e-v2

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 keys derived from 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).

Claim 4 did not hold for team deliveries in wire version 1: a relay that saw two completion receipts for one team share could build a third. Version 2 of the format fixes that; the advisory is in section 14.

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 four paths. The content encryption of section 4 is identical on all four and does not depend on the transport beneath it.

pathtransportrelay's part
DirectWebRTC data channel between the two devicessignalling only
Direct, between two appsQUIC (iroh) between the two devices, through peeza's iroh relay until a direct path opens (section 8)signalling, a ticket for the iroh relay, and forwarding until a direct path opens
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 keyThe secret every key sealing one shared payload is derived from (4.1)256 bits, from the OS random generatorone share; sharing again mints a new keysender's device; receiver's device once delivered
SubkeysOne AES-256-GCM key per purpose, and an HMAC-SHA-256 key for the receipt, derived from a transfer key or a nearby session key (4.1)256 bits eachthat of the key they come frommemory, derived when needed
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 keySecret derived by the handshake of section 6, split into subkeys like a transfer key256 bitsone nearby connectionmemory
Nearby first-contact codeSix decimal digits the sending device draws for one try of a first contact, the password of section 6's SPAKE2about 20 bits; one guess per codeone trythe sending device's screen and memory; never sent

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 Primitives and the key schedule

AES-256-GCM (FIPS 197, NIST SP 800-38D): 256-bit key, 96-bit nonce, 128-bit tag. HMAC-SHA-256 (RFC 2104) for the completion receipt. HKDF-SHA-256 (RFC 5869) to derive keys. Native apps use AWS-LC (aws-lc-rs); the browser client uses WebCrypto (AES-GCM, HKDF, HMAC). Both produce identical bytes.

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

Nothing is sealed under the transfer key itself. Each purpose has its own subkey:

subkey(purpose) = HKDF-SHA-256( input key = transfer key, salt = empty, info = "peeza-e2e-v2/" ‖ purpose, length = 32 bytes )
purposekey for
contentchunks of content (4.3)
manifestthe payload manifest (4.4)
receiptthe completion receipt, an HMAC key (4.5)
delta, adoptthe two smaller conversations in use (4.6)
fromthe name of the device that opened a link (4.6)
treereserved: no app sends it (4.6)

No two purposes share a key, so a mistake in how one of them uses nonces cannot weaken another.

4.2 Nonces

A nonce is 12 bytes. Content and the manifest use a deterministic layout:

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

The two conversations and from use a fresh random nonce for every message, carried in the message (4.6). The receipt is a MAC and has no nonce.

subkeysealed messagenonceadditional authenticated data
contenta chunk of contentnonce(0x00, chunk number)VERSION ‖ uint64BE(total)
manifestthe payload manifestnonce(0x08, 0)VERSION
delta"send only what changed" negotiation12 random bytesVERSION ‖ 0x02 or 0x03, one byte per direction
treereserved (folder-layout negotiation); no app sends it—0x04 and 0x05 reserved
adopt"is the copy I already have the same file?" check12 random bytesVERSION ‖ 0x06 or 0x07
fromthe device name an app that opened a link gives the sender12 random bytesVERSION ‖ 0x02

Why no (key, nonce) pair seals two different messages that leave a device: a key seals one payload, so a chunk number always seals the same chunk (4.3) — a team device whose copy was edited seals the changed chunk only in its own memory, to compare its tag, and discards it unsent (7.3); a key seals one manifest, and the engine refuses to seal a second (4.4); and among the few hundred messages a key carries, a random 96-bit nonce repeats with negligible probability (about n²/2⁹⁷ for n messages), not never. The domain bytes 0x01 and 0x09 were version 1's file name and receipt and are not used.

VERSION is the 12 ASCII bytes peeza-e2e-v2. It is in every subkey's HKDF info, in the additional data of every sealed message and in the receipt, 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[content]( chunk_i, nonce(0x00, i), VERSION ‖ uint64BE(total) )

that is, 8 bytes of index, the ciphertext, and the 16-byte tag. AES-256-GCM[content] means AES-256-GCM under the content subkey (4.1).

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 content 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]( 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 same message says "wire": 2; a receiver refuses a message that says anything else, with a message that both sides must be up to date.

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; if the description of a frozen payload changed, the engine refuses to send rather than seal a second manifest; 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 receipt:

receipt = base64url( HMAC-SHA-256( key = subkey("receipt"), VERSION ‖ uint64BE(total) ‖ uint16BE(length of room id) ‖ room id ) )

The sender marks a transfer complete only on a receipt that verifies, compared in constant time. The relay forwards the "done" message but cannot produce a receipt: it never holds the key, and receipts it has seen for other rooms tell it nothing about this 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); what a receipt proves is that a holder of the key says the payload arrived in that room, not which device. 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

Two optional exchanges ride beside the content, each under its own subkey, with a fresh random nonce per message and a direction byte in the additional data (table in 4.2):

message = base64url( n ‖ AES-256-GCM[purpose]( JSON, n, VERSION ‖ direction byte ) ),
n = 12 random bytes

The direction byte stops a message being reflected back to the side that sent it. They let a receiver that already has an older version, or the same file, avoid receiving it again. They carry chunk digests and ranges, never keys. The "same file" check travels through the relay when there is no direct path; the relay can see that an exchange took place, not what was said.

A third purpose, tree (folder-layout negotiation), is reserved: its purpose string and its direction bytes 0x04 and 0x05 are not reused, and no app sends it.

A fourth, from, is one message, not a conversation: an app that opened a public link puts its device name, sealed as {"name": …}, in the from field of its request through the room, so the sending device can say which device received that leg. It goes one way only, so its single direction byte 0x02 is apart from delta's by its own subkey. A browser sends none, and a sending device that cannot open it ignores it; the transfer never waits for it. The receiving device is told nothing about the sender.

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. While the sending device waits, the relay tells it whether the receiving device is connected and whether it is holding the delivery for someone to accept (section 9).

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.

A delivery to one device of a team member, rather than a file shared with the team, names the team and no shared item. The relay lets it through only while both accounts are active members of that team, always marks it to be held until someone accepts it on that device, and tells that device which account sent it — the account that owns the room, the one fact about the sender the relay has established. It adds nothing to the team's catalogue.

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, every 3 seconds, on every network interface it has, to two groups: 239.255.66.66:5354 over IPv4 and [ff02::7065:657a]:5354 (link-local scope) over IPv6, each on the interfaces that have an address of that family. The announcement is the same in both: device id, name, operating system, app version, identity public key, listening port, and the announcing device's own IPv4 addresses on its routed interfaces. A device connects back to the announced address that lies on one of its own networks when there is one; otherwise to the address the announcement came from, preferring a routable address to a link-local one and, between two of the same kind, IPv4; an IPv6 link-local address is used with the interface it arrived on, and one that arrived over a tunnel ranks last. This is unauthenticated plaintext, visible to the local network, and nothing is trusted because of it: the handshake below is what authenticates the device reached. Nearby is off until the user turns it on on macOS and iOS, which ask before an app reaches the local network. On Android, Windows and Linux it starts on; on Windows it waits while the network is one Windows calls Public.

Handshake. The devices connect over a plain WebSocket and each sends a hello with its identity public key I, a fresh ephemeral public key E and the nearby protocol version, 2. A device whose hello carries an older version is refused (outdated), with no fallback. Both compute:

transcript  = SHA-256( "peeza-lan-v2" ‖ 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 )

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. After the proofs, each side says whether it already knows the other: its key is pinned, or it is one of this account's own devices (below). When both do, the transfer starts. Otherwise the sending device draws a code — six decimal digits, uniform, from the operating system's generator — and shows it; the code never travels. The person at the receiving device types it. Both then run SPAKE2 over the Ed25519 group with the code as the password and each side's identity bound to the transcript, and confirm the result:

id_a, id_b  = "peeza-lan-v2/sender" ‖ transcript, "peeza-lan-v2/receiver" ‖ transcriptK           = SPAKE2( code, id_a, id_b )confirm_a   = HMAC-SHA-256( key = K, message = "peeza-lan-confirm-a" ‖ transcript )confirm_b   = HMAC-SHA-256( key = K, message = "peeza-lan-confirm-b" ‖ transcript )

The receiver sends its SPAKE2 message with confirm_b; the sender checks it in constant time and answers with confirm_a, which the receiver checks. A mismatch makes the sender draw a new code and lets the receiver try again; the third mismatch ends the pairing on both sides. A confirmation proves that both sides computed the same transcript, and so saw the same identity and ephemeral keys: someone in the middle who substituted a key cannot confirm without the code. K serves only the confirmation; the session key is the one above. A match pins the peer's identity key, and the next connection to a pinned device asks for no code. A device that is not signed in pins too, in its signed-out store, which is kept apart from any account's pins.

One account's own devices. When both devices are signed in, each asks the relay for its account's device list (5.2) at first contact. A peer whose device id and identity public key both appear on that list counts as known, and is pinned without a code. A device id listed with a different key, a key that differs from an earlier pin, or a list the relay cannot supply in 5 seconds does not; if either side does not know the other, both go through the code. This is the trust a send addressed to a device already takes (10.3): between one account's devices, the first key comes from the relay, not from the two people.

Consent. A pin says who a device is, not that its files are wanted. When a pinned device that is not one of the receiver's own sends, the receiver reads the sealed manifest, shows the file and waits for its person's Accept before it asks for any chunk; meanwhile it sends held once a minute, and the sender waits. A Decline ends the session with error {reason:"declined"}. A send from one of the account's own devices starts at once, unless the receiver has turned automatic acceptance off. Typing the code at first contact is the Accept.

Content. The session key takes the transfer key's place in section 4: it is split into the same subkeys (4.1), which seal the manifest and the chunks and key the receipt. 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 code. SPAKE2 reveals nothing that can be tested offline, so each guess needs a live exchange while someone is typing, and a pairing allows three before it ends.

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, except as 10.1 describes.

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. The re-sealed chunk stays in that device's memory until the tags match; one whose tag does not match is discarded, never sent, so an edited chunk is sealed under the original nonce locally but never leaves the device. 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, between two appsQUIC (iroh): TLS 1.3 inside QUIC, each side authenticated by its endpoint's Ed25519 key — a fresh one for every transfer, whose id is exchanged through the relay's signalling. The sending app holds the first frame up to two seconds for a direct path; the frames go directly when one opened, and through peeza's iroh relay only when none did. A direct path that opens later takes over within the same connection.
Device ↔ iroh relayTLS 1.2 or later (WebSocket) where the iroh relay is reached over HTTPS, and a ticket for that transfer (below). The iroh relay forwards QUIC packets it cannot decrypt.
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.

Between two apps. When peeza's relay offers it, an app asks the relay for a ticket for each transfer, opens a QUIC endpoint under a fresh key, and names that endpoint in the room before its WebRTC offer, with the relay it is reached through and its direct addresses (the iroh message's addrs: host, public and router-mapped addresses as ip:port, at most 16, never loopback or link-local; with Nearby off on a Mac or iPhone, none on a local network). The receiving app names its own endpoint with no addresses: only the side that dials uses them. Another app dials the endpoint at those addresses and through peeza's iroh relay at once, opens one stream, writes the room's id and the offset it wants ("PZ1", the id's length as a 16-bit big-endian integer, the id, the offset as a 64-bit big-endian integer), and receives the sealed frames of 4.3, each preceded by its length as a 32-bit big-endian integer. A receiver that asks through the room for the stream to go on from another offset (the request every path uses, say for a player reading ahead) gets the frames from there on the same stream, which stays open until the transfer ends. The completion receipt still travels through the relay (4.5). Any other receiver — a browser — ignores the endpoint and answers the WebRTC offer as before. The paragraph above holds unchanged: a relay that substituted an endpoint id would hold a QUIC session and still see only sealed frames.

The iroh relay is peeza's own service. It admits a connection only with a ticket the relay signed for that side of that transfer (Ed25519, valid ten minutes for admission), and tells each device its public address, as a STUN server does. This path uses no relay or address-lookup service run by others.

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 manifest and sealed content, and the sending device's machine id. 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, except between two devices signed in to the same account: those pin each other from the relay's device list without a code, so a relay that substituted a key on the list could take the place of one of them at first contact.

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. On 2026-10-07 version 2 of the format (peeza-e2e-v2) split every key into subkeys, made the receipt a MAC and gave the conversations random nonces (section 14). Apps on an older version cannot exchange files with current ones, and fail with a message that both sides must be up to date 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.

10.12 Bytes carried by the iroh relay are counted as they cross it. (The iroh relay serves only transfers between two apps.) The iroh relay counts every byte a device sends through it for a transfer, QUIC's own framing and acknowledgements included, so a transfer that stays on that relay counts somewhat more than the file's size against an allowance (8 % more, in our measurement of a 16 MB file). The WebSocket relay counts the sealed frames it forwards. Bytes sent before a direct path opens count; bytes sent after do not.

10.13 Pairing a phone by QR code: the desktop's Allow is the defence. A device already connected to an account can show a QR code that another device scans to join the same account, with no browser and no sign-in provider. The code is 128 random bits from the relay, shown as a link to https://peeza.app/pair#<code> (the code is in the fragment, so the site never receives it). It lives 2 minutes, works once, is replaced while the screen stays open and withdrawn when it closes; the relay keeps only its hash. A device that presents it does not receive anything yet: the relay asks the device that showed the code, over that device's own connection, naming the new device as it reports itself, and only an Allow from the same session that minted the code releases a session to the new device, once. Deny, or a minute without an answer, refuses it. Every pairing then emails the account the same notice as 10.9, with its Disconnect link. The network comparison of 10.9 does not apply: the Allow is the approval.

What remains:

11. Algorithms and libraries

purposealgorithmstandardimplementation
Content, manifest, conversationsAES-256-GCMFIPS 197, SP 800-38DAWS-LC (aws-lc-rs); WebCrypto in the browser
SubkeysHKDF-SHA-256RFC 5869AWS-LC (aws-lc-rs); WebCrypto in the browser
Completion receiptHMAC-SHA-256RFC 2104AWS-LC (aws-lc-rs); WebCrypto in the browser
Device identityX25519RFC 7748crypto_box
Nearby key agreementX25519RFC 7748AWS-LC (aws-lc-rs)
Envelope to a devicecrypto_box_seal (X25519, XSalsa20-Poly1305)NaCl / libsodiumcrypto_box
Nearby key derivation, payload idSHA-256FIPS 180-4AWS-LC (aws-lc-rs)
Nearby identity proof, code confirmationHMAC-SHA-256RFC 2104AWS-LC (aws-lc-rs)
Nearby first-contact codeSPAKE2, Ed25519 groupAbdalla and Pointcheval, 2005spake2 (RustCrypto); the code from AWS-LC (aws-lc-rs)
Random keysoperating-system generator—transfer keys and nonces: AWS-LC (aws-lc-rs), seeded by the operating system; device keys: the operating system's generator (getrandom); WebCrypto getRandomValues
Relay connectionTLSRFC 8446, RFC 5246rustls, on AWS-LC; the browser
Direct connectionDTLS, SCTP, ICERFC 6347, RFC 8831, RFC 8445str0m (its DTLS on AWS-LC, its SCTP sctp-proto); the browser
Direct connection between two appsQUIC, TLS 1.3, Ed25519RFC 9000, RFC 8446, RFC 8032iroh 1.3.0, built from a vendored copy with two changes of peeza's that touch no cryptography (where an app may send when Nearby is off; releasing a router port mapping); rustls on AWS-LC

Every native app embeds the same engine, so these are the libraries in every app.

No proprietary cipher or hash is used. peeza's own constructions are the key schedule, 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

Subkeys (4.1):

content
fd56a64a4093b99e5ec425d7ba13af9cc7a7572dc866bac19292c4416dc7845f
manifest
730c07adf6cf7e1f58b41bb8477e80348c24667cd7f7be1079eabe573c3b0527
receipt
e00ca01a2ae2d409131175a91434c2aa07eaa9723a5745bdebe7bdf7e06016e8
delta
b2b94aec98c41dc6cae53f9ce52ddc0739eb0ff44e064c4e4d2b7a42231c351a
tree
8eba0b899da8a63cde00c842d60b816b1c48c3aa2be9e868178c04651fdd7187
adopt
e03607cade4705487825892ab5c5035aaa018691d64c54c7d594858ac615a5b6

tree is reserved and no app sends it (4.6); its vector stays so the derivation of that purpose is pinned.

Manifest, manifest subkey, nonce(0x08, 0), additional data peeza-e2e-v2:

plaintext
{"n":"holiday photos.zip","s":6000968,"t":1789092766750,"p":true,"d":true,"f":1}
sealed
GNgoteoie4R2WzT1GoZaS9A0K_tu6q_eJjfDXkehJuJy5cJRQ5rdYGRu-pYmjzwa4OO4wPe2g_Ff9xz8wg9S506dlCAKB188GCfib30q4QmhRnFsgqnQlGMOvg0s3D3v

Receipt, receipt subkey, total 6000968, room id 1WMcraeO:

receipt
jOm_VZChG4YBf732hlJaRTdNn7m614lH7It3TsuGDUs

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

plaintext
peeza test chunk
nonce
000000000000000000000002
aad
7065657a612d6532652d76320000000000030d40
frame
0000000000000002fc31fbdf72482c593b92cd5a50c2898992d7076c047892f232c67c1c22977126

Conversation message, adopt subkey, receiver to sender (direction byte 0x06). The nonce is random on the wire; this vector fixes one:

plaintext
{"t":"have?","chunk":64512,"total":6000968}
nonce
a0a1a2a3a4a5a6a7a8a9aaab
aad
7065657a612d6532652d763206
message
oKGio6SlpqeoqaqrwUBzu3zdCi4l6cVFeYIZzwgbVW7AqFD51XT4Q9Y9uK22jf5-DoMmiMLie37QwspXxfnGTC4Kc3TyxQY

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
e6b1a2e4f06d1e769400a2459be89328ba6096524a5b4baa2c7dd6c82c330ec8
session key
dbbf27b17e4abc643def33f775d3db66e01c30df35b963fdf931f71c13dca43d
A's proof
114df8a11475dbb483a227755faca35f66357e2e6e700f57a744c267f43f36d0
B's proof
5d065f64a6fe019e791d6aba2ba1464db8ca34b9667c2229248cdac1d673ad4d
content subkey
2893ccbfb07b59a85c509f5399c3b693ee3496785831291acfbf040e6f688b23

The code's confirmations, with K fixed at 32 bytes of 0x55 over that transcript (SPAKE2's own messages are random, so they have no vector here):

confirm_a
a0f3251a6ec461a44b3da17a9ed258347494f90bbe7f12a321e20181e8057a46
confirm_b
040b91ce477fcbf4cbf634845cf189117b841498477b6b94b55e83fe83145b15

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.
42026-10-07Wire version peeza-e2e-v2, with the advisory below. 4.1: every purpose has its own HKDF subkey. 4.2, 4.6: the conversations take a random nonce per message. 4.5: the receipt is an HMAC. 4.4: a meta says wire: 2, and a second manifest under one key is refused. The legacy sealed file name is retired. 7.1 points to 10.1. 11 and 12 follow.
52026-10-07The advisory records the reviewer's re-test of version 2. 4.2 and 7.3: a team device checking an edited copy seals the changed chunk in memory before discarding it, and random nonces repeat with negligible probability rather than never; the advisory's test sentence says what the test covers. The sealed format is unchanged.
62026-10-0710.9: the apps' half of connecting a device (the "Check your email" screen and the device's name in the emails) shipped in the 2026-10-06 releases; what remains is apps older than those. The sealed format is unchanged.
72026-10-12Section 9: a device that relays without an account presents its machine id when it opens or rejoins a room, and the relay keeps a keyed hash of it with a count (a sender with none is counted by IP address, in memory only) of the bytes relayed, for 30 days, to hold it to the signed-out allowance. The sealed format is unchanged.
82026-10-125.2 and section 9: while a sending device waits on a delivery addressed to a device, the relay tells it whether that device is connected and whether it holds the delivery for someone to accept, from what the relay already holds. The sealed format is unchanged.
92026-10-18Section 6: nearby discovery also announces over IPv6, to the link-local group ff02::7065:657a on the same port, and says which address a device connects back to. The announcement and the sealed format are unchanged.
102026-10-18Section 8: between two apps on peeza's new engine — not in a released app yet — a transfer runs over QUIC (iroh), through peeza's own iroh relay until a direct path opens, carrying the sealed frames of section 4 unchanged. Section 9: what that relay sees, and the record the relay keeps per ticket. 10.12: how its bytes are counted. Section 11 follows. The sealed format is unchanged.
112026-10-185.2 and section 9: a delivery to one team member's device, rather than a file shared with the team, is always held for someone to accept on that device, and the relay tells that device which account sent it. The sealed format is unchanged.
122026-10-20Section 8: on the new engine's QUIC stream, a receiver's request through the room moves the stream to another offset, and the stream stays open until the transfer ends. The sealed format is unchanged.
132026-10-22Section 9: when a signed-in device disconnects, the relay keeps its last mirrored transfer list in memory for up to 7 days, so the dashboard can show it while the device is away. The sealed format is unchanged.
142026-10-22Every app now runs the Rust engine that version 10 called new, so sections 4.1, 8, 9, 10.12 and 11 no longer say it is unreleased; tree is reserved and no app sends it (4.1, 4.2, 4.6); section 11 names that engine's libraries. The sealed format is unchanged.
152026-10-236 and 10.3: two nearby devices signed in to the same account pin each other from the relay's device list, with no code, when both the device id and the identity key match. The sealed format is unchanged.
162026-10-236: at first contact the sending device shows a random code and the receiving device's person types it; the two confirm it with SPAKE2 over the handshake transcript, and three misses end the pairing. The code is no longer derived from the transcript, and a pairing asks when either side does not know the other. The nearby protocol is version 2 (peeza-lan-v2); a version 1 device is refused. 11 and 12.2 follow. The sealed format is unchanged.
172026-10-2510.13: a device connected to an account can show a one-time QR code that another device scans to join the account; the relay asks the showing device, and only its Allow releases a session, followed by the connection notice of 10.9. Section 9: the code's hash and the claim, in memory for at most 3 minutes. The sealed format is unchanged.
182026-10-28Section 8: between two apps, the sending app's iroh message names its endpoint's direct addresses, the receiving app dials them at once, and the sending app holds the first frame up to two seconds for a direct path before the iroh relay carries it. Section 9: those addresses pass through the relay. The sealed format is unchanged.
192026-10-29Section 9: for a team, the relay also keeps which member invited each pending address when it was not the owner, and whether the owner lets members invite. The sealed format is unchanged.
202026-10-31Section 2 lists the QUIC path between two apps beside the other three. Section 11 says which iroh is built, and that peeza's two changes to it touch no cryptography. The sealed format is unchanged.
212026-11-04Section 9: the relay compares the two ends of a transfer and tells both, as one bit, whether they are on the same network; it keeps neither the addresses nor the answer. The sealed format is unchanged.
222026-11-054.1, 4.2, 4.6: a new purpose, from: an app that opens a public link gives the sending device its device name, sealed, on its request. Section 9: the relay sees that sealed field's length, and a signed-in sender's mirrored transfer list names that device as it already names others. The sealed format of content and manifests is unchanged; older apps ignore the field.
232026-11-066: a device that is not signed in keeps the pin a matched code makes, so its next connection to that device asks for no code. The wire is unchanged; the hello's anon field is still sent, and older apps read it to pin nothing.

Advisory, 2026-10-07: the completion receipt and three sealed conversations reused AES-GCM nonces.

What was wrong. In wire version peeza-e2e-v1 every sealed message used the transfer key itself, and four kinds of message could seal two different inputs under one key and one nonce:

Reusing an AES-GCM nonce under one key with different inputs exposes that key's authentication subkey. Because the receipts and the content were sealed under the same key, whoever held two receipts of one team share could, in the worst case, also alter that share's relayed content frames undetected. It did not expose content or keys: nothing that was sealed could be read. We have not measured whether a shipping flow repeated a conversation nonce with different content, and assume that it could.

Affected. Every app and browser client that speaks peeza-e2e-v1 with the mandatory receipt: from the 2026-09-28 round — Mac 0.2.66, macOS 12–13 1.0.0-beta.68, Windows 1.0.6, Linux 0.0.3, iOS and Android build 2786, and the web client from that day's relay — through the last release before the fix.

Fixed in wire version peeza-e2e-v2 (this document): every purpose has its own key, the receipt is an HMAC with no nonce, the conversations take a random nonce per message, the legacy name is gone, and a test records every seal in scripted team deliveries, reconnects and restarts and fails on any key and nonce pair that seals two different inputs. Fixed versions: the release round that carries wire version peeza-e2e-v2, and the web client from the relay deployed with it; the round's version numbers are listed on https://peeza.app/security. A v1 app cannot exchange files with a v2 app, and says both sides must be up to date.

Verified. The same reviewer re-ran its reproduction against version 2 on 2026-10-07, at source commit 2f29172e: no forged receipt was accepted, by the engine or by the browser sender, in 100 trials of each variant (another room, another total, replays, version 1 receipts), where version 1 accepted 100 of

  1. That re-test covers the receipt construction and the source; it does not

certify the published builds.

Credit. Found by an external assessment of peeza's product and security, carried out with OpenAI Codex (report of 2026-10-07). The conversation and file name cases were found while fixing it.

peeza was in private beta, with no outside users, when this was found.