Files
2026-07-10 15:02:09 +01:00

105 lines
5.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ‖ <MsgPack PipeSignal>` (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 510×.