Gitfed
bastien-mrq/bt-message / PROTOCOL.md
PROTOCOL.md Code Preview

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 displayName for 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

  1. On receipt of any envelope, check id against a deduplication cache (1000-entry LRU).
    If already seen → discard.
  2. Insert id into the cache.
  3. If recipientID matches the local peer ID → deliver locally.
  4. 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 keyExchange envelope to all peers.
  • keyExchange envelopes are also re-broadcast with decremented TTL (mesh propagation).
  • When a peer receives a discovery envelope, 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:

  1. Group creator generates random 32-byte AES key
  2. Key is distributed to members via groupKeyDistrib (encrypted with each member's XWing key)
  3. 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:

  1. 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.

  2. 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).