btmessage — Protocol Specification
Reference for implementing a compatible Android (or any platform) client.
1. Transport layer
iOS — MultipeerConnectivity
iOS uses Apple's MultipeerConnectivity framework (Bluetooth LE + WiFi Direct / AWDL).
Service type: btmsg-pqc (Bonjour service name).
Android — required equivalent
Android must use Wi-Fi Direct (P2P) or Bluetooth Classic / BLE to discover and connect to peers on the same local network or in proximity.
The simplest cross-platform path is to run both sides over a local Wi-Fi network using plain TCP sockets or a lightweight protocol (e.g. Bonjour/mDNS for discovery + TCP for data). A dedicated bridging layer would be needed to bridge MultipeerConnectivity ↔ Android transport.
Note: Direct iOS ↔ Android Bluetooth is not natively compatible at the MultipeerConnectivity level. For a fully cross-platform implementation, replace the transport with a shared protocol such as:
- mDNS discovery + TCP/TLS over Wi-Fi
- A relay server (TURN-like) over the internet
2. Peer identity
Each device generates a UUID v4 string on first launch and persists it as its permanent peer ID.
peerID = UUID().uuidString // e.g. "3F2A1B4C-5D6E-7F8A-9B0C-1D2E3F4A5B6C"
- The peer ID is the canonical identifier for routing and key lookup.
- It is also used as the
displayNamefor MultipeerConnectivity. - It is stored in the device Keychain alongside the long-term private key.
3. Wire protocol — MeshEnvelope
Every packet exchanged on the mesh is a JSON-encoded MeshEnvelope.
3.1 Envelope structure
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": 1,
"senderID": "3F2A1B4C-...",
"recipientID": "7A8B9C0D-...",
"ttl": 7,
"payload": "<base64>",
"timestamp": 1712600000.0
}
| Field | Type | Description |
|---|---|---|
id |
String | UUID — used for deduplication across hops |
type |
UInt8 | Message type (see §3.2) |
senderID |
String | Originating peer UUID |
recipientID |
String | Destination peer UUID, "*" for broadcast, or groupID |
ttl |
UInt8 | Time-to-live; decremented at each hop, dropped at 0 |
payload |
Data | JSON base64 — content depends on type |
timestamp |
Double | Unix timestamp (seconds since epoch) of origination |
Encoding: JSONEncoder / JSONDecoder (Swift default). Data fields are base64-encoded by Codable.
3.2 Message types
| Value | Name | Default TTL | Payload |
|---|---|---|---|
1 |
chat |
7 | ChatPayload (see §3.3) |
2 |
keyExchange |
5 | Raw public key bytes (XWing, 1216b) |
3 |
ack |
5 | Message ID (UTF-8 string) |
4 |
discovery |
5 | Empty |
5 |
groupChat |
7 | GroupChatPayload |
6 |
groupKeyDistrib |
5 | GroupKeyPayload |
3.3 ChatPayload structure (DM)
The payload field of a chat envelope contains a JSON-encoded ChatPayload:
{
"messageID": "uuid-string",
"encryptedMessage": {
"kemCiphertext": "<base64 — ~1120 bytes>",
"aesCiphertext": "<base64 — 12 + N + 16 bytes>"
}
}
3.4 GroupChatPayload structure
{
"groupID": "uuid-string",
"messageID": "uuid-string",
"aesCiphertext": "<base64 — 12 + N + 16 bytes>"
}
3.5 GroupKeyPayload structure
Used to distribute group AES keys to members (encrypted with recipient's XWing key):
{
"groupID": "uuid-string",
"groupName": "Group Name",
"memberIDs": ["peer-id-1", "peer-id-2"],
"encryptedGroupKey": {
"kemCiphertext": "<base64>",
"aesCiphertext": "<base64>"
}
}
4. Routing
Flood with TTL
- On receipt of any envelope, check
idagainst a deduplication cache (1000-entry LRU).
If already seen → discard. - Insert
idinto the cache. - If
recipientIDmatches the local peer ID → deliver locally. - Otherwise → decrement TTL; if TTL > 0 forward to all connected peers.
Direct delivery optimisation
If the recipient is a directly connected peer, send only to that peer (skip flood).
Invite tie-breaking (iOS-specific)
To prevent both peers from simultaneously inviting each other:
only the peer whose displayName is lexicographically smaller initiates the connection.
5. Key exchange
Flow
Device A Device B
| |
|--- keyExchange (TTL=5) --------->|
| payload = A's public key |
| |
|<-- keyExchange (TTL=5) ----------|
| payload = B's public key |
- On connection, each peer broadcasts a
keyExchangeenvelope to all peers. keyExchangeenvelopes are also re-broadcast with decremented TTL (mesh propagation).- When a peer receives a
discoveryenvelope, it responds by broadcasting its own public key.
Public key format (XWingMLKEM768X25519)
The payload of a keyExchange envelope is the raw 1216-byte XWing public key (post-quantum hybrid combining ML-KEM-768 and X25519).
payload = xwing_public_key // exactly 1216 bytes
Note: Previous versions used 32-byte X25519 keys. The protocol has been upgraded to use XWingMLKEM768X25519 for post-quantum security.
6. Encryption
Algorithm (XWing KEM)
XWingMLKEM768X25519 + HKDF-SHA256 + AES-256-GCM
XWing is a hybrid post-quantum Key Encapsulation Mechanism combining:
- ML-KEM-768 (Kyber) — lattice-based, quantum-resistant
- X25519 — elliptic curve Diffie-Hellman
Encrypt (sender side)
inputs:
plaintext — UTF-8 message bytes
recipient_pub_key — 1216-byte XWing public key
steps:
1. Load recipient XWing public key:
recipient_pub = XWingMLKEM768X25519.PublicKey(rawRepresentation: recipient_pub_key)
2. Encapsulate (generates shared secret + ciphertext):
encap_result = recipient_pub.encapsulate()
shared_secret = encap_result.sharedSecret // SymmetricKey
kem_ciphertext = encap_result.encapsulated // ~1120 bytes
3. HKDF-SHA256:
symmetric_key = HKDF-SHA256(
ikm = shared_secret bytes,
salt = nil (or ephemeral data),
info = "btmessage-v1",
len = 32
)
4. AES-256-GCM encrypt:
nonce = random 12 bytes
ciphertext = AES-256-GCM.encrypt(key=symmetric_key, nonce=nonce, plaintext=plaintext)
// GCM tag is 16 bytes, appended
5. Serialize aesCiphertext:
aesCiphertext = nonce (12 bytes) || ciphertext (N bytes) || tag (16 bytes)
output:
EncryptedMessage {
kemCiphertext: kem_ciphertext // ~1120 bytes
aesCiphertext: aesCiphertext // 28 + N bytes
}
Decrypt (recipient side)
inputs:
encrypted_message — EncryptedMessage struct
recipient_priv_key — XWing private key
steps:
1. Decapsulate:
shared_secret = recipient_priv_key.decapsulate(encrypted_message.kemCiphertext)
2. HKDF-SHA256:
symmetric_key = HKDF-SHA256(
ikm = shared_secret bytes,
info = "btmessage-v1",
len = 32
)
3. Deserialize aesCiphertext:
nonce = aesCiphertext[0:12]
tag = aesCiphertext[-16:]
ciphertext = aesCiphertext[12:-16]
4. AES-256-GCM decrypt:
plaintext = AES-256-GCM.decrypt(key=symmetric_key, nonce=nonce,
ciphertext=ciphertext, tag=tag)
Group Encryption
Group messages use symmetric AES-256-GCM with a shared group key:
- Group creator generates random 32-byte AES key
- Key is distributed to members via
groupKeyDistrib(encrypted with each member's XWing key) - Group messages encrypted directly with AES-256-GCM (no KEM overhead)
Key sizes summary
| Element | Size |
|---|---|
| XWing public key | 1216 bytes |
| XWing private key | 2432 bytes |
| KEM ciphertext | ~1120 bytes |
| AES-256 key (derived) | 32 bytes |
| AES-GCM nonce | 12 bytes |
| AES-GCM tag | 16 bytes |
7. Security considerations
Key Storage
| Key Type | Storage Method | Protection Level |
|---|---|---|
| Identity (XWing private) | iOS Keychain | kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
| Group keys (AES) | iOS Keychain | kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
| Peer public keys | In-memory only | None (ephemeral) |
Forward Secrecy
Current implementation uses static XWing keys for identity. Forward secrecy is limited — if a private key is compromised, past messages may be decrypted. Future versions should implement ephemeral key exchange per session.
Authentication
The current protocol does not include message signatures. senderID in envelopes is not cryptographically verified. A malicious peer could spoof another peer's ID in the mesh. Applications requiring strong authentication should add Ed25519 signatures to envelopes.
8. Android implementation notes
Recommended libraries
| Component | Android library |
|---|---|
| XWing KEM | BouncyCastle 1.78+ (supports ML-KEM + X25519) |
| HKDF-SHA256 | BouncyCastle or standard javax.crypto |
| AES-256-GCM | javax.crypto.Cipher (standard JCE) |
| JSON | kotlinx.serialization or Gson |
BouncyCastle XWing
// BouncyCastle 1.78+ supports ML-KEM and X25519
// You'll need to implement XWing hybrid construction
val kemParams = MLKEMParameters.ml_kem_768
val x25519Params = X25519KeyGenerationParameters(secureRandom)
// Combine according to XWing specification
Note: As of 2024, native XWing support in BouncyCastle is pending. You may need to implement the hybrid construction manually using ML-KEM-768 + X25519.
Transport for iOS ↔ Android
MultipeerConnectivity is iOS/macOS only. For cross-platform:
-
Same Wi-Fi LAN: Use mDNS (Android
NsdManager) to advertise the service_btmsg-pqc._tcp, then connect via TCP. The JSON envelope format remains identical. -
Over internet: Use a lightweight relay (WebSocket server) — peers exchange envelopes via the relay, same JSON format, same crypto.
9. Versioning
The HKDF info string "btmessage-v1" acts as a protocol version discriminator.
Future breaking changes must increment this value (e.g. "btmessage-v2").
Protocol Versions
| Version | Encryption | Status |
|---|---|---|
| v1 (current) | XWingMLKEM768X25519 | Active |
| v0 (legacy) | X25519 only | Deprecated |
10. Complete message flow example
Alice (iOS) relay/mesh Bob (Android)
| |
|--- keyExchange: Alice_xwing_pub (1216 bytes) ------>|
|<-- keyExchange: Bob_xwing_pub (1216 bytes) ---------|
| |
| Alice types "Hello Bob" |
| → encapsulate with Bob's XWing key |
| → derive AES key via HKDF |
| → AES-256-GCM encrypt |
| |
|--- chat envelope: ChatPayload --------------------- >|
| kemCiphertext: XWing encapsulation (~1120 bytes) |
| aesCiphertext: nonce+ct+tag |
| |
| Bob decapsulates with priv key |
| → derives AES key |
| → decrypts |
| → "Hello Bob" |
| |
|<-- ack envelope: messageID -------------------------|
License
This protocol specification is provided under the same license as the btmessage project (Bastien-mrq License v1.0.0).