# Tamiz Protocol v2 Tamiz is a messenger for small groups (2 to 16 people) with **ephemeral messages** and **one-time-pad encryption**. The members create a channel in person by exchanging a large random pad directly between their phones, device to device. v2 adds groups, a channel name, and channels that never expire. After that, messages travel through a blind relay. Every byte of pad is used once and then destroyed. When the channel expires, everything is wiped on both phones and on the server. ## 1. Goals and threat model **Guarantees** - **Confidentiality even against unlimited compute.** An attacker who sees every ciphertext learns nothing except lengths, timing and the mailbox ids. This follows from the one-time pad, as long as the pad is secret, truly random and never reused. - **Integrity.** A modified, forged or replayed message is rejected (one-time Poly1305 MAC, keyed from the pad). - **Forward secrecy per message.** Once a message is decrypted, its pad bytes are zeroed on both devices. A phone seized later cannot decrypt ciphertexts that were captured earlier. - **Ephemerality.** - Messages are deleted from the devices after the contract's `message_ttl_s`. - Messages are deleted from the relay once delivered, or after `server_ttl_s`. - The whole channel is crypto-shredded at `channel_expires_at`. - **No identities on the server.** The relay has no accounts, phone numbers or names. It only sees opaque mailbox ids. **Out of scope, or only partially covered** - **Metadata.** The relay and the network see message sizes (bucketed by padding), timing, IP addresses, and which two mailboxes form a pair. - **A malicious peer.** The other person can photograph the screen. FLAG_SECURE only blocks screenshots and screen recording. - **A compromised device while the channel is alive.** Malware with root on an unlocked phone can read the pad and the messages that have not yet expired. - **Nearby transport.** Setup uses Google Nearby Connections, which is local and encrypted. The safety-code comparison in §3.4 is what defends against a man-in-the-middle during setup. ## 2. Terms | Term | Meaning | |---|---| | `P` | The shared pad: `N` bytes. `N` ∈ {64 MiB, 256 MiB, 1 GiB} | | Host / guests | The host proposes the contract and advertises. Guests join. The host is member `0`; guests are `1..m-1` in the order they joined | | `m` | Number of members, 2 to 16 | | `‖` | Concatenation | | `u8`, `u32`, `u64` | Unsigned big-endian integers | | `SHA256`, `hex` | Standard SHA-256; lowercase hexadecimal | ## 3. Channel creation (in person) ### 3.1 Contract The host proposes a contract. The guest must accept it explicitly. ```json { "v": 2, "channel_id": "<32 hex chars, random from host>", "name": "Dad - Lucia", "members": ["Dad", "Lucia"], "created_at": 1790500000, "channel_expires_at": 1793092000, "message_ttl_s": 172800, "server_ttl_s": 172800, "pad_size": 268435456 } ``` - **Canonical form `C`:** UTF-8 JSON with keys in the order shown above, no spaces, integers only. - **`name`, `members`:** at most 40 characters each, never containing `"`, `\` or control characters, so they need no JSON escaping. `members[i]` is the display name of member `i`. - **`channel_expires_at`:** absolute Unix seconds, or `0` for **never**. After this moment every device wipes the channel. A channel that never expires ends only when its pad runs out or someone burns it. - **`message_ttl_s`:** how long a message lives on a device, counted from the moment that device stored it (sent or received). - **`server_ttl_s`:** the maximum time a ciphertext may wait on the relay. The relay clamps it to at most 7 days. ### 3.2 Local link - **Transport:** Nearby Connections, strategy `P2P_STAR`, service id `net.digitalfile.tamiz`. The host advertises and each guest discovers it. - **Connection check:** for every connection, both screens show the Nearby authentication digits, and both users confirm they match before the connection is accepted. - **Lobby:** the host sees the list of people who joined, then taps **Start**. After that nobody else can join. Link messages are BYTES payloads holding JSON `{"t": "", ...}`: 1. Guest → host: `{"t":"hello","name":""}` 2. Host → each guest: `{"t":"contract","c":"","index":i}` 3. Guest → host: `{"t":"accept"}` or `{"t":"reject"}`. A single reject aborts the setup for everyone. 4. The pad is exchanged (§3.3). 5. Everyone exchanges `{"t":"done","h":""}` with the host. Each device compares the value with its own. ### 3.3 Pad generation - **Generation:** each device generates `R_self` (`N` bytes). - **Star exchange:** each guest sends `R_guest` to the host in one STREAM payload. The host computes `P = R_host XOR R_guest1 XOR … XOR R_guest(m-1)` chunk by chunk, writes it, and streams `P` back to every guest in one STREAM payload each. `P` is at least as unpredictable as the best single contribution. The host is a member, so it is trusted with `P` anyway. - **Physical noise:** `R_self` is not taken from `SecureRandom` alone. Throughout setup the device folds physical noise into a 32-byte pool: accelerometer, gyroscope and magnetometer readings, plus their nanosecond timestamps. The pool update is `pool = SHA256(pool ‖ sample)`. Chunk `i` of `R_self` is then `SecureRandom(len) XOR AES-256-CTR(key = SHA256(pool ‖ "tamiz-mix"), iv = u64(i) ‖ 0^8)`. Sensor noise alone is low-entropy and biased, so it is only ever *added* to the system generator and never replaces it. - **Storage:** contributions are never stored. Only `P` is written, and it is encrypted at rest (§6). - **Hashing:** `SHA256(P)` is computed incrementally while the pad is written. ### 3.4 Safety code ``` F = SHA256("tamiz-v2-fp" ‖ C ‖ SHA256(P)) code = decimal(u32(F[0..4]) mod 10^4) ‖ " " ‖ decimal(u32(F[4..8]) mod 10^4) ‖ " " ‖ decimal(u32(F[8..12]) mod 10^4) (each zero-padded to 4 digits) ``` - **Comparison:** every phone shows `code`, and the users check it side by side. - **Result:** the channel becomes active only when the user taps **Codes match**. On mismatch or cancel, everything is wiped. ### 3.6 Text key by camera (optional, in person only) **Why it exists.** The main pad travels over Nearby, whose link is encrypted with standard cryptography. Someone who recorded that radio traffic and could one day break its encryption would recover the pad. To avoid that, the channel can carry a second, smaller **text pad** `T` that never goes over any radio: it goes from screen to camera. **Contract.** `"text_pad":`, appended after `pad_size` (before `remote`), only when it is greater than 0. Allowed sizes: 64 KiB to 8 MiB. The app offers 512 KiB, 1 MiB and 2 MiB. **Generation.** The host generates `T` with its entropy mixer. Guests don't contribute: nothing can travel back visually during the same pass. **Frames.** `T` is cut into 1000-byte chunks. Each frame is: ``` "BQ1:" ‖ base64( "BQ" ‖ 0x01 ‖ u16 index ‖ u16 count ‖ u32 len(T) ‖ SHA256(T)[0..4] ‖ chunk ) ``` - It is shown as a QR code (error correction L, byte mode). - The host loops over all frames at about 10 per second until every guest confirms. - Guests keep every new index they see, and ignore frames with a different `SHA256(T)[0..4]`. **Confirmation.** When a guest has every chunk, it checks `SHA256(T)` and sends over Nearby `{"t":"tdone","h":hex(SHA256(T))}`. The host continues with the main pad (§3.3) once every guest has confirmed with the host's own hash. **Safety code.** When there is a text pad, the hash `H` given to §3.4 is `SHA256(SHA256(P) ‖ SHA256(T))`. **Use.** `T` has its own member regions (same layout as §4, but no mailboxes: those always come from `P`) and its own send cursor and consumed ranges. - Messages of kind text (including view once) use `T` while the author's region has room; otherwise they fall back to `P`. - A frame that uses `T` sets bit `0x80` of the `sender` byte in the header. Because the header is covered by the MAC, that flag can't be tampered with. **Physical risk.** Anyone who films the QR sequence gets `T`. The host screen says to shield it. ### 3.7 Camera-only channels (maximum security) **Contract.** `"visual":true`, placed after `text_pad` (which must be absent) and before `remote` (which must be absent). `pad_size` is at most 8 MiB; the app offers 1, 2 and 4 MiB. **Key.** The host generates the main pad `P` itself and sends it with the §3.6 animated-QR procedure, instead of the Nearby exchange of §3.3. No key material is sent over any radio. Nearby only carries names, terms and hashes. **Completion.** 1. Each guest confirms with `tdone`, which here carries `SHA256(P)`. 2. The host checks every hash and answers with `done`. 3. Everyone moves to the safety code (§3.4). **Use.** - Mailboxes, layout and messages are exactly as in §4–§5. - The app offers only texts (including view once). A single photo would consume a large part of such a small pad. **The UI calls the three in-person options "security levels":** Maximum (§3.7), High (§3.3 + §3.6) and Standard (§3.3). ### 3.5 Remote setup (invite code) Remote setup is for testing, or for people who cannot meet. **It is weaker:** the pad travels over the internet, so the channel is only as strong as the standard cryptography that carries it (ECDH P-256 + AES-256-GCM), not information-theoretically secure. The contract gets `"remote":true`, placed last in the canonical form, and the app labels the channel **Remote setup**. **Invite code.** The host generates `S`: 12 symbols from `ABCDEFGHJKMNPQRSTVWXYZ0123456789` (60 bits), shown as `XXXX-XXXX-XXXX`. On input, `O→0`, `I/L→1` and `U→V`. From `S`: - rendezvous mailbox: `R = hex(SHA256("tz-remote-box:" ‖ S))[0..32]` - invite key: `K_S = SHA256("tz-remote-key:" ‖ S)` **Envelope:** `nonce[12] ‖ AES-256-GCM(key, nonce, plaintext)`. Messages are flat JSON. **Flow:** 1. Each guest creates an ECDH P-256 key pair and a random private reply mailbox `B`. It posts to `R`: `Env(K_S, {"t":"hello","name","pub","box":B})`. 2. The host polls `R` and shows the joined names in its lobby, then taps **Start**. 3. To each guest's `B`, the host posts `0x01 ‖ Env(K_S, {"t":"contract","c":base64(C),"index":i,"pub":hostPub})`. 4. The guest answers to `R` with `Env(K_S, {"t":"accept"|"reject","box":B})`. 5. Per guest, the pad key is `K_i = SHA256("tz-remote-pad:" ‖ S ‖ ":" ‖ hostPub ‖ ":" ‖ guestPub ‖ ECDH(host, guest))`. 6. The host generates `P` alone, with the entropy mixer, and sends it in 4 MiB chunks: `0x02 ‖ Env(K_i, u64(index) ‖ bytes)`. It ends with `0x01 ‖ Env(K_i, {"t":"done","h":hex(SHA256(P))})`. 7. Each guest reassembles the chunks in order, checks the hash, and deletes everything from `B`. 8. Everyone compares the safety code (§3.4) **over a voice or video call**. **Why it holds up:** - Someone who sees the invite code (for example in a chat) can read the hellos, but not the pad: that needs ECDH. - An active man in the middle changes the pad, and with it the safety code. They could also show up in the lobby as an extra name. - Setup messages live at most 1 hour on the relay. - The app limits remote pads to 256 MiB. ## 4. Pad layout | Range | Use | |---|---| | `[16i, 16i + 16)` | Inbox id `M_i` of member `i` (up to 16 members) | | `[256 + i·S, 256 + (i+1)·S)` | Send region of member `i`, where `S = floor((N − 256) / m)` | - **Mailbox ids:** a mailbox id on the wire is `hex(M_i)` (32 chars). Each device reads only its own inbox. - **Sending:** each device keeps a send cursor that starts at the beginning of its own region and only moves forward. - **Receiving:** each device keeps the set of consumed ranges, as absolute pad offsets. - **Fan-out:** a message from member `i` is one frame, uploaded once to the inbox of every other member. ## 5. Messages ### 5.1 Plaintext ``` pt = type u8 ‖ msg_id [16] ‖ sent_at u64 (ms) ‖ body_len u32 ‖ body ‖ zero padding ``` | type | body | |---|---| | `0x01` text | UTF-8 | | `0x02` image | JPEG, re-encoded on the device (strips EXIF), longest side ≤ 1600 px | | `0x03` voice | Opus in Ogg, mono, about 24 kbit/s, at most 2 minutes. Always sent on the main pad, never on the text pad; not offered in camera-only channels (§3.7) | | `0x04` burn | empty. The peer has destroyed the channel, so the receiver wipes it too | **View once:** bit `0x80` of `type` can be combined with text or image (`0x81`, `0x82`). - The sender keeps only a marker, never the content. - The recipient shows a placeholder. When it is opened, the message file is deleted *before* the content is displayed, so it can be seen exactly once. - If it is never opened, it still expires with `message_ttl_s`. **Padding** (it hides exact lengths and consumes pad; decided by `type & 0x7f`): | Case | `len(pt)` rounded up to a multiple of | |---|---| | Text and burn | 256 bytes | | Image or voice | 16 KiB | ### 5.2 Frame (on the wire) ``` header = "TZ" ‖ 0x02 ‖ sender u8 ‖ offset u64 ‖ ct_len u32 (16 bytes) frame = header ‖ ct ‖ tag[16] ``` **Header fields:** - `sender` is the author's member index. It is covered by the MAC, so it can't be forged. - `offset` is the absolute pad offset where this message starts. **Sender:** 1. Needs `32 + ct_len` unused bytes at `offset = cursor`. 2. `K_mac = P[offset, offset+32)` and `K_enc = P[offset+32, offset+32+ct_len)`. 3. `ct = pt XOR K_enc`. 4. `tag = Poly1305(K_mac, header ‖ ct)`. This is RFC 8439 Poly1305 used as a one-time MAC with a fresh key per message, so it is information-theoretically secure. 5. Zero `P[offset, offset+32+ct_len)`, advance `cursor`, then upload the frame. **Receiver:** 1. Check magic, version, and that `sender` is another member. 2. Check that the range lies inside the sender's region and does not overlap any consumed range. 3. Recompute the tag and compare it in constant time. On mismatch, drop the frame and do not zero anything. 4. `pt = ct XOR K_enc`, then zero the range and record it as consumed. 5. A replay targets consumed (zeroed) bytes, so step 2 rejects it. **Pad exhausted:** the channel is dead. The people must meet again. ## 6. Storage on the device **Channel key:** - Per channel there is a Keystore key `tz_ch_` (AES-256-GCM, non-exportable, StrongBox when available). - It wraps a random 32-byte data key `DEK`, which is stored in `key.bin`. **`pad.bin` (the pad):** - `P` encrypted with AES-256-CTR under `DEK` (IV all zeros; the key is unique to this file). This allows random access. - Consumed ranges are overwritten with **literal zero bytes**, not encrypted zeros, so old and new blocks never share a keystream. **`state.bin`:** AES-256-GCM(`DEK`) of the contract, our member index, the local channel name, the send cursor and the consumed ranges. **`msgs/.bin`:** AES-256-GCM(`DEK`) of `{sender, type, sent_at, stored_at, expires_at, body}`. Messages are shown in `stored_at` order, because members' clocks may differ. **Wipe:** 1. Delete the Keystore key first. This is the crypto-shred: everything left on flash becomes unreadable. 2. Then delete the directory. ## 7. Relay API Base: `https:///api/tz`. There is no authentication: knowing the mailbox id *is* the capability. The server stores only `SHA256(mailbox)`. | Method | Path | Body / Response | |---|---|---| | POST | `/box/{mailbox}` | multipart `blob` (frame, ≤ 25 MiB) + `ttl` (s). → `201 {"id": "…"}`. Per mailbox: max 500 messages waiting | | GET | `/box/{mailbox}` | → `{"messages":[{"id","size","created"}]}`, oldest first | | GET | `/box/{mailbox}/{id}` | → the frame bytes | | DELETE | `/box/{mailbox}/{id}` | → `204`. The receiver deletes each message after storing it | - **Expiry:** the relay deletes messages when `ttl` (clamped to 60 s … 7 days) passes. - **Logs:** request logs are disabled. ## 8. Push - **Topic:** `topic(M) = "tz" ‖ hex(SHA256("tamiz-push-v1:" ‖ hex(M)))[0..32]`. - **Publishing:** after storing a message for mailbox `M`, the relay publishes the body `1` to `topic(M)` on its own ntfy server. The event carries no content. - **Subscribing:** the app keeps one stream `GET https:///push/,,…/json` open from a foreground service. On any event, and on every (re)connect, it lists and fetches its inbound mailboxes. - **Access:** anonymous clients may subscribe but not publish. ## 9. Expiry rules (enforced by each device) | Event | Action | |---|---| | `now ≥ stored_at + message_ttl_s` | Delete the message file | | `channel_expires_at ≠ 0` and `now ≥ channel_expires_at` | Wipe the channel (§6) | | Pad exhausted | Channel marked dead. The user can only wipe it | | User taps **Burn** | Send a `burn` message if the pad allows it, then wipe | | `burn` received from any member | Wipe | | Safety code mismatch or setup aborted | Wipe | Checks run when the app opens, every minute while a chat is open, when a push arrives, and from a periodic WorkManager job (15 min).