Part of the protocol reference: store-and-forward messages and Messaging v2.
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".
- 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 undernoBackupFilesDir/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 signsBluetoothVideoMesh/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 formBCC1:+ 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 KeystorePURPOSE_AGREE_KEYkey 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.
| 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.
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.
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.
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 countand 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 inSUMMARYand 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.
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 ofu32 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 repeatedfile IDfrom 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 asapplication/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_thumbnailsis 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-256of 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 u16as 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 whendownloaded(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.
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 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 0CHAT(can keep the exchange open), bit 1FILES(understands class 4 and unlistedWANT), bit 2PREKEYS(phase 2), others zero.prekeys heldis 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 answersDONE(error); aHELLOversion 2 sender that receivesDONE(error)as its first answer marks that peer "legacy" for an hour and, within that hour, dials it withHELLOversion 1, a plain v1 exchange that never offers version 3 bundles. A v2 phone that receives aHELLOversion 1 answers withHELLOversion 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-byteHELLOthat 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
CHATbits) either side may send furtherSUMMARYmessages (withlast = 1) at any time untilDONE; 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 sentDONE(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
CANCELit 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
DONEis 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; DEBUGtest_mv2_progress_total=falserestores the fixed 120 s); the tag table is rebuilt when a card is accepted during the session; the responder readsHELLObefore 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
HELLOcarries theCHATbit. 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
WANTis sent or aHEADERaccepted 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 refusesquotaif 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, 60CANCELper minute, 30WANTfor the same bundle per hour, and 30 asymmetric operations per exchange from a non-contact peer; exceeding a budget ends the exchange withDONE(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 withREFUSE quota (2)when that contact's 25% share of the vault is full.
- A receipt
status 1(delivered) is created after the message is stored durably and opened; on a direct link the sender also learns delivery fromDELIVERED_LOCALand the receipt is then not sent. A receiptstatus 2(read) is created only when the receiver's read-receipt setting is on and the message hadWANT_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 receiptstatus 3is 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.
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.
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)withPthe 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.SandRare 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-untiltime 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 afteruse-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 whichprekeys heldfrom 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 afterPREKEY_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-untiland mark it used locally at seal time (never reuse); with none left use the newest rotating prekey inside itsuse-until; with none at all (for example a contact not met for 21 days) the message isPREPARING("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 afterPREKEY_GONEor 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.
k2needsECDH(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 secretKp, 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 aBPK1signature is a non-repudiable statement that the issuer gave these keys to this contact (a small deniability cost); the signature only prevents prekey substitution.
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 prekeySPKand, where one is left, a one-time prekeyOPK.SK = HKDF(DH(S, SPK) || DH(E, R) || DH(E, SPK) || DH(E, OPK))withSandRthe static mailbox keys (these authenticate both sides and keep the exchange deniable) andEa fresh ephemeral key; theDH(E, OPK)term is omitted when no one-time key is left. The responder deletesOPKwhen 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_SKIPis 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 thanMAX_SKIPis 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).
- 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
ACCEPTwith 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):
HELLOcarries 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 meetingHELLOcarries each side's next counter so a stranded window re-aligns over the authenticated link; the daily tag stays valid in parallel.
Where the code made a rule exact, or differs from the first wording above (the design is otherwise followed):
- The
BSM2header is 70 bytes (BSM24, 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 is4 + 70 + 18 + 1,044,480 = 1,044,572bytes 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
SUMMARYanswered by oneWANTeach is ordinary traffic); the hard limit is 2,000 in any 10 minutes.CANCELis limited to 60 per minute andWANTfor 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
SUMMARYand 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
CANCELreleased the serve); any other segmentWANTis not repeated within an exchange. - Receipt status 4 (
key gone),REFUSEreason 9 and thePREKEYSkind are decoded and refused or ignored with a reason; nothing in phase 1a emits them.
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
IHDRfirst, a JPEG a valid marker afterFFD8FF, anftypbox a plausible size, an MPEG audio file two consecutive valid frame headers, anMZfile a PE header where the DOS header points).#!AMRis 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,<scriptand 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 asaudio/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.jpgthat is an ELF file is offered asholiday.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,grantUriPermissionson. A grant is an 18-byte random token incontent://<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 withEIO). Only moder. Bytes come fromStorageManager.openProxyFileDescriptorbacked byFileVault.readBlockwith 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 HEIFispe, or the JPEGSOF), then the picture is decoded withinSampleSizechosen 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 inMessagingV2ImageService(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_isolatedfalse 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 attest_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
APPnandCOMsegment is dropped except the 12-byteAdobeAPP14 (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-byteExifAPP1 holding only that tag followsSOI. 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.
MediaExtractorandMediaMuxercopy the audio and video tracks into a new MP4 (or WebM for a WebM source), copy the rotation as an orientation hint, never callsetLocation, and skip every other track. The output is then scanned: everyudta,meta,uuid,ilst,keysand location, device or title atom under the top level,moovandtrakis renamed tofreewith 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 bysendFile, 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) setsautoDownloadMaxBytes;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.
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
CHATgets 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 answeredDONE(rate limited)and counts asRATE_LIMITED. An admitted exchange that the link cut (INTERRUPTEDorTIMEOUT) 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 offerCHAT, so a plain sync between two phones that merely have the app open (the DEBUGsf_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 withoutCHATis 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 latercreated msonly refreshes the stored time. A card with another mailbox key from an accepted contact is held as a pending key change if itscreated msis 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,WANTansweredREFUSE 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, aVERIFIEDcontact 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.addCompletionListenerreports 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, anddeliverLatencyMs(queue to delivered on the sender's clock) insf2_status.
| 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.