# E2E Plaintext Framing > Defines the structure of the **bytes that are encrypted** in the `ciphertext` field of the > `Message` frame ([relay-protocol.md §6](relay-protocol.md)). **The cryptography does not > change** ([crypto.md](crypto.md)): a blob of bytes is always encrypted with AES-256-GCM. What > changes is *what* those bytes are: a **versioned frame** wrapping the JSON payload. > > The relay remains **blind**: it sees only ciphertext, nothing about versions or compression. --- ## 1. Structure The **plaintext** (what is encrypted) is: ``` plaintext = version (1 byte) ‖ comp (1 byte) ‖ payload ``` | Field | Byte | Values | Meaning | |-------|------|--------|---------| | `version` | 1 | `0x01` \| `0x02` | Framing version. `0x01` = JSON app payload; `0x02` = **pipe signaling** (MsgPack, see below). Unknown value → receiver discards with log. | | `comp` | 1 | `0x00` \| `0x01` | Compression algorithm applied to `payload` (§2). | | `payload` | N | — | The content: **JSON UTF-8** ([payloads.md](payloads.md)) for `0x01`, **MsgPack `PipeSignal`** ([pipe.md §2](pipe.md)) for `0x02`; optionally compressed. | > **`version 0x02` (pipe signaling).** Reserved for the pipe control plane ([pipe.md](pipe.md)): > `0x02 ‖ 0x00 ‖ ` (uncompressed). It rides this same E2E channel; a receiver > peeks the first byte to route `0x02` to its pipe layer and `0x01` to the JSON app path. The > existing `decompress_payload` still only accepts `0x01` — the pipe layer handles `0x02` itself. `version` and `comp` are **in plaintext inside the plaintext** (readable only after decryption): they cannot go in the AAD or outside the ciphertext, or the relay would see them. They are integrity-protected by the GCM tag along with the rest. > **Two versioning planes, do not confuse.** `version` (this byte, `0x01`) versions the > **framing** (the binary envelope). The JSON field `v` inside the `payload` > ([payloads.md §1](payloads.md)) versions the **payload schema**. They are independent: framing > can evolve while a `kind`'s schema stays fixed, and vice versa. In these documents "version" = > framing byte; "`v`" = payload schema. (The name `v` is unchanged from the original design for > consistency with existing payloads.) ## 2. Compression | `comp` | Algorithm | Notes | |--------|-----------|-------| | `0x00` | none | `payload` = JSON UTF-8 as-is. | | `0x01` | **zlib / DEFLATE** (RFC 1950/1951) | Default for large payloads. Safe interop: Rust `flate2` ↔ iOS `Compression` framework (`COMPRESSION_ZLIB`). | | `0x02…` | _reserved_ | E.g. `lz4` in the future. Addable without breakage: a receiver that does not know a `comp` value discards with log. | Rules: 1. **Compress-then-encrypt, always in this order.** The ciphertext is not compressible; compressing after would give no gain. 2. Compression is **optional on the sender side**, **mandatory on the receiver side**: anyone receiving MUST handle both `0x00` and `0x01`. 3. **Threshold**: compress only if `len(payload)` exceeds ~1 KiB. Below that, the zlib header overhead wipes out any gain → use `0x00`. 4. Compression operates on `payload` **only**, not on the two header bytes. ## 3. Decoding (receiver side) For each decrypted `Message` envelope: 1. AES-GCM → obtain the `plaintext` blob (AAD/anti-replay identical to the crypto contract, [crypto.md §6](crypto.md)). 2. Read `version = plaintext[0]`. If `!= 0x01` → discard with log. 3. Read `comp = plaintext[1]`. If unknown → discard with log. 4. `body = plaintext[2:]`; if `comp == 0x01` → decompress (zlib). 5. Parse `body` as JSON; validate `v`/`kind` ([payloads.md §6](payloads.md)); apply action idempotently. ## 4. No Version Disambiguation There is no v1/v2 transport coexistence in production (clean break, no distributed v1 clients). Therefore **no disambiguation trick is needed**: every payload is a versioned frame (`version = 0x01`). A receiver reading a `version` different from `0x01` discards with log (§3 step 2). ## 5. Sizes & Limits The `ciphertext` travels as **raw bytes** in the `Message` protobuf ([relay-protocol.md §10](relay-protocol.md)): **no base64**, so the frame limit applies almost entirely to the ciphertext. Full chain: ``` payload →(zlib?)→ body →(GCM: +16B tag)→ raw ciphertext →(protobuf: +~tens of bytes)→ frame ``` **Normative constants** (frame limit differs per channel): ``` # Standard frame 64 KiB (control + Message live=false store-and-forward) MAX_CIPHERTEXT_BYTES = 65000 # raw ciphertext (GCM tag included) # Live frame 512 KiB (Message live=true, authenticated connection) MAX_LIVE_CIPHERTEXT_BYTES = 524000 # raw ciphertext (GCM tag included) ``` Values leave a few hundred bytes of margin for the protobuf envelope (`peer` 32B, `nonce` 12B, field tags, `live`) under the respective `MAX_*_FRAME_BYTES`. Anyone composing a large payload **MUST** close the packet before exceeding `MAX_LIVE_CIPHERTEXT_BYTES`, estimating the size **after** compression and tag. Compression helps fit more data per frame: health-type data (JSON numeric and repetitive) typically compresses 5–10×.