Skip to content

Latest commit

 

History

History
268 lines (196 loc) · 62.3 KB

messaging.md

File metadata and controls

268 lines (196 loc) · 62.3 KB

Messaging

Part of the protocol reference: store-and-forward messages and Messaging v2.

Store-and-forward messages

Status (2026-10-11): no longer experimental. The Settings > Experiments switch "Chats (beta)" (shipped default off until the phone test passes; DEBUG test_store_forward is the benchmark switch); "Send chats automatically" is a separate sub-switch. The wire is unchanged. With the switch off, no reader accepts hop type 11, no endpoint reads a dispatch record, and calls/streams are unchanged. v1 covers two directly adjacent phones only; the carrier role (DECISIONS Design A v2) exists in code but is hard-off. Design and reasoning: DECISIONS "Design A".

Keys and contact card

  • Each identity has a static P-256 mailbox key: Android Keystore ECDH (PURPOSE_AGREE_KEY) on API 31+, otherwise a software key whose PKCS#8 bytes are sealed by a non-exportable Keystore AES-256-GCM key. The same Keystore AES key encrypts opened messages and every metadata file (contacts, neighbour ledger, bundle meta, seen IDs) at rest under noBackupFilesDir/store-forward. If the recorded mailbox key can no longer be loaded (Keystore cleared, data restored elsewhere), a new key is created and Messages shows a "message key reset" notice until dismissed, because contacts must receive the new card.
  • Contact card BCC1: BCC1 | version 1 | identity SPKI length (2) | identity SPKI | compressed mailbox key (33) | created ms (8) | signature length (2) | ECDSA signature. The identity key signs BluetoothVideoMesh/StoreForward/Card/1 || everything before the signature. The node ID is SHA-256(identity SPKI)[0:16]. A card decodes only with a valid signature and an on-curve mailbox point. Text form BCC1: + unpadded base64url (about 300 characters, QR-ready; no QR renderer is bundled).
  • A card received over a link must match the link's authenticated node ID. It is stored PENDING until the user accepts it, and a pending card authorizes nothing: it is not in the tag table, its bundles are refused (not for me) and stay queued with the sender until acceptance. Ignoring a card removes it and anything held for it; unaccepted cards are dropped after 14 days. A different mailbox key for a known identity is ignored (key-change warnings are open work). Typed card text is accepted immediately. No two held cards (pending or accepted) may share a mailbox key: a link card reusing one is refused (MAILBOX_IN_USE), and a typed or accepted card removes pending cards that copied its key (accepting beside another accepted holder fails). Senders are resolved by the node ID inside the key slot, which must be an accepted card holding exactly that mailbox key. Cards carry no proof of possession of the mailbox key: ECDH cannot give a non-interactive proof and a Keystore PURPOSE_AGREE_KEY key cannot sign. The identity binding in the KDF (below) and the uniqueness rule leave a squatting card unable to open, forge or block anything.
  • Safety number: 20 digits (four groups of five) from SHA-256("BluetoothVideoMesh/StoreForward/SafetyNumber/1" || u16 SPKI length || identity SPKI || compressed mailbox key), shown for your own card and for each pending card so users compare them in person before accepting.
  • Pair secret Kp = HKDF-SHA256(ikm = ECDH(mailbox_A, mailbox_B), salt = SHA-256(sorted compressed mailbox keys), info = "BluetoothVideoMesh/StoreForward/Pair/2" || the two node IDs sorted). Binding the node IDs means the same mailbox key under another identity gives a different secret and different tags (no unknown-key share). Recipient tag = HMAC-SHA256(Kp, "tag" || utcDay u32)[0:8], where utcDay is the bundle's created hour / 24. Both members compute the same tag. It differs per pair and per creation day, but a bundle's tag and ID are fixed for that bundle's whole lifetime (up to 14 days), so an observer who sees one bundle again can follow it until it expires. Bundles created on different days carry unlinkable tags. Recipients precompute tags for every held card over the 14-day window.

Bundle header BSB1 (carrier-visible, immutable)

Field Bytes
Magic BSB1, version 2 (version 1 is rejected) 4+1
Created hour, expiry hour (UTC hours since epoch) 4+4
Priority class (0 text/receipt/location/card, 1 voice, 2 photo, 3 video) 1
Padded plaintext length 4
Chunk count (1-256) 2
Slot count (1-8) 1
Key slots 154 x slots
Chunk hash list: SHA-256(ciphertext chunk)[0:16] 16 x chunks

Bundle ID = SHA-256(header). Decoding is strict: the lifetime must be positive and at most 336 hours (QA4), the chunk count must equal ceil(padded / 8176), and the length must be exact. v1 writes one slot. No proof-of-work field exists in version 1.

Slot (154 bytes) = tag (8) | ephemeral key e, compressed (33) | part1 (49 + 16) | part2 (32 + 16). AAD = all fixed header fields and the hash list (everything except the slots, including the slot count) || slot index (1) || slot tag. salt = SHA-256("BluetoothVideoMesh/StoreForward/2" || e || R).

  • k1 = HKDF(ECDH(e, R), salt, ".../sender" || recipient node ID); part1 = AES-256-GCM(k1, nonce 0, sender compressed mailbox key || sender node ID, AAD).
  • k2 = HKDF(k1 || ECDH(S, R), salt, ".../content-key" || sender node ID || recipient node ID); part2 = AES-256-GCM(k2, nonce 0, 32-byte content key, AAD || part1).

The recipient learns and authenticates the sender (static-static ECDH), and the sender must be a held card whose node ID matches part1. Carriers learn nothing about the sender. There is no signature, so the recipient cannot prove authorship to a third party (deniable, QA7); like Noise X, a compromised recipient key can impersonate others to that recipient. Unknown senders are refused (contacts only, QA2).

Forward-secrecy limitation (open design decision). Both slot keys derive from the recipient's long-term mailbox key, so a later compromise of that key decrypts every retained or captured bundle addressed to it and reveals the senders. On API 31+ the key is non-exportable in Keystore: an attacker must keep using the phone. On API 29-30 it is a software key sealed by a Keystore AES key that has no user-authentication requirement, so a temporary app-process compromise can copy it for later offline decryption. Options, not yet chosen:

  • per-bundle forward secrecy from one-time prekeys published in the contact card or exchanged over a link (X3DH-like; adds key management and prekey exhaustion);
  • periodic mailbox-key rotation with card re-exchange;
  • setUserAuthenticationRequired (or unlocked-device-required) on the wrapping key, which stops background opening while the phone is locked.

On API 29-30 the unwrapped software private key also lives in the app's memory for as long as the process runs (it is decrypted once when Messages or an exchange first opens the store). Anyone who can read that process memory (a debugger on a compromised phone, a memory-dumping exploit) obtains it, and with it every past and future bundle until the key is reset. API 31+ keeps the private key inside Keystore.

Content BSM1

BSM1 | kind (1) | created ms (8) | conversation ID (16) | reply-to bundle ID (32, zero if none) | MIME length (1) | MIME | body length (4) | body. Kinds: text 1, voice 2, photo 3, video 4, location 5, receipt 6, contact card 7. A receipt's reply-to is the delivered bundle ID and its body the received UTC hour (QA3: 1:1 receipts on, hour resolution; no read receipts). The plaintext u32 length || BSM1 is zero-padded to Padmé size (minimum 1024 bytes, capped at 256 chunks), split into 8176-byte pieces, and each piece is sealed with AES-256-GCM under the per-bundle content key, nonce = chunk index, AAD = index u16 || count u16. Ciphertext chunks are 8 KiB except the last. Opening verifies every chunk hash, every tag and zero padding; any failure rejects the bundle.

Transfer BSF1 in hop type 11

Each BMS1 type-11 record carries BSF1 | message type (1) | body length (4) | body inside the unchanged authenticated, ordered link session.

Type Body Purpose
HELLO 1 version (1), free carrier quota (4; 0 = not a carrier), daily remaining from this neighbour (4), inventory digest (8) First message each way
SUMMARY 2 count (2), last (1), entries of bundle ID (32), tag (8), class (1), expiry hour (4), chunk count (2), chunks held (2) Up to 256 entries per message
WANT 3 bundle ID (32), need header (1), chunk count (2), needed-chunk bitmap Resume from bitmaps
HEADER 4 copy tokens (1, always 0 in v1), BSB1 header
CHUNK 5 bundle ID (32), index (2), ciphertext (at most 8 KiB) Verified against the hash list before storage
ACCEPT/REFUSE 6 bundle ID (32), reason: 0 sent (all chunks this phone can give for the WANT), 1 have, 2 quota, 3 expired, 4 rate, 5 too large, 6 invalid, 7 not for me, 8 unknown
DELIVERED_LOCAL 7 bundle ID (32) The recipient's phone tells its adjacent peer it holds the bundle. It is sent only to the contact that owns the bundle's tag: the authenticated peer must be that contact and the offered tag must equal the delivered bundle's own tag, so it is no recipient oracle. Acted on only for the receiver's own bundle addressed to that authenticated peer; ignored for carried bundles (QA5)
DONE 8 reason: 0 normal, 1 suspend, 2 timeout, 3 error, 4 rate limited 4: the responder refuses because this neighbour exchanged too recently or too often
CARD 9 BCC1 card Sent after HELLO

Differences from the original design draft (see decisions "Design A"): SUMMARY and CHUNK carry the full 32-byte bundle ID (an 8-byte prefix made WANT ambiguous and let a peer grind collisions); CARD 9 and reason 0 "sent" were added; the HELLO digest is recorded but does not yet skip a sync.

Exchange. A responder admits at most one exchange per adjacent identity per 60 s and 10 per hour; otherwise it answers DONE(rate limited) and closes. While an exchange holds the call endpoint, the call listener stays open (one extra LE CoC listen, counted by the registration ledger). An incoming connection then suspends the exchange within one chunk and is answered as an ordinary call. The initiator sends HELLO and CARD. A responder whose call endpoint has the experiment on reads the first record after the BMH2 link handshake with type 11 allowed for that read only: type 11 starts an exchange, and anything else goes unchanged into the endpoint handshake. After a peer's HELLO, each side sends SUMMARY. The bundles offered are its own queued bundles addressed to that authenticated peer. A peer's HELLO carrier quota is self-declared and widens the offer only when this phone itself runs the carrier role (off in v1). With carrying on, complete carried bundles not received from that peer are also offered. Ordering is by class, then size, then age. The receiver admits each entry hop-locally and WANTs it. The offerer answers in order with HEADER (if needed), the CHUNKs it holds and REPLY 0. A side sends DONE(normal) when every WANT is answered, and the exchange ends when both DONEs have crossed. At most 1,024 SUMMARY entries are offered or accepted per exchange. A repeated WANT for a bundle, or more unknown WANTs than that, is a protocol violation. Each WANT becomes one lazily read job, so outbound queues are bounded. Serving stops with ACCEPT/REFUSE 4 (rate) at 8 MB per exchange or 20 MB per neighbour per UTC day. A dedicated writer drains outbound work so that neither side blocks on a full radio buffer. A local suspend (live call, sf_suspend) is checked before every record: at most the chunk being written completes, then DONE(suspend) and the link closes. A separate watchdog enforces 10 s without progress and 120 s per exchange. If the writer is stuck in a write to a peer that stopped reading, the watchdog closes the link one second after a suspend request. A malformed or out-of-order message sends DONE(error) and closes. After more than four poisoned chunks the exchange is abandoned. Chunks write idempotently and bitmaps persist after each chunk, so any interruption resumes from the next missing chunk with this or another phone.

Durability. An own bundle's header and chunks are fsynced before its meta (the commit point) marks it complete. Received chunks are fsynced in batches of 16 (and when a bundle completes or an exchange ends) before the saved bitmap claims them, so a crash only re-requests the unsynced chunks. Each stored chunk is checked against the hash list before it is served. A damaged own bundle becomes FAILED (shown as failed, no longer offered) and the exchange continues with the next bundle. A bundle whose header cannot be read at startup is kept and listed as failed instead of being deleted silently. Deleting an outgoing message withdraws it. Neighbour ledger records idle for 30 days are pruned, and at most 512 are kept.

Admission and storage (hop-local). Per authenticated adjacent identity: 20 MB and 200 bundles per UTC day and 8 MB per exchange. Per sending contact: 50 bundles per day, charged only after the HEADER's key slot authenticates that contact as the owner of the bundle's tag. Before that, admission charges only the adjacent neighbour's allowance, so a stranger who knows a tag cannot spend a contact's allowance. A header whose slot does not authenticate the tag's owner is refused (6 invalid) before any chunk. Inbox 64 MB, counting opened incoming messages; own outgoing 32 MB. Opened messages (both directions) are kept encrypted at rest, up to 96 MB. Each keeps its bundle's expiry hour and is deleted at that hour. The list decrypts only small listing records; bodies are decrypted on demand. Carried: 64 MB, at most 25% per source neighbour and 10% per tag; eviction removes stalled partials (24 h), then class 3 > 2 > 1 > 0, oldest first, and never own or inbox data. Expiry uses the receiving phone's clock: stored_until = min(expiry, received + 14 d), with 2 h skew allowed only at acceptance and a created hour at most 2 h ahead. Opened, rejected, delivered and user-deleted IDs stay in a seen set until expiry + 2 h skew + 1 h, past the last hour acceptance allows, so a replay never reopens or resurrects a message or triggers another receipt. Receiving is crash-safe: the opened message is stored durably first. Only then is the ID tombstoned and marked delivered, and the ciphertext deleted. If storing fails (archive full or a write error) the ciphertext stays, delivery is not acknowledged, and opening is retried at the next exchange. Decrypted voice notes play from memory, and recording leftovers are deleted when Messages starts, pauses or closes.

What a link observer or peer learns. A radio observer sees a call-transport link with record sizes and timing. The adjacent peer learns the other's identity (already true of BMH2), mailbox card, the offered tags/classes/sizes/expiry and transfer timing. Contents, senders and recipients of carried bundles stay hidden. Discovery is unchanged: v1 uses the private-call availability advertisement, and a version-3 announcement is not implemented.

Messaging v2: conversations, attachments, receipts, forward secrecy (phases 1a, 1b and 1d built 2026-10-12; on whenever Messages is on since 2026-10-21; phases 1c, 2 to 3 not built)

Status: the wire, vault, receipts and accounting of phase 1a and the attachment pipeline of phase 1b (sniffing, safe open, re-encode, remux) are implemented behind the switch and the chat-session policy, contact rules and v1 migration of phase 1d are implemented as well, all covered by JVM tests; the conversation UI (1c), prekeys (2), ratchet sessions (2b) and carriers (3) are not. Switch test_messaging_v2 (no Settings row; on whenever Messages is on since the design decision of 2026-10-21, false is the DEBUG control arm; sub-switches in switches.md "Messaging v2"). Design and reasons: DECISIONS "Design M". It extends "Store-and-forward messages" above and changes none of BMH2/BMH3, BMS1, BME1, calls or streams. With the switch off, every byte on air and on the link is exactly as described in the section above (bundle version 2, BSM1, HELLO version 1). Compatibility with 0.2.0-beta is not a goal, but every rule below fails safe: a reader that does not understand something refuses it with a reason and stores nothing.

Versions. Phase 1 writes bundle header version 3 and content BSM2. Phase 2 writes bundle header version 4 when it seals to a prekey, and phase 2b version 5 for a ratchet session message. A v2 reader accepts header versions 2 (legacy, read-only content BSM1), 3, 4 and 5; it never writes version 2. Per contact a minimum security version is pinned: once a contact has sent a bundle or a prekey batch of version 4 or higher, anything lower to or from that contact is refused invalid (shown in the thread, not silent); versions 2 and 3 are read only on a direct link from a contact whose pin is still below 4, and are never carried or re-offered to a third phone (phase 3 admission accepts versions 4 and 5 only). A reader that gets any other version, or a kind it does not know, answers REFUSE invalid (6) for the bundle, counts it, and keeps the link.

Bundle header version 3

Identical to version 2 ("Bundle header BSB1") except:

  • Priority class 4 (BULK) is valid. Classes 0 to 3 are unchanged. A decoder rejects class above 4.
  • A class 4 bundle has no key slot: where version 2 has slot count and the slots, a class 4 header has slot count 0 followed by the 8-byte pair tag (the same daily tag as for any bundle to that contact). Its chunks are sealed under a segment key derived from the file key in the manifest (below), so one forward-secret seal (the manifest) covers the whole file. A class 4 bundle has 1 to 256 chunks, is never listed in SUMMARY and is never accepted by a carrier (phase 3, below). Classes 0 to 3 have exactly one key slot in versions 3, 4 and 5 and a decoder rejects any other slot count: the 8-slot capacity of version 2 is not used until groups are designed (DECISIONS M13), because a slot authenticates the fixed header fields and the hash list but not the other slots while the bundle ID hashes all of them.
  • A class 4 bundle's padded length need not be a Padmé size (a full segment is exactly 128 chunks); the chunk count must still equal ceil(padded / 8176).
  • Nothing else changes: chunks, hash list, slot layout for classes 0 to 3, tag and bundle ID are as in version 2.

Content BSM2

BSM2 | kind (1) | flags (1) | created ms (8) | message ID (16, random) | conversation ID (16) | reply-to message ID (16, zero if none)
     | disappear seconds (4, zero = none) | body length (4) | body

The plaintext u32 length || BSM2 is padded (Padmé, minimum 1024 bytes), chunked and sealed exactly as for BSM1, except that a SEGMENT bundle is not padded when its body is a full segment (it is exactly 128 chunks). The conversation ID must equal SHA-256(sorted hex node IDs)[0:16] of this phone and the authenticated sender of the slot (as v1 computes it); any other value is refused invalid, so a contact cannot file a message into another contact's thread, and reply-to is honoured only for a message of the same thread. The message ID is the logical identity of the message: the receiver deduplicates on (authenticated sender, message ID) as well as on the bundle ID, with the bundle's tombstone lifetime, so a re-sealed replacement of an already opened message is dropped; the sender keeps every bundle ID it ever sealed for a message and maps receipts for any of them to it. created ms must lie inside the created hour written in the bundle header (the header hour is itself checked against the receiver's clock with the 2-hour skew allowance); otherwise invalid. The receiver displays the time clamped to [arrival - 14 days, arrival].

flags: bit 0 WANT_READ_RECEIPT (the sender has read receipts on); bits 1 to 7 reserved, a reader ignores a message with a reserved bit set (fail safe).

Kind Value Body
TEXT 1 UTF-8, 1 to 8000 bytes (the 70-byte header and the 4-byte length make at most 8074 bytes before padding; Padmé rounds that up to 8192, so a text near the maximum seals to two chunks), NFC not required; a reader replaces invalid sequences for display only
LOCATION 5 as v1, not used by the v2 UI
RECEIPT 6 status (1) 1 delivered, 2 read, 3 downloaded (every segment of the named file received and verified), 4 key gone (phase 2: the recipient could not open the bundle because its key was already deleted; the sender may re-seal under the same message ID); count (1) 1 to 64; received hour (4); bundle ID (32) x count. A receipt is class 0, never archived, never answered, and is sealed to the rotating prekey in phase 2
ATTACHMENT 8 manifest, below
SEGMENT 9 `file ID (16)
PREKEYS 10 phase 2, below

Kinds 2, 3, 4 and 7 exist only in legacy BSM1. A reader of BSM2 rejects them.

ATTACHMENT body.

file ID (16, random) | presentation (1) | total size (8) | segment count (1) | SHA-256 of the whole file (32)
| file key (32, random; zero and unused when inline) | name length (1) | name | MIME length (1) | MIME
| width (2) | height (2) | duration ms (4) | thumbnail length (2) | thumbnail
| then: inline data (segment count 0)  or  count x ( segment bundle ID (32) | segment chunk count (2) )
  • presentation: 0 file, 1 picture, 2 voice note, 3 video.
  • total size 1 to 64,000,000. Segment count 0 means inline and requires total size at most 1,000,000 and the data to follow (exactly total size bytes, nothing after). Otherwise segment count is ceil(total / 1,044,480) (1 to 62) and one entry per segment follows; a full segment has chunk count 128 and the last has the chunk count of its Padmé-padded length (the padded length of u32 length || BSM2 header (70) || file ID (16) || index (2) || data); a segment entry whose chunk count does not follow from the size rejects the manifest. A repeated file ID from the same sender with different segment IDs is refused.
  • name: 0 to 96 UTF-8 bytes. A reader normalises it to NFC and removes /, \, NUL, other control characters (Cc), format characters including the bidirectional embeddings, overrides and isolates U+202A to U+202E and U+2066 to U+2069 and zero-width characters (Cf), and a leading dot; an empty result is displayed as "file". The name is never used as a path: files are stored under their random ID.
  • MIME: 0 to 100 ASCII bytes matching [a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*; otherwise it is treated as application/octet-stream. It is the sender's claim: a picture is drawn only if the magic bytes match a decodable picture type and the decoded size is at most 40 megapixels; audio and video presentations are played only through the platform players after the same check.
  • thumbnail: 0 to 16384 bytes, a JPEG (no other format) with a valid header of at most 320 x 320 px. It is not decoded automatically: a received message shows a generic icon, the name and the size, and a picture is decoded only after a tap, down-sampled to display size and in an isolated process where the platform guarantees one (finding 10). A reader decodes the thumbnail by itself only when test_mv2_auto_thumbnails is on, which is allowed only after isolated, resource-limited and time-limited decoding is proven on every reference phone. Any thumbnail that fails a check is ignored.
  • width, height, duration: hints for layout, bounded to 65535, 65535 and 86,400,000; ignored if inconsistent with the decoded media.
  • The segment IDs are bundle IDs (SHA-256 of each segment's class 4 header). The manifest bundle is sealed like any bundle (one slot, sender authenticated, forward secret in phase 2); segments need no slot.
  • Segment key = HKDF-SHA256(file key, salt = file ID, info = "BluetoothVideoMesh/StoreForward/Segment/1" || index (2)). Each chunk of a segment bundle is AES-256-GCM under the segment key with nonce = chunk index and AAD = index u16 || count u16 as for any bundle. A segment is therefore readable only by someone who opened the manifest. The receiver keeps the file key in a separate transfer record (not in the archived manifest) until the file is complete and verified, abandoned, withdrawn or expired, then erases it; the sender erases its copy when downloaded (receipt status 3) arrives or the message is withdrawn, so a later archive compromise cannot decrypt captured segments (finding 14).

SEGMENT body. file ID must equal the ID of a manifest the receiver holds and accepted; index is the position in that manifest; data is exactly 1,044,480 bytes, except the last segment, which is total - index x 1,044,480 bytes. (The plaintext is u32 length || BSM2 header (70) || file ID (16) || index (2) || data, which for a full segment is 1,044,572 bytes = 128 chunks of 8176, not padded.) The receiver checks that the opened segment's bundle ID is the one the manifest lists for that index. After the last segment the receiver checks the whole-file SHA-256; a mismatch deletes the file, marks it "Damaged" and counts a violation against the sender.

File vault

Received and sent file bytes, inline attachments included, are stored in the app's private no-backup directory, one file per local random ID (never the sender's file ID), outside the sealed archive, which keeps only the manifest without inline data. Each file has a random 256-bit vault key held in its sealed meta record. The bytes are 4096-byte blocks, each AES-256-GCM sealed with nonce = 96-bit block index and AAD = local ID || block index (4) || final-block flag (1); the final block is shorter and carries flag 1, so a truncated file fails on open. Segment i starts at block i x 255 (1,044,480 = 255 x 4096), so segments can be written in any order. An open for viewing is served by a ContentProvider (not exported, one-time per-URI read grants revoked when the thread is left or after 5 minutes, at most 4 open descriptors, block cache wiped on release) that decrypts only the requested blocks through StorageManager.openProxyFileDescriptor; no plaintext copy is created in the app's storage. The type reported to the other app is the app's own sniffed type, never the sender's claim. Sealed bytes are immutable (finding 11; AES-GCM with an index-derived nonce is catastrophic if one nonce is used twice with different plaintext under one key): a vault block is written once; a retry after a crash rewrites it only with identical bytes (compared) or allocates a new vault file with a fresh key; the final-block flag of a file is fixed when the file is created from its declared size; sealed bundles and chunks are retransmitted verbatim; a bundle that must change is a new bundle with a fresh ephemeral key and content key, and a segment that must change means a new file key, a new manifest and a new file ID. The vault is excluded from backup and deleted with the emergency wipe, with its keys.

BSF1 version 2

BSF1 messages are carried in BMS1 hop type 11 as before. The changes (all under the master switch):

  • HELLO version 2: version (1) = 2 | free carrier quota (4) | daily remaining (4) | digest (8) | flags (1) | prekeys held (1). flags: bit 0 CHAT (can keep the exchange open), bit 1 FILES (understands class 4 and unlisted WANT), bit 2 PREKEYS (phase 2), others zero. prekeys held is the number of unused one-time prekeys from the peer that this phone holds (phase 2; 0 before). A v1 reader rejects the 19-byte body and answers DONE(error); a HELLO version 2 sender that receives DONE(error) as its first answer marks that peer "legacy" for an hour and, within that hour, dials it with HELLO version 1, a plain v1 exchange that never offers version 3 bundles. A v2 phone that receives a HELLO version 1 answers with HELLO version 1 and runs a plain v1 exchange (flags are absent, nothing of version 2 is used), so a v1 phone can dial it. A 17-byte HELLO that claims version 2, a 19-byte one that claims version 1, and flag bits other than the three defined are malformed.
  • SUMMARY never lists class 4. In a chat session (both HELLO CHAT bits) either side may send further SUMMARY messages (with last = 1) at any time until DONE; a bundle already offered in this exchange is not repeated; the 1,024-entry limit counts bundles offered in any window of 30 minutes. A SUMMARY that arrives after the receiver sent DONE(IDLE) is ignored.
  • WANT is honoured for a bundle that is not in the SUMMARY if and only if the offerer holds it as an own bundle (role OWN) addressed to the requesting authenticated peer. A WANT for any other unlisted bundle counts as an unknown WANT (the existing limit applies). A WANT for a segment is sent only for IDs listed by a manifest the receiver accepted, with an admission entry synthesized from the manifest (class 4, the pair tag, the manifest bundle's expiry hour, the manifest's chunk count). A second WANT for a bundle is a protocol violation only while the first job for it is still running; after the job finished or after CANCEL it is allowed.
  • CANCEL (11): bundle ID (32). The receiver tells the sender to stop serving that bundle; the sender abandons the job before the next chunk and answers nothing. Chunks for that bundle that arrive within 5 seconds after the CANCEL are dropped without counting as poisoned or duplicate.
  • PING (12), empty body, sent when nothing else was sent for 20 seconds in a chat session. Two deadlines are independent (finding 19): connection liveness (any valid record resets it, a PING counts; 60 seconds without a record ends the exchange with DONE(timeout)) and transfer progress (while a job is running, a chunk must move every 10 seconds whatever else arrives; 120 seconds in total for a non-chat exchange). A PING more often than once per 10 seconds is a protocol violation.
  • DONE reason 5 IDLE: the thread was left for 2 minutes, or 30 minutes passed; the link closes cleanly and a new exchange opens if still needed.
  • REFUSE reason 9 PREKEY_GONE (phase 2): the slot named a prekey that this phone no longer holds. The sender re-seals the message (same message ID, fresh keys) only if the refusal arrives from the authenticated adjacent recipient of that bundle, or as an end-to-end receipt status 4; a refusal from any other phone is ignored.
  • Link state machine (changes to the v1 reader and writer). In a chat session DONE is sent only at the end of the session; the watchdog is 60 seconds (otherwise 10 s without a record, 10 s without a chunk while a transfer runs, and a 120 s total that is extended while chunks keep flowing, at least 4 within the last 10 s, and for 10 s after the last chunk so a sender with nothing left to send waits for the peer's completion, up to 15 minutes; DEBUG test_mv2_progress_total=false restores the fixed 120 s); the tag table is rebuilt when a card is accepted during the session; the responder reads HELLO before it applies the cooldown; if both phones initiate at once the phone with the lower node ID keeps the initiator role and the other closes its attempt.
  • Service order. The writer serves queued work smallest class first and, within a class, smallest remaining size first, and re-evaluates at every chunk: a record of class 0 to 2 written while a class 4 segment is streaming is sent before the segment's next chunk. A local suspend is still checked before every record.
  • Admission tiers. For an authenticated adjacent identity that is an accepted contact: 128 MiB per exchange and 512 MiB per UTC day in each direction, 200 messages (non-bulk, non-receipt bundles) per day and 1,000 receipts per day, a bulk byte counts only toward the byte limits. Other adjacent identities keep the v1 limits (8 MB per exchange, 20 MB per day, 200 bundles per day). The existing responder cooldown (60 s, 10 per hour) is relaxed to 5 s and 60 per hour only for an accepted contact whose HELLO carries the CHAT bit. Per-sender admission of 50 bundles per day becomes 200 messages per day for contacts; segments are charged only after the manifest that names them was accepted and only for the bytes the receiver asked for.
  • Physical accounting and budgets. Before a WANT is sent or a HEADER accepted the receiver reserves the physical bytes the transfer can occupy (staged ciphertext of at most 2.1 MB per segment, vault bytes, metadata rows, tombstone), summed over everything in flight, and refuses quota if it does not fit or if free device storage would drop below 200 MB; segments are imported into the vault and deleted one at a time. Global caps apply on top of the per-neighbour limits: all non-contact neighbours together 64 MB per day and 16 MB per hour, all contacts together 1 GiB per day, at most 8 bundles in flight, at most 20,000 seen/tombstone entries, 2,000 bundle records and 2,000 archive rows. Control records are limited to 20 per second and 2,000 per 10 minutes per link, 60 CANCEL per minute, 30 WANT for the same bundle per hour, and 30 asymmetric operations per exchange from a non-contact peer; exceeding a budget ends the exchange with DONE(rate limited).
  • Own queue. 100 waiting messages and 512 MiB of own bundles per contact (1 GiB overall); a segment's sealed copy is deleted when the receiver reports downloaded (receipt status 3) or the message is withdrawn. The receiver refuses inline attachments with REFUSE quota (2) when that contact's 25% share of the vault is full.

Receipts and states

  • A receipt status 1 (delivered) is created after the message is stored durably and opened; on a direct link the sender also learns delivery from DELIVERED_LOCAL and the receipt is then not sent. A receipt status 2 (read) is created only when the receiver's read-receipt setting is on and the message had WANT_READ_RECEIPT; it is created when the message was shown in the open thread. In a chat session receipts are sent immediately; otherwise they are batched (up to 64 IDs, flushed after 30 seconds or when the link is about to close), and on a carried path (phase 3) delayed by a uniform random 0 to 60 seconds. A receipt status 3 is created when the last segment is verified; the sender deletes its sealed segments only then.
  • Delivered, downloaded and read are independent facts (finding 18): the sender keeps one flag per message for each, set at most once; receipts are deduplicated by (bundle ID, status), so an earlier delivered receipt never suppresses a later read or downloaded one. A receipt for an unknown bundle ID is ignored. A flag is never cleared, a receipt that arrives after a message was marked not sent still sets its flag, and the thread shows the highest applicable state.
  • A receipt is valid only from the authenticated contact the bundle was addressed to (the slot's authenticated sender).
  • Own-message state shown to the user: waiting, sending, delivered, read, not sent; from phase 3 also handed on. "Handed on" is set only by a successful hand-over to a carrier and is never shown as delivered.

Disappearing messages

disappear seconds (0 to 31,536,000) is the sender's timer for this message. On the sending phone the countdown starts at the first delivery acknowledgement; on the receiving phone it starts when the message is read. At zero both sides delete the message, its files and its listing record. A receiver raises a timer below 1 hour to 1 hour and shows a notice with a Keep action per message. A sender that changes the conversation's timer applies it to new messages only.

Prekeys (phase 2)

Header version 4. Slot (158 bytes): tag (8) | ephemeral key e (33) | masked prekey ID (4) | part1 (49 + 16) | part2 (32 + 16). The wire value is ID xor HMAC-SHA256(Kp, "pkid" || e)[0:4] (Kp the pair secret), so a carrier sees neither the kind of key (bit 31 marks a rotating prekey in the real ID) nor its serial number, and the same key is not recognisable across bundles or days; the recipient unmasks with the pair secret of the contact that the slot's tag names. Prekey IDs are per audience: the lookup key is (contact, ID).

  • salt = SHA-256("BluetoothVideoMesh/StoreForward/3" || e || P || prekey ID) with P the compressed prekey public key.
  • k1 = HKDF(ECDH(e, P), salt, ".../sender" || recipient node ID); part1 as in version 2.
  • k2 = HKDF(k1 || ECDH(S, R), salt, ".../content-key" || sender node ID || recipient node ID); part2 as in version 2. S and R are the static mailbox keys, which authenticate the sender and keep the exchange deniable; they no longer protect confidentiality alone.
  • Slot AAD adds the masked prekey ID. The recipient looks up the private prekey by (contact, ID). Consumption is one recoverable transaction (finding 4): (1) append a journal record "open-intent: bundle ID, message ID, key ID"; (2) store the message, its message ID and the bundle tombstone; (3) delete the one-time key; (4) mark the record done; the delivery is acknowledged only after step 4, and a restart replays unfinished records forward. A one-time key is claimed by the first bundle that opens with it; a second bundle naming a claimed key is refused as a replay even if the key still exists. A rotating prekey stays until its grace ends.

Prekey batch BPK1 (carried in BSF1 message PREKEYS (10) or in a PREKEYS bundle, always sealed to one contact):

BPK1 | version 1 | generation (4) | created ms (8) | rotating prekey: ID (4), key (33), use-until ms (8)
     | count (1, 0..32) | count x (ID (4), key (33), use-until ms (8)) | signature length (2) | ECDSA signature

The identity key signs BluetoothVideoMesh/StoreForward/PreKeys/1 || issuer node ID || audience node ID || everything before the signature. A batch is accepted only from the contact it names as issuer, for this phone as audience, with generation at least the stored one; it replaces the unused one-time keys held for that contact when generation is higher and otherwise (equal generation) is idempotent. Key IDs are serial numbers that the issuer never reuses (a persisted counter); the receiving phone persists per issuer the set of IDs it has used (as ranges) before using a key, and refuses a batch entry whose ID is in that set or is already bound to a different key, so a replayed signed batch cannot resurrect a consumed key, also across crashes (finding 2). Each batch is per contact, so a contact exhausting or leaking keys affects only itself and a prekey cannot link two contacts.

  • Lifecycle and budgets. Every key carries a use-until time of issue + 21 days; the sender never seals to a key past it (and never to one within 1 hour of it, for clock skew), and the recipient deletes the private half 15 days after use-until (more than the 14-day maximum bundle lifetime, the 2-hour skew and a day of margin), whether or not it was used. The issuer keeps 20 one-time prekeys per contact and tops up in a live exchange in which prekeys held from the peer is below 8; the cap is 40 unused one-time keys per contact and 400 overall, counted from the issuer's own records (the peer's claim only schedules a top-up and never raises a cap); at most 2 batches per hour per contact and 12 per hour overall, including the immediate batch after PREKEY_GONE; a slot that fails to open counts double against the sender's and the supplying neighbour's allowance, and more than 20 failed opens per hour from one neighbour end the exchange (finding 9). A new rotating prekey is issued every 7 days; up to three are usable at once.
  • Sender. Use the lowest unused one-time ID held for that contact that is inside its use-until and mark it used locally at seal time (never reuse); with none left use the newest rotating prekey inside its use-until; with none at all (for example a contact not met for 21 days) the message is PREPARING ("Waiting to exchange keys") and is sealed in the first exchange that delivers a batch. Manifests, text and prekey messages use a one-time key where one is left; receipts always use the rotating prekey (they reveal only an hour and must not exhaust the one-time keys). Segments never use a prekey. There is no fallback to a slot sealed to the static key. A re-seal after PREKEY_GONE or status 4 reuses the message ID, so the recipient drops it if the first bundle was in fact opened (finding 3).
  • Storage. Every prekey gets its own Keystore wrapping key on every API level (API 31 and later: the prekey itself is a Keystore EC key, PURPOSE_AGREE_KEY), deleted when the key is used or expires; sub-batches of several keys under one wrapper are not used, because a rewritten file stays decryptable from older copies while its wrapper survives (finding 13). The number of Keystore aliases is capped at 200 overall; contacts beyond the cap are issued rotating prekeys only until aliases free up. Alias creation time and limits are measured on the required phones in a spike that blocks phase 2. Erasure on flash is best effort and the user text says so. The emergency wipe enumerates and deletes every alias and file.
  • What this protects. k2 needs ECDH(S, R), so a prekey alone opens only the sender-identity part. After a one-time prekey is deleted, a copy of the bundle (held by a carrier or in the sender's outbox) cannot be opened by someone who later obtains the recipient's static key, and the sender field stays sealed; the sender's ephemeral key is discarded at once. A rotating prekey gives the same after its deletion. It does not protect a message while its prekey is still held, a phone that is compromised while running or unlocked, the archive, or the addressing metadata: the tag derives from the static pair secret Kp, so someone who later obtains the static keys and holds captured bundles can match their tags to the pair and the day (finding 5; DECISIONS M9, Q-M11). Key compromise impersonation toward the recipient remains (as in Noise X), and a BPK1 signature is a non-repudiable statement that the issuer gave these keys to this contact (a small deniability cost); the signature only prevents prekey substitution.

Sessions: Double Ratchet over bundles (phase 2b, recommended forward-secrecy path)

Status: outline that fixes the shape and the rules; the exact byte layouts and the vectors (generated by a reference script and cross-checked against a second implementation, because no published P-256 vectors exist) are a blocking deliverable before phase 2b is coded. Switch test_mv2_ratchet (with it off, phase 2 uses a prekey seal per message). It follows the public X3DH and Double Ratchet specifications (header-encryption variant), with P-256 for every Diffie-Hellman, HKDF-SHA256, and AES-256-GCM with a unique key per message, and no copied code.

  • Bootstrap. The first sender to a contact uses that contact's BPK1: the newest rotating prekey SPK and, where one is left, a one-time prekey OPK. SK = HKDF(DH(S, SPK) || DH(E, R) || DH(E, SPK) || DH(E, OPK)) with S and R the static mailbox keys (these authenticate both sides and keep the exchange deniable) and E a fresh ephemeral key; the DH(E, OPK) term is omitted when no one-time key is left. The responder deletes OPK when it commits the session (the journal rules above). The initial message carries an init block (E, the masked key IDs) inside its slot; every later message until the first reply is received carries it again, so the first message may arrive late or out of order.
  • Slot version 5. tag (8) | flags (1) | [init block: E (33) | masked OPK ID (4) | SPK ID (4), only while the session is not acknowledged] | encrypted header (nonce 12 + ratchet public key 33 + previous chain length 4 + message number 4 + tag 16 = 69) | wrapped content key (32 + 16). The content key is wrapped under the ratchet message key; the bundle header, hash list, chunks and tag are as before. A carrier sees only the tag. The receiver trial-decrypts the header with the session's current, next and previous header keys (at most 3 per session, at most 2 sessions per contact), so the ratchet public key is never visible.
  • State. Per contact: root key, sending and receiving chain keys and header keys, our ratchet key pair, their ratchet public key, counters Ns, Nr, PN, and skipped message keys. The state, the opened message, its message ID and the bundle tombstone commit as one journalled unit (the same journal as the prekey consumption); an acknowledgement is sent only after the commit. The state is sealed at rest, excluded from backup and deleted by the emergency wipe.
  • Out of order and loss. MAX_SKIP is 256 per contact. A skipped message key is stored with a purge time of the bundle's expiry hour + 3 hours (the tombstone horizon) and deleted then; nothing outside that window is kept. A header that asks to skip more than MAX_SKIP is refused and counted. Duplicates are dropped by bundle ID and message ID, and a used key no longer exists.
  • Simultaneous start. If both phones start a session at once, both sessions stay for at most 7 days; both phones send on the session whose initiator has the lower node ID as soon as they have seen it, and the other is retired after its skipped keys expire.
  • Reset. If the state fails its own check, or the contact reports "cannot open", the thread shows "Secure session reset" and the next message re-bootstraps from fresh prekeys; the user is told, and a reset never silently drops queued messages (they are re-sealed under the same message IDs).
  • Not provided. Metadata forward secrecy (the tag derives from Kp), multi-device, and healing for a sender who never receives a reply (the DH ratchet advances only on replies; the message keys still advance and are deleted per message).

Carriers and bulk (phase 3 rules on top of Design A)

  • Two admission paths (finding 7). Recipient path: v1's, a header is accepted for chunks only after its slot authenticates a contact as the owner of the tag. Carrier path (an opaque carrier holds no key): strict header decode, versions 4 and 5 only, class 0 to 3, 1 to 256 chunks, every chunk checked against the hash list, expiry inside the allowed window, at most 10% of the carrier quota per tag and 25% per supplying neighbour, charged to the authenticated supplying neighbour's allowances and to the global non-contact caps; never charged to a contact's sender allowance. Class 4 is refused (REFUSE too large (5)), versions 2 and 3 are refused (REFUSE invalid (6)).
  • Carrier eviction order: expired, stalled partials, then by class 3 > 2 > 1 > 0, then by creation hour ascending and bundle ID ascending, never by local receive time, so two carriers that exchange converge on the same set.
  • Seen set: expiry + 3 hours, as in v1.
  • Copy tokens (hop-local metadata, never in the bundle; finding 16): 6 for classes 0 to 2, 2 for class 3. A hand-over is a journalled two-step transaction: the giver records "handing n tokens", the taker answers ACCEPT with the count it stored, the giver commits; a crash between leaves the giver holding the tokens (a duplicate budget is possible, a lost one is not). A carrier hands over half (rounded down) to another carrier; the last token goes to a phone that asks for the bundle's tag. A carrier cannot verify that phone is the recipient, so the copy limits are cooperative and are not claimed to hold against a malicious carrier.
  • Digest (finding 15): HELLO carries the first 8 bytes of SHA-256 over the sorted list of (bundle ID, SHA-256 of the chunks-held bitmap) of everything held; equal digests may skip the SUMMARY but never replace it: a full SUMMARY is exchanged at least every 6 hours per neighbour. XOR of IDs is not used (manipulable and blind to partial chunks).
  • Optional per-message tags (test_mv2_message_tags, Experimental; blocking before promotion; finding 17): Kdir = HKDF(Kp, "tag-dir" || sender node ID || recipient node ID), tag n = HMAC(Kdir, counter n)[0:8]; the sender persists its counter; the receiver precomputes the next 32 and keeps skipped tags for up to 256; on every direct meeting HELLO carries each side's next counter so a stranded window re-aligns over the authenticated link; the daily tag stays valid in parallel.

Implementation notes (phase 1a)

Where the code made a rule exact, or differs from the first wording above (the design is otherwise followed):

  • The BSM2 header is 70 bytes (BSM2 4, kind 1, flags 1, created 8, message ID 16, conversation ID 16, reply-to 16, disappear 4, body length 4), not 86. A full segment is 4 + 70 + 18 + 1,044,480 = 1,044,572 bytes in 128 chunks. Padmé rounding applies to everything else, so a text of 8000 bytes seals to two chunks.
  • The 20 control records per second are a sustained rate with a burst allowance of 256 (one full SUMMARY answered by one WANT each is ordinary traffic); the hard limit is 2,000 in any 10 minutes. CANCEL is limited to 60 per minute and WANT for one bundle to 30 per hour, as specified.
  • At most 8 bundles are in flight: a receiver asks for the first 8 entries of a SUMMARY and requests the others, in the same order, as slots free up in the same exchange. Reservations (twice the bundle's maximum ciphertext: the staged bytes and the vault bytes they become) are released when the bundle is stored, refused or cancelled, and when the exchange ends.
  • Connection liveness in a non-chat v2 exchange is the v1 rule (10 seconds without a record in either direction); in a chat session it counts only inbound records (60 seconds), so a phone cannot keep a dead peer alive with its own PINGs.
  • A receipt bundle is pre-charged like a message and refunded at open: it then counts against the contact's 1,000 receipts per day, not the 200 messages.
  • A failed or contradictory segment (authentication failure, wrong file ID, position or length) marks the download "Damaged" at once and erases the file key; a damaged file cannot be retried because its segment bundles are tombstoned, so the sender must send the file again.
  • The sender seals every segment and the manifest with one timestamp, so they share their created and expiry hours; a receiver's synthesized admission entry for a segment therefore equals the segment header's expiry exactly.
  • A download the user cancelled is asked for again within the same exchange when it is approved again (the earlier CANCEL released the serve); any other segment WANT is not repeated within an exchange.
  • Receipt status 4 (key gone), REFUSE reason 9 and the PREKEYS kind are decoded and refused or ignored with a reason; nothing in phase 1a emits them.

Implementation notes (phase 1b: attachment pipeline)

Nothing on the wire changes in 1b: the pipeline produces the file, name, MIME, presentation, hints and thumbnail that ATTACHMENT already carries, and decides what the receiver does with the bytes. The rules the code makes exact:

  • Sniffing. The receiver reads at most 8 KiB from the start and 64 KiB from the end of the vault file and classifies it by structure, not by a bare magic number (a PNG needs IHDR first, a JPEG a valid marker after FFD8FF, an ftyp box a plausible size, an MPEG audio file two consecutive valid frame headers, an MZ file a PE header where the DOS header points). #!AMR is audio, any other #! is a script. Text is valid UTF-8 without NUL or control characters other than tab, line and form feed and carriage return; text that starts with #! is a script, text that starts with <!doctype, <html, <?xml, <svg, <script and similar is markup (Save only). The sniffed type, never the claim, is the type handed to another app; a voice note in an MP4 container is handed over as audio/mp4.
  • Polyglots. A picture, audio, video, PDF or text file that also has a ZIP end-of-central-directory record in its last 64 KiB whose central directory really starts where the record says (an archive appended to a picture, a program inside a PDF) keeps its sniffed type but is not opened and not decoded: it is Save only with the strong warning. A file of unknown type with such a record is reported as a ZIP.
  • Open. Only sniffed pictures, audio, video, PDF and plain text, and only when the file is complete and not a polyglot. Everything else is Save only; installable and executable types (APK, DEX, JAR, class, ELF, PE, Mach-O, shortcuts, scripts) and polyglots carry the strong warning, other types (archives, markup, Office documents, unknown) the plain one. The Save dialog suggests a name whose extension matches the bytes (holiday.jpg that is an ELF file is offered as holiday.jpg.so; a label that claims a known type for a file of unknown type gets .bin).
  • Grants. The provider authority is <application id>.v2open, not exported, grantUriPermissions on. A grant is an 18-byte random token in content://<authority>/f/<token>/<name>; it lives until revoked (leaving the thread, the emergency wipe) or five minutes, at most 16 grants exist (the oldest gives way), at most 4 descriptors are open for the whole app, and the provider checks the grant on every query, open and read, so revoking stops an app that still holds a platform permission or an open descriptor (reads then fail with EIO). Only mode r. Bytes come from StorageManager.openProxyFileDescriptor backed by FileVault.readBlock with one plaintext block cached and wiped on release. The grant is per Open tap, not per byte: the other app may read it more than once within the lifetime above.
  • Pictures. Nothing is decoded without a tap. On a tap, the sniffed header size is checked first (40 megapixels, from IHDR, VP8/VP8L/VP8X, the GIF screen descriptor, the BMP header, the largest HEIF ispe, or the JPEG SOF), then the picture is decoded with inSampleSize chosen from the display edge (1280 px) and scaled to it, so the allocation depends on the display and not on the header. The decode runs in MessagingV2ImageService (android:isolatedProcess="true", not exported, no permissions, killed on unbind or after 10 s); the bytes reach it as a proxy descriptor. If the service cannot be bound, the same bounds apply in the app process (test_mv2_decode_isolated false forces that control arm).
  • Photos on send. Default: orientation applied (mirror, then clockwise rotation, per EXIF 2.32), transparency flattened on white, longest edge test_mv2_photo_edge (1600), JPEG at test_mv2_photo_quality (80), no metadata of the source survives; sources beyond 150 megapixels are refused. Thumbnail: JPEG, longest edge 320 (240 or 160 if it does not fit), quality 60/45/30, at most 16,384 bytes, otherwise none. GIF is sent unchanged (the animation is kept) with the hidden-data note.
  • Original. A JPEG goes through a streaming segment filter: every APPn and COM segment is dropped except the 12-byte Adobe APP14 (a colour-transform flag some CMYK pictures need; ICC profiles are dropped unless requested), everything after the first end-of-image marker is dropped (motion-photo videos, second pictures, appended archives), and when the picture had an EXIF orientation other than 1 a 36-byte Exif APP1 holding only that tag follows SOI. A JPEG chosen as a file gets the same filter. A JPEG whose structure cannot be followed (truncated, bad lengths, more than 20,000 segments) is re-encoded instead and the sheet says so. Other picture types are sent unchanged with the hidden-data note.
  • Video. MediaExtractor and MediaMuxer copy the audio and video tracks into a new MP4 (or WebM for a WebM source), copy the rotation as an orientation hint, never call setLocation, and skip every other track. The output is then scanned: every udta, meta, uuid, ilst, keys and location, device or title atom under the top level, moov and trak is renamed to free with its payload zeroed (sizes unchanged), and a second scan must find nothing. If the platform cannot remux, or the scan cannot parse the file, the video is sent as it is with a warning that it may carry its location. A video larger than 64 MB (plus 2%) is refused before remuxing; there is no compression (Q-M10).
  • Temporary files. A remuxed or filtered file of an outgoing attachment is written to cache/mv2-out/ under a random name, read by sendFile, overwritten with zeros and deleted when the prepared attachment is closed; files older than an hour are swept at the next send. No plaintext file is created for a received file.
  • Switches. test_mv2_autodownload_mb (0 to 64, default 8) sets autoDownloadMaxBytes; test_mv2_max_file_mb (1 to 64) is checked before any sealing; test_mv2_photo_edge (320 to 4096), test_mv2_photo_quality (30 to 95), test_mv2_decode_isolated, test_mv2_auto_thumbnails (read, no code consumes it). A non-benchmark launch uses the defaults.

Implementation notes (phase 1d)

The link rules above were coded in phase 1a; phase 1d adds the policy around them and the contact rules (switch test_mv2_chat_session, on inside test_messaging_v2; false is the control arm). Where the code made a rule exact:

  • Responder cooldown after HELLO. A v2 responder reads the HELLO first, then applies the cooldown: an accepted contact whose HELLO carries CHAT gets 5 s and 60 per hour, everyone else (strangers, contacts without the flag, v1 HELLOs) the v1 60 s and 10 per hour. A v1 responder is unchanged (checks before any record). A refused HELLO is answered DONE(rate limited) and counts as RATE_LIMITED. An admitted exchange that the link cut (INTERRUPTED or TIMEOUT) before any bundle chunk moved in either direction and before anything was opened gives the 60 s spacing back (the hourly count stays), so the retry after a failed attempt is not refused; a finished, refused, suspended or protocol-failed exchange keeps it.
  • Who offers CHAT. A phone sets the flag only for a contact (or a person verified in person whose card has not arrived), only while it has a reason to stay: that contact's thread is on screen or was left less than 2 minutes ago, or (for the answering phone only) the app is in the foreground. A phone that starts an exchange without a thread of its own does not offer CHAT, so a plain sync between two phones that merely have the app open (the DEBUG sf_sync, auto sync) is a plain exchange that ends when everything is exchanged. A contact whose keys changed is never offered a session. The dialer is the phone whose thread is open; with both threads open the lower node ID dials at once and the higher waits 4 s for that dial, then dials itself. A collision of two simultaneous dials is not resolved inside the handshake: the incoming link is dropped as for any busy endpoint and both sides retry after at least 5.5 s with a 1.5-fold backoff capped at 60 s (always above the relaxed 5 s cooldown). A session that ended at 30 minutes is re-opened 5.5 s later while the thread is open; a plain exchange the contact answered without CHAT is not repeated sooner than 65 s.
  • Ending. The controller ends a session (endChat, DONE(IDLE)) when this phone has had no reason to stay for 2 minutes; the 30 minute maximum and the 60 s liveness / 10 s progress deadlines are the link's own. A call or stream ends it through the existing suspend path (DONE(suspend) within one chunk); the call from an open thread is not special-cased and uses the same path.
  • Cards. With Messaging v2 on, a card received over the authenticated link is judged by the verified-contact store: an identity verified in person is accepted at once (source VERIFIED, the verified label), any other card stays pending behind the safety number. At most 4 CARD records per v2 exchange. The tag table and the admission tier are rebuilt whenever the contact book changes, before the next SUMMARY is read. A card with the same keys and a later created ms only refreshes the stored time. A card with another mailbox key from an accepted contact is held as a pending key change if its created ms is later than the stored card's (an older or equal one is ignored, so a replayed signed card cannot roll a key back); until the user compares the new safety number and accepts it, nothing is sent to that contact (no new message, no offer, WANT answered REFUSE unknown), messages that waited are sealed again under the same message ID (texts) or become "Not sent" (files and the rest), and the old keys remain the only trusted keys for reading. Housekeeping follows the store: a pending card whose identity becomes verified is accepted, a VERIFIED contact whose verification was forgotten is removed (never a hand-accepted one, never while the store is unreadable).
  • Migration from v1 (first v2 start, repeatable): queued unsent v1 texts and inline-sized photos, voice notes and videos are sealed again as version 3 bundles in the contact's thread (shown at the time they were written) and the v1 bundle and Sent row are withdrawn; v1 messages stay readable as legacy rows; receipts, bundles already handed over and anything over the inline limit stay as v1 bundles.
  • Observability. Node.addCompletionListener reports every stored message while an exchange runs (a chat session lasts up to 30 minutes). DEBUG lines: BENCHMARK message_v2 chat_recv (arrival time), chat dial, chat end, chat status, and deliverLatencyMs (queue to delivered on the sender's clock) in sf2_status.

Limits and vectors

Constant Value
Text body 8000 B
Inline attachment 1,000,000 B
Segment data 1,044,480 B (255 x 4096)
Maximum file 64,000,000 B (62 segments)
Name / MIME / thumbnail 96 B / 100 B / 16,384 B
Receipt IDs per receipt 64
Waiting messages per contact 100
Chat session ping / dead / maximum 20 s / 60 s / 30 min
Transfer progress deadline / control records 10 s per chunk / 20 per s and 2,000 per 10 min
Global caps non-contact 64 MB/day, 16 MB/h; contacts 1 GiB/day; 8 bundles in flight; free-space floor 200 MB
Skipped ratchet keys 256 per contact, purged at expiry + 3 h
Prekeys per batch / refill below / outstanding cap 20 (up to 32 allowed) / 8 / 40 per contact, 400 overall; 200 Keystore aliases
Rotating prekey issue period / use window / delete after use window 7 d / 21 d / 15 d

Vectors: tools/messaging_v2_reference.py is an independent implementation of the byte layouts above (it needs the cryptography package for AES-GCM) and writes the fixture MessagingV2Vectors.kt, which MessagingV2VectorTest replays against the app and tools/tests/test_messaging_v2_reference.py keeps in sync. Phase 1a covers a BSM2 TEXT (with and without reply and timer), an inline and a segmented ATTACHMENT, a RECEIPT with two IDs, the segment keys, the Padmé and segment padding sizes, one complete version 3 class 4 bundle (header, bundle ID and chunks) and the BSF1 version 2 messages. Still to generate: hostile names and MIME strings are covered by unit tests rather than vectors, and (phase 2) a version 4 slot with a masked prekey ID and a BPK1 batch (about 0.9 KB for 20 keys).

What a link observer or peer learns. Unchanged from v1 for phase 1: a radio observer sees one call-transport link; the adjacent contact learns only what is sent to it. The chat session adds timing (a link that stays open while a thread is on screen) and ping cadence; it adds no content. Carriers (phase 3) learn what DECISIONS "Design M" M11 lists.